diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 3add7cb25..416e1822a 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ 34074F61203D0CBE004596AE /* OWSSounds.m in Sources */ = {isa = PBXBuildFile; fileRef = 34074F5F203D0CBD004596AE /* OWSSounds.m */; }; 34074F62203D0CBE004596AE /* OWSSounds.h in Headers */ = {isa = PBXBuildFile; fileRef = 34074F60203D0CBE004596AE /* OWSSounds.h */; settings = {ATTRIBUTES = (Public, ); }; }; 34080EFE2225F96D0087E99F /* ImageEditorPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34080EFD2225F96D0087E99F /* ImageEditorPaletteView.swift */; }; + 34080F0022282C880087E99F /* AttachmentCaptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34080EFF22282C880087E99F /* AttachmentCaptionViewController.swift */; }; + 34080F02222853E30087E99F /* ImageEditorBrushViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34080F01222853E30087E99F /* ImageEditorBrushViewController.swift */; }; + 34080F04222858DC0087E99F /* OWSViewController+ImageEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34080F03222858DC0087E99F /* OWSViewController+ImageEditor.swift */; }; 340B02BA1FA0D6C700F9CFEC /* ConversationViewItemTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */; }; 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */; }; 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87C204DAC8C007AEB0F /* NotificationSettingsViewController.m */; }; @@ -638,6 +641,9 @@ 34074F5F203D0CBD004596AE /* OWSSounds.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSSounds.m; sourceTree = ""; }; 34074F60203D0CBE004596AE /* OWSSounds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSSounds.h; sourceTree = ""; }; 34080EFD2225F96D0087E99F /* ImageEditorPaletteView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorPaletteView.swift; sourceTree = ""; }; + 34080EFF22282C880087E99F /* AttachmentCaptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentCaptionViewController.swift; sourceTree = ""; }; + 34080F01222853E30087E99F /* ImageEditorBrushViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorBrushViewController.swift; sourceTree = ""; }; + 34080F03222858DC0087E99F /* OWSViewController+ImageEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OWSViewController+ImageEditor.swift"; sourceTree = ""; }; 340B02B61F9FD31800F9CFEC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = translations/he.lproj/Localizable.strings; sourceTree = ""; }; 340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItemTest.m; sourceTree = ""; }; 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NotificationSettingsOptionsViewController.m; sourceTree = ""; }; @@ -1912,6 +1918,7 @@ 34BEDB0C21C405B0007B0EAE /* ImageEditor */ = { isa = PBXGroup; children = ( + 34080F01222853E30087E99F /* ImageEditorBrushViewController.swift */, 34BBC850220B8EEF00857249 /* ImageEditorCanvasView.swift */, 34BBC853220C7ADA00857249 /* ImageEditorContents.swift */, 34BBC84E220B8A0100857249 /* ImageEditorCropViewController.swift */, @@ -1925,6 +1932,7 @@ 34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */, 34BEDB1221C43F69007B0EAE /* ImageEditorView.swift */, 34BBC856220C7ADA00857249 /* OrderedDictionary.swift */, + 34080F03222858DC0087E99F /* OWSViewController+ImageEditor.swift */, ); path = ImageEditor; sourceTree = ""; @@ -2106,6 +2114,7 @@ isa = PBXGroup; children = ( 34AC09D2211B39B000997B47 /* AttachmentApprovalViewController.swift */, + 34080EFF22282C880087E99F /* AttachmentCaptionViewController.swift */, 34AC09CF211B39B000997B47 /* ContactFieldView.swift */, 34AC09CD211B39B000997B47 /* ContactShareApprovalViewController.swift */, 34AC09DB211B39B100997B47 /* CountryCodeViewController.h */, @@ -3328,6 +3337,7 @@ 34AC09E1211B39B100997B47 /* SelectThreadViewController.m in Sources */, 34AC09EF211B39B100997B47 /* ViewControllerUtils.m in Sources */, 346941A2215D2EE400B5BFAD /* OWSConversationColor.m in Sources */, + 34080F0022282C880087E99F /* AttachmentCaptionViewController.swift in Sources */, 34AC0A17211B39EA00997B47 /* VideoPlayerView.swift in Sources */, 34BEDB1321C43F6A007B0EAE /* ImageEditorView.swift in Sources */, 34AC09EE211B39B100997B47 /* EditContactShareNameViewController.swift in Sources */, @@ -3386,6 +3396,7 @@ 34D5872F208E2C4200D2255A /* OWS109OutgoingMessageState.m in Sources */, 34AC09F8211B39B100997B47 /* CountryCodeViewController.m in Sources */, 451F8A341FD710C3005CB9DA /* FullTextSearcher.swift in Sources */, + 34080F04222858DC0087E99F /* OWSViewController+ImageEditor.swift in Sources */, 346129FE1FD5F31400532771 /* OWS106EnsureProfileComplete.swift in Sources */, 34AC0A10211B39EA00997B47 /* TappableView.swift in Sources */, 346129F91FD5F31400532771 /* OWS104CreateRecipientIdentities.m in Sources */, @@ -3405,6 +3416,7 @@ 346941A3215D2EE400B5BFAD /* Theme.m in Sources */, 4C23A5F2215C4ADE00534937 /* SheetViewController.swift in Sources */, 34BBC84D220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift in Sources */, + 34080F02222853E30087E99F /* ImageEditorBrushViewController.swift in Sources */, 34AC0A14211B39EA00997B47 /* ContactCellView.m in Sources */, 34AC0A15211B39EA00997B47 /* ContactsViewHelper.m in Sources */, 346129FF1FD5F31400532771 /* OWS103EnableVideoCalling.m in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/Cells/ConversationMediaView.swift b/Signal/src/ViewControllers/ConversationView/Cells/ConversationMediaView.swift index 05ad14a1e..39c31fe4e 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/ConversationMediaView.swift +++ b/Signal/src/ViewControllers/ConversationView/Cells/ConversationMediaView.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation diff --git a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift index 53b149cc9..776aa4295 100644 --- a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift +++ b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift @@ -210,13 +210,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.navigationItem.title = nil - if mode != .sharedNavigation { - let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, - target: self, action: #selector(cancelPressed)) - cancelButton.tintColor = .white - self.navigationItem.leftBarButtonItem = cancelButton - } - guard let firstItem = attachmentItems.first else { owsFailDebug("firstItem was unexpectedly nil") return @@ -226,49 +219,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // layout immediately to avoid animating the layout process during the transition self.currentPageViewController.view.layoutIfNeeded() - - // As a refresher, the _Information Architecture_ here is: - // - // You are approving an "Album", which has multiple "Attachments" - // - // The "media message text" and the "media rail" belong to the Album as a whole, whereas - // each caption belongs to the individual Attachment. - // - // The _UI Architecture_ reflects this hierarchy by putting the MediaRail and - // MediaMessageText input into the bottomToolView which is then the AttachmentApprovalView's - // inputAccessoryView. - // - // Whereas a CaptionView lives in each page of the PageViewController, per Attachment. - // - // So as you page, the CaptionViews move out of view with its page, whereas the input - // accessory view (rail/media message text) will remain fixed in the viewport. - // - // However (and here's the kicker), at rest, the media's CaptionView rests just above the - // input accessory view. So when things are static, they appear as a single piece of - // interface. - // - // I'm not totally sure if this is what Myles had in mind, but the screenshots left a lot of - // behavior ambiguous, and this was my best interpretation. - // - // Because of this complexity, it is insufficient to observe only the - // KeyboardWillChangeFrame, since the keyboard could be changing frame when the CaptionView - // became/resigned first responder, when AttachmentApprovalViewController became/resigned - // first responder, or when the AttachmentApprovalView's inputAccessoryView.textView - // became/resigned first responder, and because these things can happen in immediatre - // sequence, getting a single smooth animation requires handling each notification slightly - // differently. - NotificationCenter.default.addObserver(self, - selector: #selector(keyboardWillShow(notification:)), - name: .UIKeyboardWillShow, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(keyboardDidShow(notification:)), - name: .UIKeyboardDidShow, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(keyboardWillHide(notification:)), - name: .UIKeyboardWillHide, - object: nil) } override public func viewWillAppear(_ animated: Bool) { @@ -283,13 +233,15 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } navigationBar.overrideTheme(type: .clear) - updateCaptionVisibility() + updateNavigationBar() } override public func viewDidAppear(_ animated: Bool) { Logger.debug("") super.viewDidAppear(animated) + + updateNavigationBar() } override public func viewWillDisappear(_ animated: Bool) { @@ -310,64 +262,37 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return true } - var lastObservedKeyboardTop: CGFloat = 0 - var inputAccessorySnapshotView: UIView? - - @objc - func keyboardDidShow(notification: Notification) { - // If this is a result of the vc becoming first responder, the keyboard isn't actually - // showing, rather the inputAccessoryView is now showing, so we want to remove any - // previously added toolbar snapshot. - if isFirstResponder, inputAccessorySnapshotView != nil { - removeToolbarSnapshot() - } - } - - @objc - func keyboardWillShow(notification: Notification) { - guard let userInfo = notification.userInfo else { - owsFailDebug("userInfo was unexpectedly nil") - return - } + // MARK: - Navigation Bar - guard let keyboardStartFrame = userInfo[UIKeyboardFrameBeginUserInfoKey] as? CGRect else { - owsFailDebug("keyboardEndFrame was unexpectedly nil") - return - } + public func updateNavigationBar() { + var navigationBarItems = [UIView]() + var isShowingCaptionView = false - guard let keyboardEndFrame = userInfo[UIKeyboardFrameEndUserInfoKey] as? CGRect else { - owsFailDebug("keyboardEndFrame was unexpectedly nil") - return + if let viewControllers = viewControllers, + viewControllers.count == 1, + let firstViewController = viewControllers.first as? AttachmentPrepViewController { + navigationBarItems = firstViewController.navigationBarItems() + isShowingCaptionView = firstViewController.isShowingCaptionView } - Logger.debug("\(keyboardStartFrame) -> \(keyboardEndFrame)") - lastObservedKeyboardTop = keyboardEndFrame.size.height - - let keyboardScenario: KeyboardScenario = bottomToolView.isEditingMediaMessage ? .editingMessage : .editingCaption - currentPageViewController.updateCaptionViewBottomInset(keyboardScenario: keyboardScenario) - } - - @objc - func keyboardWillHide(notification: Notification) { - guard let userInfo = notification.userInfo else { - owsFailDebug("userInfo was unexpectedly nil") + guard !isShowingCaptionView else { + // Hide all navigation bar items while the caption view is open. + self.navigationItem.leftBarButtonItem = nil + self.navigationItem.rightBarButtonItem = nil return } - guard let keyboardStartFrame = userInfo[UIKeyboardFrameBeginUserInfoKey] as? CGRect else { - owsFailDebug("keyboardEndFrame was unexpectedly nil") - return - } + updateNavigationBar(navigationBarItems: navigationBarItems) - guard let keyboardEndFrame = userInfo[UIKeyboardFrameEndUserInfoKey] as? CGRect else { - owsFailDebug("keyboardEndFrame was unexpectedly nil") - return + let hasCancel = (mode != .sharedNavigation) + if hasCancel { + let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, action: #selector(cancelPressed)) + cancelButton.tintColor = .white + self.navigationItem.leftBarButtonItem = cancelButton + } else { + self.navigationItem.leftBarButtonItem = nil } - - Logger.debug("\(keyboardStartFrame) -> \(keyboardEndFrame)") - - lastObservedKeyboardTop = UIScreen.main.bounds.height - keyboardEndFrame.size.height - currentPageViewController.updateCaptionViewBottomInset(keyboardScenario: .hidden) } // MARK: - View Helpers @@ -412,12 +337,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return pagerScrollView }() - func updateCaptionVisibility() { - for pageViewController in pageViewControllers { - pageViewController.updateCaptionVisibility(attachmentCount: attachments.count) - } - } - // MARK: - UIPageViewControllerDelegate public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { @@ -433,9 +352,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // use compact scale when keyboard is popped. let scale: AttachmentPrepViewController.AttachmentViewScale = self.isFirstResponder ? .fullsize : .compact pendingPage.setAttachmentViewScale(scale, animated: false) - - let keyboardScenario: KeyboardScenario = bottomToolView.isEditingMediaMessage ? .editingMessage : .hidden - pendingPage.updateCaptionViewBottomInset(keyboardScenario: keyboardScenario) } } @@ -454,6 +370,8 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC updateMediaRail() } } + + updateNavigationBar() } // MARK: - UIPageViewControllerDataSource @@ -524,7 +442,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC Logger.debug("cache miss.") let viewController = AttachmentPrepViewController(attachmentItem: item) viewController.prepDelegate = self - viewController.updateCaptionVisibility(attachmentCount: attachments.count) cachedPages[item] = viewController return viewController @@ -537,8 +454,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } page.loadViewIfNeeded() - let keyboardScenario: KeyboardScenario = bottomToolView.isEditingMediaMessage ? .editingMessage : .hidden - page.updateCaptionViewBottomInset(keyboardScenario: keyboardScenario) self.setViewControllers([page], direction: direction, animated: isAnimated, completion: nil) updateMediaRail() @@ -700,72 +615,8 @@ extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate self.approvalDelegate?.attachmentApproval?(self, changedCaptionOfAttachment: attachmentItem.attachment) } - func prepViewController(_ prepViewController: AttachmentPrepViewController, willBeginEditingCaptionView captionView: CaptionView) { - // When the CaptionView becomes first responder, the AttachmentApprovalViewController will - // consequently resignFirstResponder, which means the bottomToolView would disappear from - // the screen, so before that happens, we add a snapshot to holds it's place. - addInputAccessorySnapshot() - } - - func prepViewController(_ prepViewController: AttachmentPrepViewController, didBeginEditingCaptionView captionView: CaptionView) { - // Disable paging while captions are being edited to avoid a clunky animation. - // - // Loading the next page causes the CaptionView to resign first responder, which in turn - // dismisses the keyboard, which in turn affects the vertical offset of both the CaptionView - // from the page we're leaving as well as the page we're entering. Instead we require the - // user to dismiss *then* swipe. - disablePaging() - } - - func addInputAccessorySnapshot() { - assert(inputAccessorySnapshotView == nil) - // To fix a layout glitch where the snapshot view is 1/2 the width of the screen, it's key - // that we use `bottomToolView` and not `inputAccessoryView` which can trigger a layout of - // the `bottomToolView`. - // Presumably the frame of the inputAccessoryView has just changed because we're in the - // middle of switching first responders. We want a snapshot as it *was*, not reflecting any - // just-applied superview layout changes. - inputAccessorySnapshotView = bottomToolView.snapshotView(afterScreenUpdates: true) - guard let inputAccessorySnapshotView = inputAccessorySnapshotView else { - owsFailDebug("inputAccessorySnapshotView was unexpectedly nil") - return - } - - view.addSubview(inputAccessorySnapshotView) - inputAccessorySnapshotView.autoSetDimension(.height, toSize: bottomToolView.bounds.height) - inputAccessorySnapshotView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .top) - } - - func removeToolbarSnapshot() { - guard let inputAccessorySnapshotView = self.inputAccessorySnapshotView else { - owsFailDebug("inputAccessorySnapshotView was unexpectedly nil") - return - } - inputAccessorySnapshotView.removeFromSuperview() - self.inputAccessorySnapshotView = nil - } - - func prepViewController(_ prepViewController: AttachmentPrepViewController, didEndEditingCaptionView captionView: CaptionView) { - enablePaging() - } - - func desiredCaptionViewBottomInset(keyboardScenario: KeyboardScenario) -> CGFloat { - switch keyboardScenario { - case .hidden, .editingMessage: - return bottomToolView.bounds.height - case .editingCaption: - return lastObservedKeyboardTop - } - } - - // MARK: Helpers - - func disablePaging() { - pagerScrollView?.panGestureRecognizer.isEnabled = false - } - - func enablePaging() { - pagerScrollView?.panGestureRecognizer.isEnabled = true + func prepViewControllerUpdateNavigationBar() { + self.updateNavigationBar() } } @@ -819,11 +670,7 @@ enum KeyboardScenario { protocol AttachmentPrepViewControllerDelegate: class { func prepViewController(_ prepViewController: AttachmentPrepViewController, didUpdateCaptionForAttachmentItem attachmentItem: SignalAttachmentItem) - func prepViewController(_ prepViewController: AttachmentPrepViewController, willBeginEditingCaptionView captionView: CaptionView) - func prepViewController(_ prepViewController: AttachmentPrepViewController, didBeginEditingCaptionView captionView: CaptionView) - func prepViewController(_ prepViewController: AttachmentPrepViewController, didEndEditingCaptionView captionView: CaptionView) - - func desiredCaptionViewBottomInset(keyboardScenario: KeyboardScenario) -> CGFloat + func prepViewControllerUpdateNavigationBar() } public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarDelegate, OWSVideoPlayerDelegate { @@ -848,6 +695,13 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD private(set) var scrollView: UIScrollView! private(set) var contentContainer: UIView! private(set) var playVideoButton: UIView? + private var imageEditorView: ImageEditorView? + + fileprivate var isShowingCaptionView = false { + didSet { + prepDelegate?.prepViewControllerUpdateNavigationBar() + } + } // MARK: - Initializers @@ -861,30 +715,9 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD fatalError("init(coder:) has not been implemented") } - func updateCaptionVisibility(attachmentCount: Int) { - if attachmentCount > 1 { - captionView.isHidden = false - return - } - - // If we previously had multiple attachments, we'd have shown the caption fields. - // - // Subsequently, if the user had added caption text, then removed the other attachments - // we will continue to show this caption field, so as not to hide any already-entered text. - if let captionText = captionView.captionText, captionText.count > 0 { - captionView.isHidden = false - return - } - - captionView.isHidden = true - } - // MARK: - Subviews - lazy var captionView: CaptionView = { - return CaptionView(attachmentItem: attachmentItem) - }() - + // TODO: Do we still need this? lazy var touchInterceptorView: UIView = { let touchInterceptorView = UIView() let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapTouchInterceptorView(gesture:))) @@ -938,20 +771,16 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD mediaMessageView.autoPinEdgesToSuperviewEdges() #if DEBUG - if let imageEditorModel = attachmentItem.imageEditorModel, - let imageMediaView = mediaMessageView.contentView { + if let imageEditorModel = attachmentItem.imageEditorModel { let imageEditorView = ImageEditorView(model: imageEditorModel, delegate: self) if imageEditorView.configureSubviews() { - mediaMessageView.isHidden = true + self.imageEditorView = imageEditorView - // TODO: Is this necessary? - imageMediaView.isUserInteractionEnabled = true + mediaMessageView.isHidden = true - contentContainer.addSubview(imageEditorView) - imageEditorView.autoPin(toTopLayoutGuideOf: self, withInset: 0) - autoPinView(toBottomOfViewControllerOrKeyboard: imageEditorView, avoidNotch: true) - imageEditorView.autoPinWidthToSuperview() + view.addSubview(imageEditorView) + imageEditorView.autoPinEdgesToSuperviewEdges() imageEditorView.addControls(to: imageEditorView, viewController: self) @@ -1023,12 +852,22 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD view.addSubview(touchInterceptorView) touchInterceptorView.autoPinEdgesToSuperviewEdges() touchInterceptorView.isHidden = true + } + + override public func viewWillAppear(_ animated: Bool) { + Logger.debug("") + + super.viewWillAppear(animated) + + prepDelegate?.prepViewControllerUpdateNavigationBar() + } + + override public func viewDidAppear(_ animated: Bool) { + Logger.debug("") - view.addSubview(captionView) - captionView.delegate = self + super.viewDidAppear(animated) - captionView.autoPinWidthToSuperview() - captionViewBottomConstraint = captionView.autoPinEdge(toSuperviewEdge: .bottom) + prepDelegate?.prepViewControllerUpdateNavigationBar() } override public func viewWillLayoutSubviews() { @@ -1041,32 +880,13 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD ensureAttachmentViewScale(animated: false) } - // MARK: CaptionView lifts with keyboard - - var hasLaidOutCaptionView: Bool = false - var captionViewBottomConstraint: NSLayoutConstraint! - func updateCaptionViewBottomInset(keyboardScenario: KeyboardScenario) { - guard let prepDelegate = self.prepDelegate else { - owsFailDebug("prepDelegate was unexpectedly nil") - return - } - - let changeBlock = { - let offset: CGFloat = -1 * prepDelegate.desiredCaptionViewBottomInset(keyboardScenario: keyboardScenario) - self.captionViewBottomConstraint.constant = offset - self.captionView.superview?.layoutIfNeeded() - } + // MARK: - Navigation Bar - // To avoid an animation glitch, we apply this update without animation before initial - // appearance. But after that, we want to apply the constraint change within the existing - // animation context, since we call this while handling a UIKeyboard notification, which - // allows us to slide up the CaptionView in lockstep with the keyboard. - if hasLaidOutCaptionView { - changeBlock() - } else { - hasLaidOutCaptionView = true - UIView.performWithoutAnimation { changeBlock() } + public func navigationBarItems() -> [UIView] { + guard let imageEditorView = imageEditorView else { + return [] } + return imageEditorView.navigationBarItems() } // MARK: - Event Handlers @@ -1074,7 +894,6 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD @objc func didTapTouchInterceptorView(gesture: UITapGestureRecognizer) { Logger.info("") - captionView.endEditing() touchInterceptorView.isHidden = true } @@ -1228,29 +1047,17 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD } } -extension AttachmentPrepViewController: CaptionViewDelegate { - func captionViewWillBeginEditing(_ captionView: CaptionView) { - prepDelegate?.prepViewController(self, willBeginEditingCaptionView: captionView) - } - - func captionView(_ captionView: CaptionView, didChangeCaptionText captionText: String?, attachmentItem: SignalAttachmentItem) { +extension AttachmentPrepViewController: AttachmentCaptionDelegate { + func captionView(_ captionView: AttachmentCaptionViewController, didChangeCaptionText captionText: String?, attachmentItem: SignalAttachmentItem) { let attachment = attachmentItem.attachment attachment.captionText = captionText prepDelegate?.prepViewController(self, didUpdateCaptionForAttachmentItem: attachmentItem) - } - func captionViewDidBeginEditing(_ captionView: CaptionView) { - // Don't allow user to pan until they've dismissed the keyboard. - // This avoids a really ugly animation from simultaneously dismissing the keyboard - // while loading a new PrepViewController, and it's CaptionView, whose layout depends - // on the keyboard's position. - touchInterceptorView.isHidden = false - prepDelegate?.prepViewController(self, didBeginEditingCaptionView: captionView) + isShowingCaptionView = false } - func captionViewDidEndEditing(_ captionView: CaptionView) { - touchInterceptorView.isHidden = true - prepDelegate?.prepViewController(self, didEndEditingCaptionView: captionView) + func captionViewDidCancel() { + isShowingCaptionView = false } } @@ -1331,15 +1138,33 @@ extension AttachmentPrepViewController: ImageEditorViewDelegate { if withNavigation { let navigationController = OWSNavigationController(rootViewController: viewController) navigationController.modalPresentationStyle = .overFullScreen - self.present(navigationController, animated: true) { + + if let navigationBar = navigationController.navigationBar as? OWSNavigationBar { + navigationBar.overrideTheme(type: .clear) + } else { + owsFailDebug("navigationBar was nil or unexpected class") + } + + self.present(navigationController, animated: false) { // Do nothing. } } else { - self.present(viewController, animated: true) { + self.present(viewController, animated: false) { // Do nothing. } } } + + public func imageEditorPresentCaptionView() { + let view = AttachmentCaptionViewController(delegate: self, attachmentItem: attachmentItem) + self.imageEditor(presentFullScreenOverlay: view, withNavigation: true) + + isShowingCaptionView = true + } + + public func imageEditorUpdateNavigationBar() { + prepDelegate?.prepViewControllerUpdateNavigationBar() + } } // MARK: - @@ -1393,251 +1218,9 @@ class BottomToolView: UIView { } } -protocol CaptionViewDelegate: class { - func captionView(_ captionView: CaptionView, didChangeCaptionText captionText: String?, attachmentItem: SignalAttachmentItem) - func captionViewWillBeginEditing(_ captionView: CaptionView) - func captionViewDidBeginEditing(_ captionView: CaptionView) - func captionViewDidEndEditing(_ captionView: CaptionView) -} - -class CaptionView: UIView { - - var captionText: String? { - get { return textView.text } - set { - textView.text = newValue - updatePlaceholderTextViewVisibility() - } - } - - let attachmentItem: SignalAttachmentItem - var attachment: SignalAttachment { - return attachmentItem.attachment - } - - weak var delegate: CaptionViewDelegate? - - private let kMinTextViewHeight: CGFloat = 38 - private var textViewHeightConstraint: NSLayoutConstraint! - - private lazy var lengthLimitLabel: UILabel = { - let lengthLimitLabel = UILabel() - - // Length Limit Label shown when the user inputs too long of a message - lengthLimitLabel.textColor = .white - lengthLimitLabel.text = NSLocalizedString("ATTACHMENT_APPROVAL_CAPTION_LENGTH_LIMIT_REACHED", comment: "One-line label indicating the user can add no more text to the attachment caption.") - lengthLimitLabel.textAlignment = .center - - // Add shadow in case overlayed on white content - lengthLimitLabel.layer.shadowColor = UIColor.black.cgColor - lengthLimitLabel.layer.shadowOffset = CGSize(width: 0.0, height: 0.0) - lengthLimitLabel.layer.shadowOpacity = 0.8 - lengthLimitLabel.isHidden = true - - return lengthLimitLabel - }() - - // MARK: Initializers - - init(attachmentItem: SignalAttachmentItem) { - self.attachmentItem = attachmentItem - - super.init(frame: .zero) - - backgroundColor = UIColor.black.withAlphaComponent(0.6) - - self.captionText = attachmentItem.captionText - textView.delegate = self - - let textContainer = UIView() - textContainer.addSubview(placeholderTextView) - placeholderTextView.autoPinEdgesToSuperviewEdges() - - textContainer.addSubview(textView) - textView.autoPinEdgesToSuperviewEdges() - textViewHeightConstraint = textView.autoSetDimension(.height, toSize: kMinTextViewHeight) - - let hStack = UIStackView(arrangedSubviews: [addCaptionButton, textContainer, doneButton]) - doneButton.isHidden = true - - addSubview(hStack) - hStack.autoPinEdgesToSuperviewMargins() - - addSubview(lengthLimitLabel) - lengthLimitLabel.autoPinEdge(toSuperviewMargin: .left) - lengthLimitLabel.autoPinEdge(toSuperviewMargin: .right) - lengthLimitLabel.autoPinEdge(.bottom, to: .top, of: textView, withOffset: -9) - lengthLimitLabel.setContentHuggingHigh() - lengthLimitLabel.setCompressionResistanceHigh() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - - func endEditing() { - textView.resignFirstResponder() - } - - override var inputAccessoryView: UIView? { - // Don't inherit the vc's inputAccessoryView - return nil - } - - // MARK: Subviews - - func updatePlaceholderTextViewVisibility() { - let isHidden: Bool = { - guard !self.textView.isFirstResponder else { - return true - } - - guard let captionText = self.textView.text else { - return false - } - - guard captionText.count > 0 else { - return false - } - - return true - }() - - placeholderTextView.isHidden = isHidden - } - - private lazy var placeholderTextView: UITextView = { - let placeholderTextView = UITextView() - placeholderTextView.text = NSLocalizedString("ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER", comment: "placeholder text for an empty captioning field") - placeholderTextView.isEditable = false - - placeholderTextView.backgroundColor = .clear - placeholderTextView.font = UIFont.ows_dynamicTypeBody - - placeholderTextView.textColor = Theme.darkThemePrimaryColor - placeholderTextView.tintColor = Theme.darkThemePrimaryColor - placeholderTextView.returnKeyType = .done - - return placeholderTextView - }() - - private lazy var textView: UITextView = { - let textView = UITextView() - textView.backgroundColor = .clear - textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance - textView.font = UIFont.ows_dynamicTypeBody - textView.textColor = Theme.darkThemePrimaryColor - textView.tintColor = Theme.darkThemePrimaryColor - - return textView - }() - - lazy var addCaptionButton: UIButton = { - let addCaptionButton = OWSButton { [weak self] in - self?.textView.becomeFirstResponder() - } - - let icon = #imageLiteral(resourceName: "ic_add_caption").withRenderingMode(.alwaysTemplate) - addCaptionButton.setImage(icon, for: .normal) - addCaptionButton.tintColor = Theme.darkThemePrimaryColor - - return addCaptionButton - }() - - lazy var doneButton: UIButton = { - let doneButton = OWSButton { [weak self] in - self?.textView.resignFirstResponder() - } - doneButton.setTitle(CommonStrings.doneButton, for: .normal) - doneButton.tintColor = Theme.darkThemePrimaryColor - - return doneButton - }() -} - -let kMaxCaptionCharacterCount = 240 - // Coincides with Android's max text message length let kMaxMessageBodyCharacterCount = 2000 -extension CaptionView: UITextViewDelegate { - - public func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { - delegate?.captionViewWillBeginEditing(self) - return true - } - - public func textViewDidBeginEditing(_ textView: UITextView) { - updatePlaceholderTextViewVisibility() - doneButton.isHidden = false - addCaptionButton.isHidden = true - - delegate?.captionViewDidBeginEditing(self) - } - - public func textViewDidEndEditing(_ textView: UITextView) { - updatePlaceholderTextViewVisibility() - doneButton.isHidden = true - addCaptionButton.isHidden = false - - delegate?.captionViewDidEndEditing(self) - } - - public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - let existingText: String = textView.text ?? "" - let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) - - let kMaxCaptionByteCount = kOversizeTextMessageSizeThreshold / 4 - guard proposedText.utf8.count <= kMaxCaptionByteCount else { - Logger.debug("hit caption byte count limit") - self.lengthLimitLabel.isHidden = false - - // `range` represents the section of the existing text we will replace. We can re-use that space. - // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be - // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is - // to just measure the utf8 encoded bytes of the replaced substring. - let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - - // Accept as much of the input as we can - let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete - if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false - } - - // After verifying the byte-length is sufficiently small, verify the character count is within bounds. - // Normally this character count should entail *much* less byte count. - guard proposedText.count <= kMaxCaptionCharacterCount else { - Logger.debug("hit caption character count limit") - - self.lengthLimitLabel.isHidden = false - - // `range` represents the section of the existing text we will replace. We can re-use that space. - let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count - - // Accept as much of the input as we can - let charBudget: Int = Int(kMaxCaptionCharacterCount) - charsAfterDelete - if charBudget >= 0 { - let acceptableNewText = String(text.prefix(charBudget)) - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false - } - - self.lengthLimitLabel.isHidden = true - return true - } - - public func textViewDidChange(_ textView: UITextView) { - self.delegate?.captionView(self, didChangeCaptionText: textView.text, attachmentItem: attachmentItem) - } -} - protocol MediaMessageTextToolbarDelegate: class { func mediaMessageTextToolbarDidTapSend(_ mediaMessageTextToolbar: MediaMessageTextToolbar) func mediaMessageTextToolbarDidBeginEditing(_ mediaMessageTextToolbar: MediaMessageTextToolbar) @@ -1942,11 +1525,11 @@ class MediaMessageTextToolbar: UIView, UITextViewDelegate { return true } - guard let captionText = self.textView.text else { + guard let text = self.textView.text else { return false } - guard captionText.count > 0 else { + guard text.count > 0 else { return false } diff --git a/SignalMessaging/ViewControllers/AttachmentCaptionViewController.swift b/SignalMessaging/ViewControllers/AttachmentCaptionViewController.swift new file mode 100644 index 000000000..c54a8e31c --- /dev/null +++ b/SignalMessaging/ViewControllers/AttachmentCaptionViewController.swift @@ -0,0 +1,310 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +protocol AttachmentCaptionDelegate: class { + func captionView(_ captionView: AttachmentCaptionViewController, didChangeCaptionText captionText: String?, attachmentItem: SignalAttachmentItem) + func captionViewDidCancel() +} + +class AttachmentCaptionViewController: OWSViewController { + + weak var delegate: AttachmentCaptionDelegate? + + private let attachmentItem: SignalAttachmentItem + + private let originalCaptionText: String? + + private let textView = UITextView() + + private var textViewHeightConstraint: NSLayoutConstraint? + + private let kMaxCaptionCharacterCount = 240 + + init(delegate: AttachmentCaptionDelegate, + attachmentItem: SignalAttachmentItem) { + self.delegate = delegate + self.attachmentItem = attachmentItem + self.originalCaptionText = attachmentItem.captionText + + super.init(nibName: nil, bundle: nil) + + self.addObserver(textView, forKeyPath: "contentSize", options: .new, context: nil) + } + + @available(*, unavailable, message: "use other init() instead.") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } + + deinit { + self.removeObserver(textView, forKeyPath: "contentSize") + } + + open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + updateTextView() + } + + // MARK: - View Lifecycle + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + textView.becomeFirstResponder() + + updateTextView() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + textView.becomeFirstResponder() + + updateTextView() + } + + public override func loadView() { + self.view = UIView() + self.view.backgroundColor = UIColor(white: 0, alpha: 0.25) + self.view.isOpaque = false + + self.view.isUserInteractionEnabled = true + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(backgroundTapped))) + + configureTextView() + + let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, + action: #selector(didTapCancel)) + cancelButton.tintColor = .white + navigationItem.leftBarButtonItem = cancelButton + let doneIcon = UIImage(named: "image_editor_checkmark_full")?.withRenderingMode(.alwaysTemplate) + let doneButton = UIBarButtonItem(image: doneIcon, style: .plain, + target: self, + action: #selector(didTapDone)) + doneButton.tintColor = .white + navigationItem.rightBarButtonItem = doneButton + + self.view.layoutMargins = .zero + + lengthLimitLabel.setContentHuggingHigh() + lengthLimitLabel.setCompressionResistanceHigh() + + let stackView = UIStackView(arrangedSubviews: [lengthLimitLabel, textView]) + stackView.axis = .vertical + stackView.spacing = 20 + stackView.alignment = .fill + stackView.addBackgroundView(withBackgroundColor: UIColor(white: 0, alpha: 0.5)) + stackView.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) + stackView.isLayoutMarginsRelativeArrangement = true + self.view.addSubview(stackView) + stackView.autoPinEdge(toSuperviewEdge: .leading) + stackView.autoPinEdge(toSuperviewEdge: .trailing) + self.autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true) + + let minTextHeight: CGFloat = textView.font?.lineHeight ?? 0 + textViewHeightConstraint = textView.autoSetDimension(.height, toSize: minTextHeight) + + view.addSubview(placeholderTextView) + placeholderTextView.autoAlignAxis(.horizontal, toSameAxisOf: textView) + placeholderTextView.autoPinEdge(.leading, to: .leading, of: textView) + placeholderTextView.autoPinEdge(.trailing, to: .trailing, of: textView) + } + + private func configureTextView() { + textView.delegate = self + + textView.text = attachmentItem.captionText + textView.font = UIFont.ows_dynamicTypeBody + textView.textColor = .white + + textView.isEditable = true + textView.backgroundColor = .clear + textView.isOpaque = false + // We use a white cursor since we use a dark background. + textView.tintColor = .white + textView.isScrollEnabled = true + textView.scrollsToTop = false + textView.isUserInteractionEnabled = true + textView.textAlignment = .left + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.contentInset = .zero + } + + // MARK: - Events + + @objc func backgroundTapped(sender: UIGestureRecognizer) { + AssertIsOnMainThread() + + completeAndDismiss(didCancel: false) + } + + @objc public func didTapCancel() { + completeAndDismiss(didCancel: true) + } + + @objc public func didTapDone() { + completeAndDismiss(didCancel: false) + } + + private func completeAndDismiss(didCancel: Bool) { + if didCancel { + self.delegate?.captionViewDidCancel() + } else { + self.delegate?.captionView(self, didChangeCaptionText: self.textView.text, attachmentItem: attachmentItem) + } + + self.dismiss(animated: true) { + // Do nothing. + } + } + + // MARK: - Length Limit + + private lazy var lengthLimitLabel: UILabel = { + let lengthLimitLabel = UILabel() + + // Length Limit Label shown when the user inputs too long of a message + lengthLimitLabel.textColor = UIColor.ows_destructiveRed + lengthLimitLabel.text = NSLocalizedString("ATTACHMENT_APPROVAL_CAPTION_LENGTH_LIMIT_REACHED", comment: "One-line label indicating the user can add no more text to the attachment caption.") + lengthLimitLabel.textAlignment = .center + + // Add shadow in case overlayed on white content + lengthLimitLabel.layer.shadowColor = UIColor.black.cgColor + lengthLimitLabel.layer.shadowOffset = CGSize(width: 0.0, height: 0.0) + lengthLimitLabel.layer.shadowOpacity = 0.8 + lengthLimitLabel.isHidden = true + + return lengthLimitLabel + }() + + // MARK: - Text Height + + // TODO: We need to revisit this with Myles. + func updatePlaceholderTextViewVisibility() { + let isHidden: Bool = { + guard !self.textView.isFirstResponder else { + return true + } + + guard let captionText = self.textView.text else { + return false + } + + guard captionText.count > 0 else { + return false + } + + return true + }() + + placeholderTextView.isHidden = isHidden + } + + private lazy var placeholderTextView: UIView = { + let placeholderTextView = UITextView() + placeholderTextView.text = NSLocalizedString("ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER", comment: "placeholder text for an empty captioning field") + placeholderTextView.isEditable = false + + placeholderTextView.backgroundColor = .clear + placeholderTextView.font = UIFont.ows_dynamicTypeBody + + placeholderTextView.textColor = Theme.darkThemePrimaryColor + placeholderTextView.tintColor = Theme.darkThemePrimaryColor + placeholderTextView.returnKeyType = .done + + return placeholderTextView + }() + + // MARK: - Text Height + + private func updateTextView() { + guard let textViewHeightConstraint = textViewHeightConstraint else { + owsFailDebug("Missing textViewHeightConstraint.") + return + } + + let contentSize = textView.sizeThatFits(CGSize(width: textView.width(), height: CGFloat.greatestFiniteMagnitude)) + + // `textView.contentSize` isn't accurate when restoring a multiline draft, so we compute it here. + textView.contentSize = contentSize + + let minHeight: CGFloat = textView.font?.lineHeight ?? 0 + let maxHeight: CGFloat = 300 + let newHeight = contentSize.height.clamp(minHeight, maxHeight) + + textViewHeightConstraint.constant = newHeight + textView.invalidateIntrinsicContentSize() + textView.superview?.invalidateIntrinsicContentSize() + + textView.isScrollEnabled = contentSize.height > maxHeight + + updatePlaceholderTextViewVisibility() + } +} + +extension AttachmentCaptionViewController: UITextViewDelegate { + + public func textViewDidChange(_ textView: UITextView) { + updateTextView() + } + + public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + let existingText: String = textView.text ?? "" + let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) + + let kMaxCaptionByteCount = kOversizeTextMessageSizeThreshold / 4 + guard proposedText.utf8.count <= kMaxCaptionByteCount else { + Logger.debug("hit caption byte count limit") + self.lengthLimitLabel.isHidden = false + + // `range` represents the section of the existing text we will replace. We can re-use that space. + // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be + // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is + // to just measure the utf8 encoded bytes of the replaced substring. + let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count + + // Accept as much of the input as we can + let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete + if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { + textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) + } + + return false + } + + // After verifying the byte-length is sufficiently small, verify the character count is within bounds. + // Normally this character count should entail *much* less byte count. + guard proposedText.count <= kMaxCaptionCharacterCount else { + Logger.debug("hit caption character count limit") + + self.lengthLimitLabel.isHidden = false + + // `range` represents the section of the existing text we will replace. We can re-use that space. + let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count + + // Accept as much of the input as we can + let charBudget: Int = Int(kMaxCaptionCharacterCount) - charsAfterDelete + if charBudget >= 0 { + let acceptableNewText = String(text.prefix(charBudget)) + textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) + } + + return false + } + + self.lengthLimitLabel.isHidden = true + return true + } + + public func textViewDidBeginEditing(_ textView: UITextView) { + updatePlaceholderTextViewVisibility() + } + + public func textViewDidEndEditing(_ textView: UITextView) { + updatePlaceholderTextViewVisibility() + } +} diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorBrushViewController.swift b/SignalMessaging/Views/ImageEditor/ImageEditorBrushViewController.swift new file mode 100644 index 000000000..6c79fde1b --- /dev/null +++ b/SignalMessaging/Views/ImageEditor/ImageEditorBrushViewController.swift @@ -0,0 +1,217 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +@objc +public protocol ImageEditorBrushViewControllerDelegate: class { + func brushDidComplete() +} + +// MARK: - + +public class ImageEditorBrushViewController: OWSViewController { + + private weak var delegate: ImageEditorBrushViewControllerDelegate? + + private let model: ImageEditorModel + + private let canvasView: ImageEditorCanvasView + + private let paletteView: ImageEditorPaletteView + + private var brushGestureRecognizer: ImageEditorPanGestureRecognizer? + + init(delegate: ImageEditorBrushViewControllerDelegate, + model: ImageEditorModel, + currentColor: ImageEditorColor) { + self.delegate = delegate + self.model = model + self.canvasView = ImageEditorCanvasView(model: model) + self.paletteView = ImageEditorPaletteView(currentColor: currentColor) + + super.init(nibName: nil, bundle: nil) + + model.add(observer: self) + } + + @available(*, unavailable, message: "use other init() instead.") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } + + // MARK: - View Lifecycle + + public override func loadView() { + self.view = UIView() + self.view.backgroundColor = .black + self.view.isOpaque = true + + canvasView.configureSubviews() + self.view.addSubview(canvasView) + canvasView.autoPinEdgesToSuperviewEdges() + + paletteView.delegate = self + self.view.addSubview(paletteView) + paletteView.autoVCenterInSuperview() + paletteView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 20) + + self.view.isUserInteractionEnabled = true + + let brushGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleBrushGesture(_:))) + brushGestureRecognizer.maximumNumberOfTouches = 1 + brushGestureRecognizer.referenceView = canvasView.gestureReferenceView + self.view.addGestureRecognizer(brushGestureRecognizer) + self.brushGestureRecognizer = brushGestureRecognizer + + updateNavigationBar() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.view.layoutSubviews() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.view.layoutSubviews() + } + + public func updateNavigationBar() { + let undoButton = navigationBarButton(imageName: "image_editor_undo", + selector: #selector(didTapUndo(sender:))) + let doneButton = navigationBarButton(imageName: "image_editor_checkmark_full", + selector: #selector(didTapDone(sender:))) + + var navigationBarItems = [UIView]() + if model.canUndo() { + navigationBarItems = [undoButton, doneButton] + } else { + navigationBarItems = [doneButton] + } + updateNavigationBar(navigationBarItems: navigationBarItems) + } + + // MARK: - Actions + + @objc func didTapUndo(sender: UIButton) { + Logger.verbose("") + guard model.canUndo() else { + owsFailDebug("Can't undo.") + return + } + model.undo() + } + + @objc func didTapDone(sender: UIButton) { + Logger.verbose("") + + completeAndDismiss() + } + + private func completeAndDismiss() { + self.delegate?.brushDidComplete() + + self.dismiss(animated: false) { + // Do nothing. + } + } + + // MARK: - Brush + + // These properties are non-empty while drawing a stroke. + private var currentStroke: ImageEditorStrokeItem? + private var currentStrokeSamples = [ImageEditorStrokeItem.StrokeSample]() + + @objc + public func handleBrushGesture(_ gestureRecognizer: UIGestureRecognizer) { + AssertIsOnMainThread() + + let removeCurrentStroke = { + if let stroke = self.currentStroke { + self.model.remove(item: stroke) + } + self.currentStroke = nil + self.currentStrokeSamples.removeAll() + } + let tryToAppendStrokeSample = { + let view = self.canvasView.gestureReferenceView + let viewBounds = view.bounds + let locationInView = gestureRecognizer.location(in: view) + let newSample = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView, + viewBounds: viewBounds, + model: self.model, + transform: self.model.currentTransform()) + + if let prevSample = self.currentStrokeSamples.last, + prevSample == newSample { + // Ignore duplicate samples. + return + } + self.currentStrokeSamples.append(newSample) + } + + let strokeColor = paletteView.selectedValue.color + // TODO: Tune stroke width. + let unitStrokeWidth = ImageEditorStrokeItem.defaultUnitStrokeWidth() + + switch gestureRecognizer.state { + case .began: + removeCurrentStroke() + + tryToAppendStrokeSample() + + let stroke = ImageEditorStrokeItem(color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth) + model.append(item: stroke) + currentStroke = stroke + + case .changed, .ended: + tryToAppendStrokeSample() + + guard let lastStroke = self.currentStroke else { + owsFailDebug("Missing last stroke.") + removeCurrentStroke() + return + } + + // Model items are immutable; we _replace_ the + // stroke item rather than modify it. + let stroke = ImageEditorStrokeItem(itemId: lastStroke.itemId, color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth) + model.replace(item: stroke, suppressUndo: true) + + if gestureRecognizer.state == .ended { + currentStroke = nil + currentStrokeSamples.removeAll() + } else { + currentStroke = stroke + } + default: + removeCurrentStroke() + } + } +} + +// MARK: - + +extension ImageEditorBrushViewController: ImageEditorModelObserver { + + public func imageEditorModelDidChange(before: ImageEditorContents, + after: ImageEditorContents) { + updateNavigationBar() + } + + public func imageEditorModelDidChange(changedItemIds: [String]) { + updateNavigationBar() + } +} + +// MARK: - + +extension ImageEditorBrushViewController: ImageEditorPaletteViewDelegate { + public func selectedColorDidChange() { + // TODO: + } +} diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorCanvasView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorCanvasView.swift index 0bf3a2703..a755ae9b9 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorCanvasView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorCanvasView.swift @@ -59,7 +59,7 @@ public class ImageEditorCanvasView: UIView { private var imageLayer = CALayer() @objc - public func configureSubviews() -> Bool { + public func configureSubviews() { self.backgroundColor = .clear self.isOpaque = false @@ -94,8 +94,6 @@ public class ImageEditorCanvasView: UIView { contentView.autoPinEdgesToSuperviewEdges() updateLayout() - - return true } public var gestureReferenceView: UIView { @@ -631,6 +629,19 @@ public class ImageEditorCanvasView: UIView { } return nil } + + // MARK: - Coordinates + + public class func locationImageUnit(forLocationInView locationInView: CGPoint, + viewBounds: CGRect, + model: ImageEditorModel, + transform: ImageEditorTransform) -> CGPoint { + let imageFrame = self.imageFrame(forViewSize: viewBounds.size, imageSize: model.srcImageSizePixels, transform: transform) + let affineTransformStart = transform.affineTransform(viewSize: viewBounds.size) + let locationInContent = locationInView.minus(viewBounds.center).applyingInverse(affineTransformStart).plus(viewBounds.center) + let locationImageUnit = locationInContent.toUnitCoordinates(viewBounds: imageFrame, shouldClamp: false) + return locationImageUnit + } } // MARK: - diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorCropViewController.swift b/SignalMessaging/Views/ImageEditor/ImageEditorCropViewController.swift index b40b231c8..814a403ac 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorCropViewController.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorCropViewController.swift @@ -89,20 +89,10 @@ class ImageEditorCropViewController: OWSViewController { // MARK: - Buttons - // TODO: Apply icons. - let doneButton = OWSButton(imageName: "image_editor_checkmark_full", - tintColor: UIColor.white) { [weak self] in - self?.didTapBackButton() - } let rotate90Button = OWSButton(imageName: "image_editor_rotate", tintColor: UIColor.white) { [weak self] in self?.rotate90ButtonPressed() } - // TODO: Myles may change this asset. - let resetButton = OWSButton(imageName: "image_editor_undo", - tintColor: UIColor.white) { [weak self] in - self?.resetButtonPressed() - } let flipButton = OWSButton(imageName: "image_editor_flip", tintColor: UIColor.white) { [weak self] in self?.flipButtonPressed() @@ -113,18 +103,6 @@ class ImageEditorCropViewController: OWSViewController { } self.cropLockButton = cropLockButton - // MARK: - Header - - let header = UIStackView(arrangedSubviews: [ - UIView.hStretchingSpacer(), - resetButton, - doneButton - ]) - header.axis = .horizontal - header.spacing = 16 - header.backgroundColor = .clear - header.isOpaque = false - // MARK: - Canvas & Wrapper let wrapperView = UIView.container() @@ -172,7 +150,6 @@ class ImageEditorCropViewController: OWSViewController { let imageMargin: CGFloat = 20 let stackView = UIStackView(arrangedSubviews: [ - header, wrapperView, footer ]) @@ -217,6 +194,23 @@ class ImageEditorCropViewController: OWSViewController { updateClipViewLayout() configureGestures() + + updateNavigationBar() + } + + public func updateNavigationBar() { + // TODO: Change this asset. + let resetButton = navigationBarButton(imageName: "image_editor_undo", + selector: #selector(didTapReset(sender:))) + let doneButton = navigationBarButton(imageName: "image_editor_checkmark_full", + selector: #selector(didTapDone(sender:))) + var navigationBarItems = [UIView]() + if transform.isNonDefault { + navigationBarItems = [resetButton, doneButton] + } else { + navigationBarItems = [doneButton] + } + updateNavigationBar(navigationBarItems: navigationBarItems) } private func updateCropLockButton() { @@ -354,6 +348,7 @@ class ImageEditorCropViewController: OWSViewController { applyTransform() updateClipViewLayout() updateImageLayer() + updateNavigationBar() CATransaction.commit() } @@ -731,14 +726,14 @@ class ImageEditorCropViewController: OWSViewController { // MARK: - Events - @objc public func didTapBackButton() { + @objc func didTapDone(sender: UIButton) { completeAndDismiss() } private func completeAndDismiss() { self.delegate?.cropDidComplete(transform: transform) - self.dismiss(animated: true) { + self.dismiss(animated: false) { // Do nothing. } } @@ -771,7 +766,9 @@ class ImageEditorCropViewController: OWSViewController { isFlipped: !transform.isFlipped).normalize(srcImageSizePixels: model.srcImageSizePixels)) } - @objc public func resetButtonPressed() { + @objc func didTapReset(sender: UIButton) { + Logger.verbose("") + updateTransform(ImageEditorTransform.defaultTransform(srcImageSizePixels: model.srcImageSizePixels)) } diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift index 4711c010b..ab6daed7a 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift @@ -86,6 +86,10 @@ public class ImageEditorTransform: NSObject { isFlipped: false).normalize(srcImageSizePixels: srcImageSizePixels) } + public var isNonDefault: Bool { + return !isEqual(ImageEditorTransform.defaultTransform(srcImageSizePixels: outputSizePixels)) + } + public func affineTransform(viewSize: CGSize) -> CGAffineTransform { let translation = unitTranslation.fromUnitCoordinates(viewSize: viewSize) // Order matters. We need want SRT (scale-rotate-translate) ordering so that the translation diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift index 1f35fe06d..a4040e774 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift @@ -10,11 +10,63 @@ public protocol ImageEditorPaletteViewDelegate: class { // MARK: - +// We represent image editor colors using this (color, phase) +// tuple so that we can consistently restore palette view +// state. +@objc +public class ImageEditorColor: NSObject { + public let color: UIColor + + // Colors are chosen from a spectrum of colors. + // This unit value represents the location of the + // color within that spectrum. + public let palettePhase: CGFloat + + public var cgColor: CGColor { + return color.cgColor + } + + public required init(color: UIColor, palettePhase: CGFloat) { + self.color = color + self.palettePhase = palettePhase + } + + public class func defaultColor() -> ImageEditorColor { + return ImageEditorColor(color: UIColor(rgbHex: 0xffffff), palettePhase: 0) + } + + public static var gradientUIColors: [UIColor] { + return [ + UIColor(rgbHex: 0xffffff), + UIColor(rgbHex: 0xff0000), + UIColor(rgbHex: 0xff00ff), + UIColor(rgbHex: 0x0000ff), + UIColor(rgbHex: 0x00ffff), + UIColor(rgbHex: 0x00ff00), + UIColor(rgbHex: 0xffff00), + UIColor(rgbHex: 0xff5500), + UIColor(rgbHex: 0x000000) + ] + } + + public static var gradientCGColors: [CGColor] { + return gradientUIColors.map({ (color) in + return color.cgColor + }) + } +} + +// MARK: - + public class ImageEditorPaletteView: UIView { public weak var delegate: ImageEditorPaletteViewDelegate? - public required init() { + public var selectedValue: ImageEditorColor + + public required init(currentColor: ImageEditorColor) { + self.selectedValue = currentColor + super.init(frame: .zero) createContents() @@ -27,9 +79,6 @@ public class ImageEditorPaletteView: UIView { // MARK: - Views - // The actual default is selected later. - public var selectedColor = UIColor.white - private let imageView = UIImageView() private let selectionView = UIView() private let selectionWrapper = OWSLayerView() @@ -38,6 +87,7 @@ public class ImageEditorPaletteView: UIView { private func createContents() { self.backgroundColor = .clear self.isOpaque = false + self.layoutMargins = .zero if let image = ImageEditorPaletteView.buildPaletteGradientImage() { imageView.image = image @@ -83,36 +133,36 @@ public class ImageEditorPaletteView: UIView { // 0 = the color at the top of the image is selected. // 1 = the color at the bottom of the image is selected. private let selectionSize: CGFloat = 20 - private var selectionAlpha: CGFloat = 0 private func selectColor(atLocationY y: CGFloat) { - selectionAlpha = y.inverseLerp(0, imageView.height(), shouldClamp: true) + let palettePhase = y.inverseLerp(0, imageView.height(), shouldClamp: true) + self.selectedValue = value(for: palettePhase) updateState() delegate?.selectedColorDidChange() } - private func updateState() { - var selectedColor = UIColor.white - if let image = imageView.image { - if let imageColor = image.color(atLocation: CGPoint(x: CGFloat(image.size.width) * 0.5, y: CGFloat(image.size.height) * selectionAlpha)) { - selectedColor = imageColor - } else { - owsFailDebug("Couldn't determine image color.") - } - } else { + private func value(for palettePhase: CGFloat) -> ImageEditorColor { + guard let image = imageView.image else { owsFailDebug("Missing image.") + return ImageEditorColor.defaultColor() + } + guard let color = image.color(atLocation: CGPoint(x: CGFloat(image.size.width) * 0.5, y: CGFloat(image.size.height) * palettePhase)) else { + owsFailDebug("Missing color.") + return ImageEditorColor.defaultColor() } - self.selectedColor = selectedColor + return ImageEditorColor(color: color, palettePhase: palettePhase) + } - selectionView.backgroundColor = selectedColor + private func updateState() { + selectionView.backgroundColor = selectedValue.color guard let selectionConstraint = selectionConstraint else { owsFailDebug("Missing selectionConstraint.") return } - let selectionY = selectionWrapper.height() * selectionAlpha + let selectionY = selectionWrapper.height() * selectedValue.palettePhase selectionConstraint.constant = selectionY } @@ -141,17 +191,7 @@ public class ImageEditorPaletteView: UIView { gradientView.layer.addSublayer(gradientLayer) gradientLayer.frame = gradientBounds // See: https://github.com/signalapp/Signal-Android/blob/master/res/values/arrays.xml#L267 - gradientLayer.colors = [ - UIColor(rgbHex: 0xffffff).cgColor, - UIColor(rgbHex: 0xff0000).cgColor, - UIColor(rgbHex: 0xff00ff).cgColor, - UIColor(rgbHex: 0x0000ff).cgColor, - UIColor(rgbHex: 0x00ffff).cgColor, - UIColor(rgbHex: 0x00ff00).cgColor, - UIColor(rgbHex: 0xffff00).cgColor, - UIColor(rgbHex: 0xff5500).cgColor, - UIColor(rgbHex: 0x000000).cgColor - ] + gradientLayer.colors = ImageEditorColor.gradientCGColors gradientLayer.startPoint = CGPoint.zero gradientLayer.endPoint = CGPoint(x: 0, y: gradientSize.height) gradientLayer.endPoint = CGPoint(x: 0, y: 1.0) @@ -188,8 +228,6 @@ extension UIImage { return nil } - Logger.verbose("scale: \(self.scale)") - // Convert the location from points to pixels and clamp to the image bounds. let xPixels: Int = Int(round(locationPoints.x * self.scale)).clamp(0, imageWidth - 1) let yPixels: Int = Int(round(locationPoints.y * self.scale)).clamp(0, imageHeight - 1) diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorTextItem.swift b/SignalMessaging/Views/ImageEditor/ImageEditorTextItem.swift index 5c0b84828..1056c2bcb 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorTextItem.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorTextItem.swift @@ -11,7 +11,7 @@ public class ImageEditorTextItem: ImageEditorItem { public let text: String @objc - public let color: UIColor + public let color: ImageEditorColor @objc public let font: UIFont @@ -60,7 +60,7 @@ public class ImageEditorTextItem: ImageEditorItem { @objc public init(text: String, - color: UIColor, + color: ImageEditorColor, font: UIFont, fontReferenceImageWidth: CGFloat, unitCenter: ImageEditorSample = ImageEditorSample(x: 0.5, y: 0.5), @@ -81,7 +81,7 @@ public class ImageEditorTextItem: ImageEditorItem { private init(itemId: String, text: String, - color: UIColor, + color: ImageEditorColor, font: UIFont, fontReferenceImageWidth: CGFloat, unitCenter: ImageEditorSample, @@ -101,17 +101,17 @@ public class ImageEditorTextItem: ImageEditorItem { } @objc - public class func empty(withColor color: UIColor, unitWidth: CGFloat, fontReferenceImageWidth: CGFloat) -> ImageEditorTextItem { + public class func empty(withColor color: ImageEditorColor, unitWidth: CGFloat, fontReferenceImageWidth: CGFloat) -> ImageEditorTextItem { // TODO: Tune the default font size. let font = UIFont.boldSystemFont(ofSize: 30.0) return ImageEditorTextItem(text: "", color: color, font: font, fontReferenceImageWidth: fontReferenceImageWidth, unitWidth: unitWidth) } @objc - public func copy(withText newText: String) -> ImageEditorTextItem { + public func copy(withText newText: String, color newColor: ImageEditorColor) -> ImageEditorTextItem { return ImageEditorTextItem(itemId: itemId, text: newText, - color: color, + color: newColor, font: font, fontReferenceImageWidth: fontReferenceImageWidth, unitCenter: unitCenter, diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift b/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift index 4fd987b19..680f26823 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift @@ -92,7 +92,7 @@ private class VAlignTextView: UITextView { @objc public protocol ImageEditorTextViewControllerDelegate: class { - func textEditDidComplete(textItem: ImageEditorTextItem, text: String?) + func textEditDidComplete(textItem: ImageEditorTextItem, text: String?, color: ImageEditorColor) func textEditDidCancel() } @@ -106,14 +106,24 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel private let maxTextWidthPoints: CGFloat - private let textView = VAlignTextView(alignment: .bottom) + private let textView = VAlignTextView(alignment: .center) + + private let model: ImageEditorModel + + private let canvasView: ImageEditorCanvasView + + private let paletteView: ImageEditorPaletteView init(delegate: ImageEditorTextViewControllerDelegate, + model: ImageEditorModel, textItem: ImageEditorTextItem, maxTextWidthPoints: CGFloat) { self.delegate = delegate + self.model = model self.textItem = textItem self.maxTextWidthPoints = maxTextWidthPoints + self.canvasView = ImageEditorCanvasView(model: model) + self.paletteView = ImageEditorPaletteView(currentColor: textItem.color) super.init(nibName: nil, bundle: nil) @@ -131,43 +141,62 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel super.viewWillAppear(animated) textView.becomeFirstResponder() + + self.view.layoutSubviews() } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) textView.becomeFirstResponder() + + self.view.layoutSubviews() } public override func loadView() { self.view = UIView() - self.view.backgroundColor = UIColor(white: 0.5, alpha: 0.5) + self.view.backgroundColor = .black + self.view.isOpaque = true + + canvasView.configureSubviews() + self.view.addSubview(canvasView) + canvasView.autoPinEdgesToSuperviewEdges() + + let tintView = UIView() + tintView.backgroundColor = UIColor(white: 0, alpha: 0.33) + tintView.isOpaque = false + self.view.addSubview(tintView) + tintView.autoPinEdgesToSuperviewEdges() + tintView.layer.opacity = 0 + UIView.animate(withDuration: 0.25, animations: { + tintView.layer.opacity = 1 + }, completion: { (_) in + tintView.layer.opacity = 1 + }) configureTextView() - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, - target: self, - action: #selector(didTapBackButton)) - self.view.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) + self.view.addSubview(textView) textView.autoPinTopToSuperviewMargin() textView.autoHCenterInSuperview() - // In order to have text wrapping be as WYSIWYG as possible, we limit the text view - // to the max text width on the image. -// let maxTextWidthPoints = max(textItem.widthPoints, 200) -// textView.autoSetDimension(.width, toSize: maxTextWidthPoints, relation: .lessThanOrEqual) -// textView.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual) -// textView.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual) - textView.autoPinEdge(toSuperviewMargin: .leading) - textView.autoPinEdge(toSuperviewMargin: .trailing) self.autoPinView(toBottomOfViewControllerOrKeyboard: textView, avoidNotch: true) + + paletteView.delegate = self + self.view.addSubview(paletteView) + paletteView.autoAlignAxis(.horizontal, toSameAxisOf: textView) + paletteView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 20) + // This will determine the text view's size. + paletteView.autoPinEdge(.leading, to: .trailing, of: textView, withOffset: 8) + + updateNavigationBar() } private func configureTextView() { textView.text = textItem.text textView.font = textItem.font - textView.textColor = textItem.color + textView.textColor = textItem.color.color textView.isEditable = true textView.backgroundColor = .clear @@ -175,7 +204,7 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel // We use a white cursor since we use a dark background. textView.tintColor = .white textView.returnKeyType = .done - // TODO: Limit the size of the text. + // TODO: Limit the size of the text? // textView.delegate = self textView.isScrollEnabled = true textView.scrollsToTop = false @@ -186,25 +215,38 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel textView.contentInset = .zero } - // MARK: - Events + private func updateNavigationBar() { + let undoButton = navigationBarButton(imageName: "image_editor_undo", + selector: #selector(didTapUndo(sender:))) + let doneButton = navigationBarButton(imageName: "image_editor_checkmark_full", + selector: #selector(didTapDone(sender:))) - @objc public func didTapBackButton() { - completeAndDismiss() + let navigationBarItems = [undoButton, doneButton] + updateNavigationBar(navigationBarItems: navigationBarItems) } - private func completeAndDismiss() { + // MARK: - Events + + @objc func didTapUndo(sender: UIButton) { + Logger.verbose("") - // Before we take a screenshot, make sure selection state - // auto-complete suggestions, cursor don't affect screenshot. - textView.resignFirstResponder() - if textView.isFirstResponder { - owsFailDebug("Text view is still first responder.") + self.delegate?.textEditDidCancel() + + self.dismiss(animated: false) { + // Do nothing. } - textView.selectedTextRange = nil + } + + @objc func didTapDone(sender: UIButton) { + Logger.verbose("") + + completeAndDismiss() + } - self.delegate?.textEditDidComplete(textItem: textItem, text: textView.text) + private func completeAndDismiss() { + self.delegate?.textEditDidComplete(textItem: textItem, text: textView.text, color: paletteView.selectedValue) - self.dismiss(animated: true) { + self.dismiss(animated: false) { // Do nothing. } } @@ -215,3 +257,11 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel completeAndDismiss() } } + +// MARK: - + +extension ImageEditorTextViewController: ImageEditorPaletteViewDelegate { + public func selectedColorDidChange() { + self.textView.textColor = self.paletteView.selectedValue.color + } +} diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift index 4f6234503..0e9286845 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift @@ -8,6 +8,8 @@ import UIKit public protocol ImageEditorViewDelegate: class { func imageEditor(presentFullScreenOverlay viewController: UIViewController, withNavigation: Bool) + func imageEditorPresentCaptionView() + func imageEditorUpdateNavigationBar() } // MARK: - @@ -23,29 +25,8 @@ public class ImageEditorView: UIView { private let canvasView: ImageEditorCanvasView - private let paletteView = ImageEditorPaletteView() - - enum EditorMode: String { - // This is the default mode. It is used for interacting with text items. - case none - case brush - case text - } - - private var editorMode = EditorMode.none { - didSet { - AssertIsOnMainThread() - - updateButtons() - updateGestureState() - } - } - - private var currentColor: UIColor { - get { - return paletteView.selectedColor - } - } + // TODO: We could hang this on the model or make this static. + private var currentColor = ImageEditorColor.defaultColor() @objc public required init(model: ImageEditorModel, delegate: ImageEditorViewDelegate) { @@ -66,20 +47,15 @@ public class ImageEditorView: UIView { // MARK: - Views private var moveTextGestureRecognizer: ImageEditorPanGestureRecognizer? - private var brushGestureRecognizer: ImageEditorPanGestureRecognizer? private var tapGestureRecognizer: UITapGestureRecognizer? private var pinchGestureRecognizer: ImageEditorPinchGestureRecognizer? @objc public func configureSubviews() -> Bool { - guard canvasView.configureSubviews() else { - return false - } + canvasView.configureSubviews() self.addSubview(canvasView) canvasView.autoPinEdgesToSuperviewEdges() - paletteView.delegate = self - self.isUserInteractionEnabled = true let moveTextGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleMoveTextGesture(_:))) @@ -89,12 +65,6 @@ public class ImageEditorView: UIView { self.addGestureRecognizer(moveTextGestureRecognizer) self.moveTextGestureRecognizer = moveTextGestureRecognizer - let brushGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleBrushGesture(_:))) - brushGestureRecognizer.maximumNumberOfTouches = 1 - brushGestureRecognizer.referenceView = canvasView.gestureReferenceView - self.addGestureRecognizer(brushGestureRecognizer) - self.brushGestureRecognizer = brushGestureRecognizer - let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) self.addGestureRecognizer(tapGestureRecognizer) self.tapGestureRecognizer = tapGestureRecognizer @@ -108,130 +78,35 @@ public class ImageEditorView: UIView { // editorGestureRecognizer.require(toFail: tapGestureRecognizer) // editorGestureRecognizer.require(toFail: pinchGestureRecognizer) - updateGestureState() - return true } - private func commitTextEditingChanges(textItem: ImageEditorTextItem, textView: UITextView) { - AssertIsOnMainThread() - - guard let text = textView.text?.ows_stripped(), - text.count > 0 else { - model.remove(item: textItem) - return - } - - // Model items are immutable; we _replace_ the item rather than modify it. - let newItem = textItem.copy(withText: text) - if model.has(itemForId: textItem.itemId) { - model.replace(item: newItem, suppressUndo: false) - } else { - model.append(item: newItem) - } - } - - // The model supports redo if we ever want to add it. - private let undoButton = OWSButton() - private let brushButton = OWSButton() - private let cropButton = OWSButton() - private let newTextButton = OWSButton() - private let captionButton = OWSButton() - private let doneButton = OWSButton() - private let buttonStackView = UIStackView() - // TODO: Should this method be private? @objc public func addControls(to containerView: UIView, viewController: UIViewController) { - configure(button: undoButton, - imageName: "image_editor_undo", - selector: #selector(didTapUndo(sender:))) - - configure(button: brushButton, - imageName: "image_editor_brush", - selector: #selector(didTapBrush(sender:))) - - configure(button: cropButton, - imageName: "image_editor_crop", - selector: #selector(didTapCrop(sender:))) - - configure(button: newTextButton, - imageName: "image_editor_text", - selector: #selector(didTapNewText(sender:))) - - configure(button: captionButton, - imageName: "image_editor_caption", - selector: #selector(didTapCaption(sender:))) - - configure(button: doneButton, - imageName: "image_editor_checkmark_full", - selector: #selector(didTapDone(sender:))) - - buttonStackView.axis = .horizontal - buttonStackView.alignment = .center - buttonStackView.spacing = 20 - - containerView.addSubview(buttonStackView) - buttonStackView.autoPin(toTopLayoutGuideOf: viewController, withInset: 0) - buttonStackView.autoPinTrailingToSuperviewMargin(withInset: 18) - - containerView.addSubview(paletteView) - paletteView.autoVCenterInSuperview() - paletteView.autoPinLeadingToSuperviewMargin(withInset: 10) - - updateButtons() + delegate?.imageEditorUpdateNavigationBar() } - private func configure(button: UIButton, - imageName: String, - selector: Selector) { - if let image = UIImage(named: imageName) { - button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) - } else { - owsFailDebug("Missing asset: \(imageName)") - } - button.tintColor = .white - button.addTarget(self, action: selector, for: .touchUpInside) - button.layer.shadowColor = UIColor.black.cgColor - button.layer.shadowRadius = 4 - button.layer.shadowOpacity = 0.66 - } + // MARK: - Navigation Bar - private func updateButtons() { - var buttons = [OWSButton]() + public func navigationBarItems() -> [UIView] { + let undoButton = navigationBarButton(imageName: "image_editor_undo", + selector: #selector(didTapUndo(sender:))) + let brushButton = navigationBarButton(imageName: "image_editor_brush", + selector: #selector(didTapBrush(sender:))) + let cropButton = navigationBarButton(imageName: "image_editor_crop", + selector: #selector(didTapCrop(sender:))) + let newTextButton = navigationBarButton(imageName: "image_editor_text", + selector: #selector(didTapNewText(sender:))) + let captionButton = navigationBarButton(imageName: "image_editor_caption", + selector: #selector(didTapCaption(sender:))) - var hasPalette = false - switch editorMode { - case .text: - // TODO: - hasPalette = true - break - case .brush: - hasPalette = true - - if model.canUndo() { - buttons = [undoButton, doneButton] - } else { - buttons = [doneButton] - } - case .none: - if model.canUndo() { - buttons = [undoButton, newTextButton, brushButton, cropButton, captionButton] - } else { - buttons = [newTextButton, brushButton, cropButton, captionButton] - } - } - - for subview in buttonStackView.subviews { - subview.removeFromSuperview() - } - buttonStackView.addArrangedSubview(UIView.hStretchingSpacer()) - for button in buttons { - buttonStackView.addArrangedSubview(button) + if model.canUndo() { + return [undoButton, newTextButton, brushButton, cropButton, captionButton] + } else { + return [newTextButton, brushButton, cropButton, captionButton] } - - paletteView.isHidden = !hasPalette } // MARK: - Actions @@ -248,7 +123,9 @@ public class ImageEditorView: UIView { @objc func didTapBrush(sender: UIButton) { Logger.verbose("") - self.editorMode = .brush + let brushView = ImageEditorBrushViewController(delegate: self, model: model, currentColor: currentColor) + self.delegate?.imageEditor(presentFullScreenOverlay: brushView, + withNavigation: true) } @objc func didTapCrop(sender: UIButton) { @@ -278,38 +155,11 @@ public class ImageEditorView: UIView { @objc func didTapCaption(sender: UIButton) { Logger.verbose("") - // TODO: + delegate?.imageEditorPresentCaptionView() } @objc func didTapDone(sender: UIButton) { Logger.verbose("") - - self.editorMode = .none - } - - // MARK: - Gestures - - private func updateGestureState() { - AssertIsOnMainThread() - - switch editorMode { - case .none: - moveTextGestureRecognizer?.isEnabled = true - brushGestureRecognizer?.isEnabled = false - tapGestureRecognizer?.isEnabled = true - pinchGestureRecognizer?.isEnabled = true - case .brush: - // Brush strokes can start and end (and return from) outside the view. - moveTextGestureRecognizer?.isEnabled = false - brushGestureRecognizer?.isEnabled = true - tapGestureRecognizer?.isEnabled = false - pinchGestureRecognizer?.isEnabled = false - case .text: - moveTextGestureRecognizer?.isEnabled = false - brushGestureRecognizer?.isEnabled = false - tapGestureRecognizer?.isEnabled = false - pinchGestureRecognizer?.isEnabled = false - } } // MARK: - Tap Gesture @@ -370,11 +220,11 @@ public class ImageEditorView: UIView { let viewBounds = view.bounds let locationStart = gestureRecognizer.pinchStateStart.centroid let locationNow = gestureRecognizer.pinchStateLast.centroid - let gestureStartImageUnit = ImageEditorView.locationImageUnit(forLocationInView: locationStart, + let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) - let gestureNowImageUnit = ImageEditorView.locationImageUnit(forLocationInView: locationNow, + let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationNow, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) @@ -461,11 +311,11 @@ public class ImageEditorView: UIView { let view = self.canvasView.gestureReferenceView let viewBounds = view.bounds let locationInView = gestureRecognizer.location(in: view) - let gestureStartImageUnit = ImageEditorView.locationImageUnit(forLocationInView: locationStart, + let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) - let gestureNowImageUnit = ImageEditorView.locationImageUnit(forLocationInView: locationInView, + let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) @@ -509,7 +359,7 @@ public class ImageEditorView: UIView { let view = self.canvasView.gestureReferenceView let viewBounds = view.bounds let locationInView = gestureRecognizer.location(in: view) - let newSample = ImageEditorView.locationImageUnit(forLocationInView: locationInView, + let newSample = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView, viewBounds: viewBounds, model: self.model, transform: self.model.currentTransform()) @@ -522,7 +372,7 @@ public class ImageEditorView: UIView { self.currentStrokeSamples.append(newSample) } - let strokeColor = currentColor + let strokeColor = currentColor.color // TODO: Tune stroke width. let unitStrokeWidth = ImageEditorStrokeItem.defaultUnitStrokeWidth() @@ -561,31 +411,19 @@ public class ImageEditorView: UIView { } } - // MARK: - Coordinates - - private class func locationImageUnit(forLocationInView locationInView: CGPoint, - viewBounds: CGRect, - model: ImageEditorModel, - transform: ImageEditorTransform) -> CGPoint { - let imageFrame = ImageEditorCanvasView.imageFrame(forViewSize: viewBounds.size, imageSize: model.srcImageSizePixels, transform: transform) - let affineTransformStart = transform.affineTransform(viewSize: viewBounds.size) - let locationInContent = locationInView.minus(viewBounds.center).applyingInverse(affineTransformStart).plus(viewBounds.center) - let locationImageUnit = locationInContent.toUnitCoordinates(viewBounds: imageFrame, shouldClamp: false) - return locationImageUnit - } - // MARK: - Edit Text Tool private func edit(textItem: ImageEditorTextItem) { Logger.verbose("") - self.editorMode = .text - // TODO: let maxTextWidthPoints = model.srcImageSizePixels.width * ImageEditorTextItem.kDefaultUnitWidth // let maxTextWidthPoints = canvasView.imageView.width() * ImageEditorTextItem.kDefaultUnitWidth - let textEditor = ImageEditorTextViewController(delegate: self, textItem: textItem, maxTextWidthPoints: maxTextWidthPoints) + let textEditor = ImageEditorTextViewController(delegate: self, + model: model, + textItem: textItem, + maxTextWidthPoints: maxTextWidthPoints) self.delegate?.imageEditor(presentFullScreenOverlay: textEditor, withNavigation: true) } @@ -595,8 +433,6 @@ public class ImageEditorView: UIView { private func presentCropTool() { Logger.verbose("") - self.editorMode = .none - guard let srcImage = canvasView.loadSrcImage() else { owsFailDebug("Couldn't load src image.") return @@ -613,7 +449,7 @@ public class ImageEditorView: UIView { let cropTool = ImageEditorCropViewController(delegate: self, model: model, srcImage: srcImage, previewImage: previewImage) self.delegate?.imageEditor(presentFullScreenOverlay: cropTool, - withNavigation: false) + withNavigation: true) }} // MARK: - @@ -625,10 +461,6 @@ extension ImageEditorView: UIGestureRecognizerDelegate { owsFailDebug("Unexpected gesture.") return false } - guard editorMode == .none else { - // We only filter touches when in default mode. - return true - } let location = touch.location(in: canvasView.gestureReferenceView) let isInTextArea = self.textLayer(forLocation: location) != nil @@ -642,11 +474,11 @@ extension ImageEditorView: ImageEditorModelObserver { public func imageEditorModelDidChange(before: ImageEditorContents, after: ImageEditorContents) { - updateButtons() + delegate?.imageEditorUpdateNavigationBar() } public func imageEditorModelDidChange(changedItemIds: [String]) { - updateButtons() + delegate?.imageEditorUpdateNavigationBar() } } @@ -654,11 +486,9 @@ extension ImageEditorView: ImageEditorModelObserver { extension ImageEditorView: ImageEditorTextViewControllerDelegate { - public func textEditDidComplete(textItem: ImageEditorTextItem, text: String?) { + public func textEditDidComplete(textItem: ImageEditorTextItem, text: String?, color: ImageEditorColor) { AssertIsOnMainThread() - self.editorMode = .none - guard let text = text?.ows_stripped(), text.count > 0 else { if model.has(itemForId: textItem.itemId) { @@ -668,7 +498,7 @@ extension ImageEditorView: ImageEditorTextViewControllerDelegate { } // Model items are immutable; we _replace_ the item rather than modify it. - let newItem = textItem.copy(withText: text) + let newItem = textItem.copy(withText: text, color: color) if model.has(itemForId: textItem.itemId) { model.replace(item: newItem, suppressUndo: false) } else { @@ -677,7 +507,6 @@ extension ImageEditorView: ImageEditorTextViewControllerDelegate { } public func textEditDidCancel() { - self.editorMode = .none } } @@ -696,8 +525,7 @@ extension ImageEditorView: ImageEditorCropViewControllerDelegate { // MARK: - -extension ImageEditorView: ImageEditorPaletteViewDelegate { - public func selectedColorDidChange() { - // TODO: +extension ImageEditorView: ImageEditorBrushViewControllerDelegate { + public func brushDidComplete() { } } diff --git a/SignalMessaging/Views/ImageEditor/OWSViewController+ImageEditor.swift b/SignalMessaging/Views/ImageEditor/OWSViewController+ImageEditor.swift new file mode 100644 index 000000000..7c2e46b11 --- /dev/null +++ b/SignalMessaging/Views/ImageEditor/OWSViewController+ImageEditor.swift @@ -0,0 +1,39 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +public extension NSObject { + + public func navigationBarButton(imageName: String, + selector: Selector) -> UIView { + let button = OWSButton() + button.setImage(imageName: imageName) + button.tintColor = .white + button.addTarget(self, action: selector, for: .touchUpInside) + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowRadius = 2 + button.layer.shadowOpacity = 0.66 + return button + } +} + +// MARK: - + +public extension UIViewController { + + public func updateNavigationBar(navigationBarItems: [UIView]) { + guard navigationBarItems.count > 0 else { + self.navigationItem.rightBarButtonItems = [] + return + } + + let stackView = UIStackView(arrangedSubviews: navigationBarItems) + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .center + + self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: stackView) + } +} diff --git a/SignalMessaging/categories/UIView+OWS.swift b/SignalMessaging/categories/UIView+OWS.swift index 789f6b056..c50db8c35 100644 --- a/SignalMessaging/categories/UIView+OWS.swift +++ b/SignalMessaging/categories/UIView+OWS.swift @@ -129,7 +129,7 @@ public extension CGFloat { return CGFloatClamp(self, minValue, maxValue) } - public func clamp01(_ minValue: CGFloat, _ maxValue: CGFloat) -> CGFloat { + public func clamp01() -> CGFloat { return CGFloatClamp01(self) }