Merge branch 'charlesmchen/imageEditorCaptions'

pull/2/head
Matthew Chen 6 years ago
commit 0813ebe16d

@ -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 = "<group>"; };
34074F60203D0CBE004596AE /* OWSSounds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSSounds.h; sourceTree = "<group>"; };
34080EFD2225F96D0087E99F /* ImageEditorPaletteView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorPaletteView.swift; sourceTree = "<group>"; };
34080EFF22282C880087E99F /* AttachmentCaptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentCaptionViewController.swift; sourceTree = "<group>"; };
34080F01222853E30087E99F /* ImageEditorBrushViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorBrushViewController.swift; sourceTree = "<group>"; };
34080F03222858DC0087E99F /* OWSViewController+ImageEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OWSViewController+ImageEditor.swift"; sourceTree = "<group>"; };
340B02B61F9FD31800F9CFEC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = translations/he.lproj/Localizable.strings; sourceTree = "<group>"; };
340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItemTest.m; sourceTree = "<group>"; };
340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NotificationSettingsOptionsViewController.m; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 */,

@ -1,5 +1,5 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation

@ -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
}

@ -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()
}
}

@ -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:
}
}

@ -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: -

@ -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))
}

@ -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

@ -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)

@ -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,

@ -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
}
}

@ -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() {
}
}

@ -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)
}
}

@ -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)
}

Loading…
Cancel
Save