From 87646b1798ad188717923f7a066e68046da0e5ca Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Thu, 28 Feb 2019 11:15:46 -0500 Subject: [PATCH] Replace old caption view with new caption view. --- Signal.xcodeproj/project.pbxproj | 4 + .../AttachmentApprovalViewController.swift | 533 +----------------- .../AttachmentCaptionViewController.swift | 309 ++++++++++ .../ImageEditor/ImageEditorPaletteView.swift | 2 - .../Views/ImageEditor/ImageEditorView.swift | 11 + 5 files changed, 343 insertions(+), 516 deletions(-) create mode 100644 SignalMessaging/ViewControllers/AttachmentCaptionViewController.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 3add7cb25..ce1384943 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 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 */; }; 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 +639,7 @@ 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 = ""; }; 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 = ""; }; @@ -2106,6 +2108,7 @@ isa = PBXGroup; children = ( 34AC09D2211B39B000997B47 /* AttachmentApprovalViewController.swift */, + 34080EFF22282C880087E99F /* AttachmentCaptionViewController.swift */, 34AC09CF211B39B000997B47 /* ContactFieldView.swift */, 34AC09CD211B39B000997B47 /* ContactShareApprovalViewController.swift */, 34AC09DB211B39B100997B47 /* CountryCodeViewController.h */, @@ -3328,6 +3331,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 */, diff --git a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift index 53b149cc9..3b9dbc744 100644 --- a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift +++ b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift @@ -226,49 +226,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) { @@ -282,8 +239,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return } navigationBar.overrideTheme(type: .clear) - - updateCaptionVisibility() } override public func viewDidAppear(_ animated: Bool) { @@ -310,66 +265,6 @@ 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 - } - - guard let keyboardStartFrame = userInfo[UIKeyboardFrameBeginUserInfoKey] as? CGRect else { - owsFailDebug("keyboardEndFrame was unexpectedly nil") - return - } - - guard let keyboardEndFrame = userInfo[UIKeyboardFrameEndUserInfoKey] as? CGRect else { - owsFailDebug("keyboardEndFrame was unexpectedly nil") - return - } - - 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") - return - } - - guard let keyboardStartFrame = userInfo[UIKeyboardFrameBeginUserInfoKey] as? CGRect else { - owsFailDebug("keyboardEndFrame was unexpectedly nil") - return - } - - guard let keyboardEndFrame = userInfo[UIKeyboardFrameEndUserInfoKey] as? CGRect else { - owsFailDebug("keyboardEndFrame was unexpectedly nil") - return - } - - Logger.debug("\(keyboardStartFrame) -> \(keyboardEndFrame)") - - lastObservedKeyboardTop = UIScreen.main.bounds.height - keyboardEndFrame.size.height - currentPageViewController.updateCaptionViewBottomInset(keyboardScenario: .hidden) - } - // MARK: - View Helpers func remove(attachmentItem: SignalAttachmentItem) { @@ -412,12 +307,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 +322,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) } } @@ -524,7 +410,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 +422,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() @@ -699,74 +582,6 @@ extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate func prepViewController(_ prepViewController: AttachmentPrepViewController, didUpdateCaptionForAttachmentItem attachmentItem: SignalAttachmentItem) { 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 - } } // MARK: GalleryRail @@ -818,12 +633,6 @@ 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 } public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarDelegate, OWSVideoPlayerDelegate { @@ -861,30 +670,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:))) @@ -1023,12 +811,6 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD view.addSubview(touchInterceptorView) touchInterceptorView.autoPinEdgesToSuperviewEdges() touchInterceptorView.isHidden = true - - view.addSubview(captionView) - captionView.delegate = self - - captionView.autoPinWidthToSuperview() - captionViewBottomConstraint = captionView.autoPinEdge(toSuperviewEdge: .bottom) } override public func viewWillLayoutSubviews() { @@ -1041,40 +823,11 @@ 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() - } - - // 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() } - } - } - // MARK: - Event Handlers @objc func didTapTouchInterceptorView(gesture: UITapGestureRecognizer) { Logger.info("") - captionView.endEditing() touchInterceptorView.isHidden = true } @@ -1228,30 +981,12 @@ 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) - } - - func captionViewDidEndEditing(_ captionView: CaptionView) { - touchInterceptorView.isHidden = true - prepDelegate?.prepViewController(self, didEndEditingCaptionView: captionView) - } } extension AttachmentPrepViewController: UIScrollViewDelegate { @@ -1331,15 +1066,27 @@ 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) + } } // MARK: - @@ -1393,251 +1140,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 +1447,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..be541dba2 --- /dev/null +++ b/SignalMessaging/ViewControllers/AttachmentCaptionViewController.swift @@ -0,0 +1,309 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +protocol AttachmentCaptionDelegate: class { + func captionView(_ captionView: AttachmentCaptionViewController, didChangeCaptionText captionText: String?, attachmentItem: SignalAttachmentItem) +} + +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?.captionView(self, didChangeCaptionText: originalCaptionText, attachmentItem: attachmentItem) + } 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 = minHeight * 4 + 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/ImageEditorPaletteView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift index 1f35fe06d..8f222cfe8 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift @@ -188,8 +188,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/ImageEditorView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift index 4f6234503..45b120347 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift @@ -8,6 +8,7 @@ import UIKit public protocol ImageEditorViewDelegate: class { func imageEditor(presentFullScreenOverlay viewController: UIViewController, withNavigation: Bool) + func imageEditorPresentCaptionView() } // MARK: - @@ -278,6 +279,16 @@ public class ImageEditorView: UIView { @objc func didTapCaption(sender: UIButton) { Logger.verbose("") + delegate?.imageEditorPresentCaptionView() + +// // TODO: +// let maxTextWidthPoints = model.srcImageSizePixels.width * ImageEditorTextItem.kDefaultUnitWidth +// // let maxTextWidthPoints = canvasView.imageView.width() * ImageEditorTextItem.kDefaultUnitWidth +// +// let textEditor = ImageEditorTextViewController(delegate: self, textItem: textItem, maxTextWidthPoints: maxTextWidthPoints) +// self.delegate?.imageEditor(presentFullScreenOverlay: textEditor, +// withNavigation: true) + // TODO: }