diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index fafb368b4..c26f2904f 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -228,6 +228,8 @@ 34B6A90B218BA1D1007C4606 /* typing-animation.gif in Resources */ = {isa = PBXBuildFile; fileRef = 34B6A90A218BA1D0007C4606 /* typing-animation.gif */; }; 34B6D27420F664C900765BE2 /* OWSUnreadIndicator.h in Headers */ = {isa = PBXBuildFile; fileRef = 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */; settings = {ATTRIBUTES = (Public, ); }; }; 34B6D27520F664C900765BE2 /* OWSUnreadIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */; }; + 34BBC84B220B2CB200857249 /* ImageEditorTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */; }; + 34BBC84D220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BBC84C220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift */; }; 34BECE2B1F74C12700D7438D /* DebugUIStress.m in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2A1F74C12700D7438D /* DebugUIStress.m */; }; 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; }; 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */; }; @@ -902,6 +904,8 @@ 34B6A90A218BA1D0007C4606 /* typing-animation.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = "typing-animation.gif"; sourceTree = ""; }; 34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSUnreadIndicator.h; sourceTree = ""; }; 34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSUnreadIndicator.m; sourceTree = ""; }; + 34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorTextViewController.swift; sourceTree = ""; }; + 34BBC84C220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorPinchGestureRecognizer.swift; sourceTree = ""; }; 34BECE291F74C12700D7438D /* DebugUIStress.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIStress.h; sourceTree = ""; }; 34BECE2A1F74C12700D7438D /* DebugUIStress.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DebugUIStress.m; sourceTree = ""; }; 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = ""; }; @@ -1866,6 +1870,8 @@ children = ( 34BEDB1821C82AC5007B0EAE /* ImageEditorGestureRecognizer.swift */, 34BEDB0D21C405B0007B0EAE /* ImageEditorModel.swift */, + 34BBC84C220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift */, + 34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */, 34BEDB1221C43F69007B0EAE /* ImageEditorView.swift */, ); path = ImageEditor; @@ -3340,6 +3346,7 @@ 347850691FD9B78A007B8332 /* AppSetup.m in Sources */, 346941A3215D2EE400B5BFAD /* Theme.m in Sources */, 4C23A5F2215C4ADE00534937 /* SheetViewController.swift in Sources */, + 34BBC84D220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift in Sources */, 34AC0A14211B39EA00997B47 /* ContactCellView.m in Sources */, 34AC0A15211B39EA00997B47 /* ContactsViewHelper.m in Sources */, 346129FF1FD5F31400532771 /* OWS103EnableVideoCalling.m in Sources */, @@ -3361,6 +3368,7 @@ 4598198F204E2F28009414F2 /* OWS108CallLoggingPreference.m in Sources */, 34AC09F3211B39B100997B47 /* NewNonContactConversationViewController.m in Sources */, 4C3E245C21F29FCE000AE092 /* Toast.swift in Sources */, + 34BBC84B220B2CB200857249 /* ImageEditorTextViewController.swift in Sources */, 34AC09FA211B39B100997B47 /* SharingThreadPickerViewController.m in Sources */, 45F59A082028E4FB00E8D2B0 /* OWSAudioSession.swift in Sources */, 34612A071FD7238600532771 /* OWSSyncManager.m in Sources */, diff --git a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift index 731ee9862..565a7e94a 100644 --- a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift +++ b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift @@ -1317,6 +1317,8 @@ extension AttachmentPrepViewController: UIScrollViewDelegate { } } +// MARK: - + class BottomToolView: UIView { let mediaMessageTextToolbar: MediaMessageTextToolbar let galleryRailView: GalleryRailView diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorGestureRecognizer.swift b/SignalMessaging/Views/ImageEditor/ImageEditorGestureRecognizer.swift index 2fab76936..bacddf5bb 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorGestureRecognizer.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorGestureRecognizer.swift @@ -1,10 +1,10 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import UIKit -class ImageEditorGestureRecognizer: UIGestureRecognizer { +public class ImageEditorGestureRecognizer: UIGestureRecognizer { @objc public var shouldAllowOutsideView = true @@ -13,42 +13,56 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer { public weak var canvasView: UIView? @objc - override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { + public var startLocationInView: CGPoint = .zero + + @objc + public override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { return false } @objc - override func canBePrevented(by: UIGestureRecognizer) -> Bool { + public override func canBePrevented(by: UIGestureRecognizer) -> Bool { return false } @objc - override func shouldRequireFailure(of: UIGestureRecognizer) -> Bool { + public override func shouldRequireFailure(of: UIGestureRecognizer) -> Bool { return false } @objc - override func shouldBeRequiredToFail(by: UIGestureRecognizer) -> Bool { + public override func shouldBeRequiredToFail(by: UIGestureRecognizer) -> Bool { return true } // MARK: - Touch Handling @objc - override func touchesBegan(_ touches: Set, with event: UIEvent) { + public override func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) if state == .possible, touchType(for: touches, with: event) == .valid { // If a gesture starts with a valid touch, begin stroke. state = .began + startLocationInView = .zero + + guard let view = view else { + owsFailDebug("Missing view.") + return + } + guard let touch = touches.randomElement() else { + owsFailDebug("Missing touch.") + return + } + startLocationInView = touch.location(in: view) } else { state = .failed } } @objc - override func touchesMoved(_ touches: Set, with event: UIEvent) { + public override func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) switch state { @@ -70,7 +84,7 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer { } @objc - override func touchesEnded(_ touches: Set, with event: UIEvent) { + public override func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) switch state { @@ -88,7 +102,7 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer { } @objc - override func touchesCancelled(_ touches: Set, with event: UIEvent) { + public override func touchesCancelled(_ touches: Set, with event: UIEvent) { super.touchesCancelled(touches, with: event) state = .cancelled diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift index 246930422..eb601470c 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift @@ -13,10 +13,30 @@ import UIKit public enum ImageEditorItemType: Int { case test case stroke + case text } // MARK: - +// Represented in a "ULO unit" coordinate system +// for source image. +// +// "ULO" coordinate system is "upper-left-origin". +// +// "Unit" coordinate system means values are expressed +// in terms of some other values, in this case the +// width and height of the source image. +// +// * 0.0 = left edge +// * 1.0 = right edge +// * 0.0 = top edge +// * 1.0 = bottom edge +public typealias ImageEditorSample = CGPoint + +public typealias ImageEditorConversion = (ImageEditorSample) -> ImageEditorSample + +// MARK: - + // Instances of ImageEditorItem should be treated // as immutable, once configured. @objc @@ -44,11 +64,13 @@ public class ImageEditorItem: NSObject { super.init() } - public typealias PointConversionFunction = (CGPoint) -> CGPoint - - public func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem { + public func clone(withImageEditorConversion conversion: ImageEditorConversion) -> ImageEditorItem { return ImageEditorItem(itemId: itemId, itemType: itemType) } + + public func outputScale() -> CGFloat { + return 1.0 + } } // MARK: - @@ -60,20 +82,7 @@ public class ImageEditorStrokeItem: ImageEditorItem { @objc public let color: UIColor - // Represented in a "ULO unit" coordinate system - // for source image. - // - // "ULO" coordinate system is "upper-left-origin". - // - // "Unit" coordinate system means values are expressed - // in terms of some other values, in this case the - // width and height of the source image. - // - // * 0.0 = left edge - // * 1.0 = right edge - // * 0.0 = top edge - // * 1.0 = bottom edge - public typealias StrokeSample = CGPoint + public typealias StrokeSample = ImageEditorSample @objc public let unitSamples: [StrokeSample] @@ -117,7 +126,7 @@ public class ImageEditorStrokeItem: ImageEditorItem { return CGFloatClamp01(unitStrokeWidth) * min(dstSize.width, dstSize.height) } - public override func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem { + public override func clone(withImageEditorConversion conversion: ImageEditorConversion) -> ImageEditorItem { // TODO: We might want to convert the unitStrokeWidth too. let convertedUnitSamples = unitSamples.map { (sample) in conversion(sample) @@ -131,6 +140,159 @@ public class ImageEditorStrokeItem: ImageEditorItem { // MARK: - +@objc +public class ImageEditorTextItem: ImageEditorItem { + // Until we need to serialize these items, + // just use UIColor. + @objc + public let color: UIColor + + @objc + public let font: UIFont + + @objc + public let text: String + + @objc + public let unitCenter: ImageEditorSample + + // Leave some margins against the edge of the image. + @objc + public static let kDefaultUnitWidth: CGFloat = 0.9 + + // The max width of the text as a fraction of the image width. + @objc + public let unitWidth: CGFloat + + // 0 = no rotation. + // CGFloat.pi * 0.5 = rotation 90 degrees clockwise. + @objc + public let rotationRadians: CGFloat + + @objc + public static let kMaxScaling: CGFloat = 4.0 + @objc + public static let kMinScaling: CGFloat = 0.5 + @objc + public let scaling: CGFloat + + // This might be nil while the item is a "draft" item. + // Once the item has been "committed" to the model, it + // should always be non-nil, + @objc + public let imagePath: String? + + @objc + public init(color: UIColor, + font: UIFont, + text: String, + unitCenter: ImageEditorSample = CGPoint(x: 0.5, y: 0.5), + unitWidth: CGFloat = ImageEditorTextItem.kDefaultUnitWidth, + rotationRadians: CGFloat = 0.0, + scaling: CGFloat = 1.0, + imagePath: String? = nil) { + self.color = color + self.font = font + self.text = text + self.unitCenter = unitCenter + self.unitWidth = unitWidth + self.rotationRadians = rotationRadians + self.scaling = scaling + self.imagePath = imagePath + + super.init(itemType: .text) + } + + private init(itemId: String, + color: UIColor, + font: UIFont, + text: String, + unitCenter: ImageEditorSample, + unitWidth: CGFloat, + rotationRadians: CGFloat, + scaling: CGFloat, + imagePath: String?) { + self.color = color + self.font = font + self.text = text + self.unitCenter = unitCenter + self.unitWidth = unitWidth + self.rotationRadians = rotationRadians + self.scaling = scaling + self.imagePath = imagePath + + super.init(itemId: itemId, itemType: .text) + } + + @objc + public class func empty(withColor color: UIColor) -> ImageEditorTextItem { + let font = UIFont.boldSystemFont(ofSize: 30.0) + return ImageEditorTextItem(color: color, font: font, text: "") + } + + @objc + public func copy(withText newText: String) -> ImageEditorTextItem { + return ImageEditorTextItem(itemId: itemId, + color: color, + font: font, + text: newText, + unitCenter: unitCenter, + unitWidth: unitWidth, + rotationRadians: rotationRadians, + scaling: scaling, + imagePath: imagePath) + } + + @objc + public func copy(withUnitCenter newUnitCenter: CGPoint) -> ImageEditorTextItem { + return ImageEditorTextItem(itemId: itemId, + color: color, + font: font, + text: text, + unitCenter: newUnitCenter, + unitWidth: unitWidth, + rotationRadians: rotationRadians, + scaling: scaling, + imagePath: imagePath) + } + + @objc + public func copy(withUnitCenter newUnitCenter: CGPoint, + scaling newScaling: CGFloat, + rotationRadians newRotationRadians: CGFloat) -> ImageEditorTextItem { + return ImageEditorTextItem(itemId: itemId, + color: color, + font: font, + text: text, + unitCenter: newUnitCenter, + unitWidth: unitWidth, + rotationRadians: newRotationRadians, + scaling: newScaling, + imagePath: imagePath) + } + + public override func clone(withImageEditorConversion conversion: ImageEditorConversion) -> ImageEditorItem { + let convertedUnitCenter = conversion(unitCenter) + let convertedUnitWidth = conversion(CGPoint(x: unitWidth, y: 0)).x + + return ImageEditorTextItem(itemId: itemId, + color: color, + font: font, + text: text, + unitCenter: convertedUnitCenter, + unitWidth: convertedUnitWidth, + rotationRadians: rotationRadians, + scaling: scaling, + imagePath: imagePath) + } + + public override func outputScale() -> CGFloat { + return scaling + } +} + +// MARK: - + public class OrderedDictionary: NSObject { public typealias KeyType = String @@ -422,6 +584,11 @@ public class ImageEditorModel: NSObject { return contents.items() } + @objc + public func has(itemForId itemId: String) -> Bool { + return item(forId: itemId) != nil + } + @objc public func item(forId itemId: String) -> ImageEditorItem? { return contents.item(forId: itemId) @@ -559,7 +726,7 @@ public class ImageEditorModel: NSObject { let right = unitCropRect.origin.x + unitCropRect.size.width let top = unitCropRect.origin.y let bottom = unitCropRect.origin.y + unitCropRect.size.height - let conversion: ImageEditorItem.PointConversionFunction = { (point) in + let conversion: ImageEditorConversion = { (point) in // Convert from the pre-crop unit coordinate system // to post-crop unit coordinate system using inverse // lerp. @@ -580,7 +747,7 @@ public class ImageEditorModel: NSObject { let newContents = ImageEditorContents(imagePath: croppedImagePath, imageSizePixels: croppedImageSizePixels) for oldItem in oldContents.items() { - let newItem = oldItem.clone(withPointConversionFunction: conversion) + let newItem = oldItem.clone(withImageEditorConversion: conversion) newContents.append(item: newItem) } return newContents diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorPinchGestureRecognizer.swift b/SignalMessaging/Views/ImageEditor/ImageEditorPinchGestureRecognizer.swift new file mode 100644 index 000000000..ae7042247 --- /dev/null +++ b/SignalMessaging/Views/ImageEditor/ImageEditorPinchGestureRecognizer.swift @@ -0,0 +1,202 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +public struct ImageEditorPinchState { + public let centroid: CGPoint + public let distance: CGFloat + public let angleRadians: CGFloat + + init(centroid: CGPoint, + distance: CGFloat, + angleRadians: CGFloat) { + self.centroid = centroid + self.distance = distance + self.angleRadians = angleRadians + } + + static func empty() -> ImageEditorPinchState { + return ImageEditorPinchState(centroid: .zero, distance: 1.0, angleRadians: 0) + } +} + +public class ImageEditorPinchGestureRecognizer: UIGestureRecognizer { + + public var pinchStateStart = ImageEditorPinchState.empty() + + public var pinchStateLast = ImageEditorPinchState.empty() + + // MARK: - Touch Handling + + private var gestureBeganLocation: CGPoint? + + private func failAndReset() { + state = .failed + gestureBeganLocation = nil + } + + @objc + public override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + if state == .possible { + if gestureBeganLocation == nil { + gestureBeganLocation = centroid(forTouches: event.allTouches) + } + + switch touchState(for: event) { + case .possible: + // Do nothing + break + case .invalid: + failAndReset() + case .valid(let pinchState): + state = .began + pinchStateStart = pinchState + pinchStateLast = pinchState + } + } else { + failAndReset() + } + } + + @objc + public override func touchesMoved(_ touches: Set, with event: UIEvent) { + super.touchesMoved(touches, with: event) + + switch state { + case .began, .changed: + switch touchState(for: event) { + case .possible: + if let gestureBeganLocation = gestureBeganLocation { + let location = centroid(forTouches: event.allTouches) + + // If the initial touch moves too much without a second touch, + // this GR needs to fail - the gesture looks like a pan/swipe/etc., + // not a pinch. + let distance = CGPointDistance(location, gestureBeganLocation) + let maxDistance: CGFloat = 10.0 + guard distance <= maxDistance else { + failAndReset() + return + } + } + + // Do nothing + break + case .invalid: + failAndReset() + case .valid(let pinchState): + state = .changed + pinchStateLast = pinchState + } + default: + failAndReset() + } + } + + @objc + public override func touchesEnded(_ touches: Set, with event: UIEvent) { + super.touchesEnded(touches, with: event) + + switch state { + case .began, .changed: + switch touchState(for: event) { + case .possible: + failAndReset() + case .invalid: + failAndReset() + case .valid(let pinchState): + state = .ended + pinchStateLast = pinchState + } + default: + failAndReset() + } + } + + @objc + public override func touchesCancelled(_ touches: Set, with event: UIEvent) { + super.touchesCancelled(touches, with: event) + + state = .cancelled + } + + public enum TouchState { + case possible + case valid(pinchState : ImageEditorPinchState) + case invalid + } + + private func touchState(for event: UIEvent) -> TouchState { + guard let allTouches = event.allTouches else { + owsFailDebug("Missing allTouches") + return .invalid + } + // Note that we use _all_ touches. + if allTouches.count < 2 { + return .possible + } + guard let pinchState = pinchState(for: allTouches) else { + return .invalid + } + return .valid(pinchState:pinchState) + } + + private func pinchState(for touches: Set) -> ImageEditorPinchState? { + guard let view = self.view else { + owsFailDebug("Missing view") + return nil + } + guard touches.count == 2 else { + return nil + } + let touchList = Array(touches).sorted { (left, right) -> Bool in + // TODO: Will timestamp yield stable sort? + left.timestamp < right.timestamp + } + guard let touch0 = touchList.first else { + return nil + } + guard let touch1 = touchList.last else { + return nil + } + let location0 = touch0.location(in: view) + let location1 = touch1.location(in: view) + + let centroid = CGPointScale(CGPointAdd(location0, location1), 0.5) + let distance = CGPointDistance(location0, location1) + + // The valence of the angle doesn't matter; we're only going to be using + // changes to the angle. + let delta = CGPointSubtract(location1, location0) + let angleRadians = atan2(delta.y, delta.x) + + return ImageEditorPinchState(centroid: centroid, + distance: distance, + angleRadians: angleRadians) + } + + private func centroid(forTouches touches: Set?) -> CGPoint { + guard let view = self.view else { + owsFailDebug("Missing view") + return .zero + } + guard let touches = touches else { + return .zero + } + guard touches.count > 0 else { + return .zero + } + var sum = CGPoint.zero + for touch in touches { + let location = touch.location(in: view) + sum = CGPointAdd(sum, location) + } + + let centroid = CGPointScale(sum, 1 / CGFloat(touches.count)) + return centroid + } +} diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift b/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift new file mode 100644 index 000000000..75a580646 --- /dev/null +++ b/SignalMessaging/Views/ImageEditor/ImageEditorTextViewController.swift @@ -0,0 +1,213 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +@objc +public protocol VAlignTextViewDelegate: class { + func textViewDidComplete() +} + +// MARK: - + +private class VAlignTextView: UITextView { + fileprivate weak var textViewDelegate: VAlignTextViewDelegate? + + enum Alignment: String { + case top + case center + case bottom + } + private let alignment: Alignment + + @objc public override var bounds: CGRect { + didSet { + if oldValue != bounds { + updateInsets() + } + } + } + + @objc public override var frame: CGRect { + didSet { + if oldValue != frame { + updateInsets() + } + } + } + + public init(alignment: Alignment) { + self.alignment = alignment + + super.init(frame: .zero, textContainer: nil) + + self.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil) + } + + @available(*, unavailable, message: "use other init() instead.") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } + + deinit { + self.removeObserver(self, forKeyPath: "contentSize") + } + + private func updateInsets() { + let topOffset: CGFloat + switch alignment { + case .top: + topOffset = 0 + case .center: + topOffset = max(0, (self.height() - contentSize.height) * 0.5) + case .bottom: + topOffset = max(0, self.height() - contentSize.height) + } + contentInset = UIEdgeInsets(top: topOffset, leading: 0, bottom: 0, trailing: 0) + } + + open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + updateInsets() + } + + // MARK: - Key Commands + + override var keyCommands: [UIKeyCommand]? { + return [ + UIKeyCommand(input: "\r", modifierFlags: .command, action: #selector(self.modifiedReturnPressed(sender:)), discoverabilityTitle: "Send Message"), + UIKeyCommand(input: "\r", modifierFlags: .alternate, action: #selector(self.modifiedReturnPressed(sender:)), discoverabilityTitle: "Send Message") + ] + } + + @objc + public func modifiedReturnPressed(sender: UIKeyCommand) { + Logger.verbose("") + + self.textViewDelegate?.textViewDidComplete() + } +} + +// MARK: - + +@objc +public protocol ImageEditorTextViewControllerDelegate: class { + func textEditDidComplete(textItem: ImageEditorTextItem, text: String?) + func textEditDidCancel() +} + +// MARK: - + +// A view for editing text item in image editor. +class ImageEditorTextViewController: OWSViewController, VAlignTextViewDelegate { + private weak var delegate: ImageEditorTextViewControllerDelegate? + + private let textItem: ImageEditorTextItem + + private let maxTextWidthPoints: CGFloat + + private let textView = VAlignTextView(alignment: .bottom) + + init(delegate: ImageEditorTextViewControllerDelegate, + textItem: ImageEditorTextItem, + maxTextWidthPoints: CGFloat) { + self.delegate = delegate + self.textItem = textItem + self.maxTextWidthPoints = maxTextWidthPoints + + super.init(nibName: nil, bundle: nil) + + self.textView.textViewDelegate = self + } + + @available(*, unavailable, message: "use other init() instead.") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } + + // MARK: - View Lifecycle + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + textView.becomeFirstResponder() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + textView.becomeFirstResponder() + } + + override func loadView() { + self.view = UIView() + self.view.backgroundColor = UIColor(white: 0.5, alpha: 0.5) + + 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 having text wrapping be as WYSIWYG as possible, we limit the text view + // to the max text width on the image. + let maxTextWidthPoints = max(self.maxTextWidthPoints, 200) + textView.autoSetDimension(.width, toSize: maxTextWidthPoints, relation: .lessThanOrEqual) + self.autoPinView(toBottomOfViewControllerOrKeyboard: textView, avoidNotch: true) + } + + private func configureTextView() { + textView.text = textItem.text + textView.font = textItem.font + textView.textColor = textItem.color + + textView.isEditable = true + textView.backgroundColor = .clear + textView.isOpaque = false + // We use a white cursor since we use a dark background. + textView.tintColor = .white + textView.returnKeyType = .done + // TODO: + // textView.delegate = self + textView.isScrollEnabled = true + textView.scrollsToTop = false + textView.isUserInteractionEnabled = true + textView.textAlignment = .center + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.contentInset = .zero + } + + // MARK: - Events + + @objc public func didTapBackButton() { + completeAndDismiss() + } + + private func completeAndDismiss() { + + // 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.") + } + textView.selectedTextRange = nil + + self.delegate?.textEditDidComplete(textItem: textItem, text: textView.text) + + self.dismiss(animated: true) { + // Do nothing. + } + } + + // MARK: - VAlignTextViewDelegate + + func textViewDidComplete() { + completeAndDismiss() + } +} diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift index d4b81425f..94da61b12 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift @@ -4,13 +4,61 @@ import UIKit +extension UIView { + public func renderAsImage() -> UIImage? { + return renderAsImage(opaque: false, scale: UIScreen.main.scale) + } + + public func renderAsImage(opaque: Bool, scale: CGFloat) -> UIImage? { + if #available(iOS 10, *) { + let format = UIGraphicsImageRendererFormat() + format.scale = scale + format.opaque = opaque + let renderer = UIGraphicsImageRenderer(bounds: self.bounds, + format: format) + return renderer.image { (context) in + self.layer.render(in: context.cgContext) + } + } else { + UIGraphicsBeginImageContextWithOptions(bounds.size, opaque, scale) + if let _ = UIGraphicsGetCurrentContext() { + drawHierarchy(in: bounds, afterScreenUpdates: true) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + } + owsFailDebug("Could not create graphics context.") + return nil + } + } +} + +private class EditorTextLayer: CATextLayer { + let itemId: String + + public init(itemId: String) { + self.itemId = itemId + + super.init() + } + + @available(*, unavailable, message: "use other init() instead.") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } +} + +// MARK: - + // A view for editing outgoing image attachments. // It can also be used to render the final output. @objc -public class ImageEditorView: UIView, ImageEditorModelDelegate { +public class ImageEditorView: UIView, ImageEditorModelDelegate, ImageEditorTextViewControllerDelegate, UIGestureRecognizerDelegate { + private let model: ImageEditorModel enum EditorMode: String { + // This is the default mode. It is used for interacting with text items. case none case brush case crop @@ -20,23 +68,11 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { didSet { AssertIsOnMainThread() - switch editorMode { - case .none: - editorGestureRecognizer?.isEnabled = false - case .brush: - // Brush strokes can start and end (and return from) outside the view. - editorGestureRecognizer?.shouldAllowOutsideView = true - editorGestureRecognizer?.isEnabled = true - case .crop: - // Crop gestures can start and end (and return from) outside the view. - editorGestureRecognizer?.shouldAllowOutsideView = true - editorGestureRecognizer?.isEnabled = true - } + updateGestureState() } } - // TODO: - private static let defaultColor = UIColor.ows_signalBlue + private static let defaultColor = UIColor.white private var currentColor = ImageEditorView.defaultColor @objc @@ -59,6 +95,8 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { private var imageViewConstraints = [NSLayoutConstraint]() private let layersView = OWSLayerView() private var editorGestureRecognizer: ImageEditorGestureRecognizer? + private var tapGestureRecognizer: UITapGestureRecognizer? + private var pinchGestureRecognizer: ImageEditorPinchGestureRecognizer? @objc public func configureSubviews() -> Bool { @@ -77,18 +115,50 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { self.isUserInteractionEnabled = true layersView.isUserInteractionEnabled = true - let editorGestureRecognizer = ImageEditorGestureRecognizer(target: self, action: #selector(handleTouchGesture(_:))) + + let editorGestureRecognizer = ImageEditorGestureRecognizer(target: self, action: #selector(handleEditorGesture(_:))) editorGestureRecognizer.canvasView = layersView + editorGestureRecognizer.delegate = self self.addGestureRecognizer(editorGestureRecognizer) self.editorGestureRecognizer = editorGestureRecognizer - editorGestureRecognizer.isEnabled = false + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) + self.addGestureRecognizer(tapGestureRecognizer) + self.tapGestureRecognizer = tapGestureRecognizer + + let pinchGestureRecognizer = ImageEditorPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) + self.addGestureRecognizer(pinchGestureRecognizer) + self.pinchGestureRecognizer = pinchGestureRecognizer + + // De-conflict the GRs. + 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) + } + } + @objc public func updateImageView() -> Bool { - Logger.verbose("") guard let image = UIImage(contentsOfFile: model.currentImagePath) else { owsFailDebug("Could not load image") @@ -129,6 +199,8 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { private let redoButton = UIButton(type: .custom) private let brushButton = UIButton(type: .custom) private let cropButton = UIButton(type: .custom) + private let newTextButton = UIButton(type: .custom) + private var allButtons = [UIButton]() @objc public func addControls(to containerView: UIView) { @@ -148,11 +220,17 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { label: NSLocalizedString("IMAGE_EDITOR_CROP_BUTTON", comment: "Label for crop button in image editor."), selector: #selector(didTapCrop(sender:))) + configure(button: newTextButton, + label: "Text", + selector: #selector(didTapNewText(sender:))) + let redButton = colorButton(color: UIColor.red) let whiteButton = colorButton(color: UIColor.white) let blackButton = colorButton(color: UIColor.black) - let stackView = UIStackView(arrangedSubviews: [brushButton, cropButton, undoButton, redoButton, redButton, whiteButton, blackButton]) + allButtons = [brushButton, cropButton, undoButton, redoButton, newTextButton, redButton, whiteButton, blackButton] + + let stackView = UIStackView(arrangedSubviews: allButtons) stackView.axis = .vertical stackView.alignment = .center stackView.spacing = 10 @@ -191,6 +269,11 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { redoButton.isEnabled = model.canRedo() brushButton.isSelected = editorMode == .brush cropButton.isSelected = editorMode == .crop + newTextButton.isSelected = false + + for button in allButtons { + button.isHidden = isEditingTextItem + } } // MARK: - Actions @@ -225,6 +308,14 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { toggle(editorMode: .crop) } + @objc func didTapNewText(sender: UIButton) { + Logger.verbose("") + + let textItem = ImageEditorTextItem.empty(withColor: currentColor) + + edit(textItem: textItem) + } + func toggle(editorMode: EditorMode) { if self.editorMode == editorMode { self.editorMode = .none @@ -240,12 +331,160 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { currentColor = color } - @objc - public func handleTouchGesture(_ gestureRecognizer: UIGestureRecognizer) { + // MARK: - Gestures + + private func updateGestureState() { AssertIsOnMainThread() switch editorMode { case .none: + editorGestureRecognizer?.shouldAllowOutsideView = true + editorGestureRecognizer?.isEnabled = true + tapGestureRecognizer?.isEnabled = true + pinchGestureRecognizer?.isEnabled = true + case .brush: + // Brush strokes can start and end (and return from) outside the view. + editorGestureRecognizer?.shouldAllowOutsideView = true + editorGestureRecognizer?.isEnabled = true + tapGestureRecognizer?.isEnabled = false + pinchGestureRecognizer?.isEnabled = false + case .crop: + // Crop gestures can start and end (and return from) outside the view. + editorGestureRecognizer?.shouldAllowOutsideView = true + editorGestureRecognizer?.isEnabled = true + tapGestureRecognizer?.isEnabled = false + pinchGestureRecognizer?.isEnabled = false + } + } + + // MARK: - Tap Gesture + + @objc + public func handleTapGesture(_ gestureRecognizer: UIGestureRecognizer) { + AssertIsOnMainThread() + + guard gestureRecognizer.state == .recognized else { + owsFailDebug("Unexpected state.") + return + } + + guard let textLayer = textLayer(forGestureRecognizer: gestureRecognizer) else { + return + } + + guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else { + owsFailDebug("Missing or invalid text item.") + return + } + + edit(textItem: textItem) + } + + private var isEditingTextItem = false { + didSet { + AssertIsOnMainThread() + + updateButtons() + } + } + + private func edit(textItem: ImageEditorTextItem) { + Logger.verbose("") + + toggle(editorMode: .none) + + guard let viewController = self.containingViewController() else { + owsFailDebug("Can't find view controller.") + return + } + + isEditingTextItem = true + + let maxTextWidthPoints = imageView.width() * ImageEditorTextItem.kDefaultUnitWidth + + let textEditor = ImageEditorTextViewController(delegate: self, textItem: textItem, maxTextWidthPoints: maxTextWidthPoints) + let navigationController = OWSNavigationController(rootViewController: textEditor) + navigationController.modalPresentationStyle = .overFullScreen + viewController.present(navigationController, animated: true) { + // Do nothing. + } + } + + // MARK: - Pinch Gesture + + // These properties are valid while moving a text item. + private var pinchingTextItem: ImageEditorTextItem? + private var pinchHasChanged = false + + @objc + public func handlePinchGesture(_ gestureRecognizer: ImageEditorPinchGestureRecognizer) { + AssertIsOnMainThread() + + // We could undo an in-progress pinch if the gesture is cancelled, but it seems gratuitous. + + switch gestureRecognizer.state { + case .began: + let pinchState = gestureRecognizer.pinchStateStart + guard let gestureRecognizerView = gestureRecognizer.view else { + owsFailDebug("Missing gestureRecognizer.view.") + return + } + let location = gestureRecognizerView.convert(pinchState.centroid, to: unitReferenceView) + guard let textLayer = textLayer(forLocation: location) else { + // The pinch needs to start centered on a text item. + return + } + guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else { + owsFailDebug("Missing or invalid text item.") + return + } + pinchingTextItem = textItem + pinchHasChanged = false + case .changed, .ended: + guard let textItem = pinchingTextItem else { + return + } + + let locationDelta = CGPointSubtract(gestureRecognizer.pinchStateLast.centroid, + gestureRecognizer.pinchStateStart.centroid) + let unitLocationDelta = convertToUnit(location: locationDelta, shouldClamp: false) + let unitCenter = CGPointClamp01(CGPointAdd(textItem.unitCenter, unitLocationDelta)) + + // NOTE: We use max(1, ...) to avoid divide-by-zero. + let newScaling = CGFloatClamp(textItem.scaling * gestureRecognizer.pinchStateLast.distance / max(1.0, gestureRecognizer.pinchStateStart.distance), + ImageEditorTextItem.kMinScaling, + ImageEditorTextItem.kMaxScaling) + + let newRotationRadians = textItem.rotationRadians + gestureRecognizer.pinchStateLast.angleRadians - gestureRecognizer.pinchStateStart.angleRadians + + let newItem = textItem.copy(withUnitCenter: unitCenter, + scaling: newScaling, + rotationRadians: newRotationRadians) + + if pinchHasChanged { + model.replace(item: newItem, suppressUndo: true) + } else { + model.replace(item: newItem, suppressUndo: false) + pinchHasChanged = true + } + + if gestureRecognizer.state == .ended { + pinchingTextItem = nil + } + default: + pinchingTextItem = nil + } + } + + // MARK: - Editor Gesture + + @objc + public func handleEditorGesture(_ gestureRecognizer: ImageEditorGestureRecognizer) { + AssertIsOnMainThread() + + switch editorMode { + case .none: + handleDefaultGesture(gestureRecognizer) break case .brush: handleBrushGesture(gestureRecognizer) @@ -254,6 +493,65 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { } } + // These properties are valid while moving a text item. + private var movingTextItem: ImageEditorTextItem? + private var movingTextStartUnitLocation = CGPoint.zero + private var movingTextStartUnitCenter = CGPoint.zero + private var movingTextHasMoved = false + + @objc + public func handleDefaultGesture(_ gestureRecognizer: ImageEditorGestureRecognizer) { + AssertIsOnMainThread() + + // We could undo an in-progress move if the gesture is cancelled, but it seems gratuitous. + + switch gestureRecognizer.state { + case .began: + guard let gestureRecognizerView = gestureRecognizer.view else { + owsFailDebug("Missing gestureRecognizer.view.") + return + } + let location = gestureRecognizerView.convert(gestureRecognizer.startLocationInView, to: unitReferenceView) + guard let textLayer = textLayer(forLocation: location) else { + owsFailDebug("No text layer") + return + } + guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else { + owsFailDebug("Missing or invalid text item.") + return + } + movingTextStartUnitLocation = convertToUnit(location: location, + shouldClamp: false) + + movingTextItem = textItem + movingTextStartUnitCenter = textItem.unitCenter + movingTextHasMoved = false + + case .changed, .ended: + guard let textItem = movingTextItem else { + return + } + + let unitLocation = unitSampleForGestureLocation(gestureRecognizer, shouldClamp: false) + let unitLocationDelta = CGPointSubtract(unitLocation, movingTextStartUnitLocation) + let unitCenter = CGPointClamp01(CGPointAdd(movingTextStartUnitCenter, unitLocationDelta)) + let newItem = textItem.copy(withUnitCenter: unitCenter) + + if movingTextHasMoved { + model.replace(item: newItem, suppressUndo: true) + } else { + model.replace(item: newItem, suppressUndo: false) + movingTextHasMoved = true + } + + if gestureRecognizer.state == .ended { + movingTextItem = nil + } + default: + movingTextItem = nil + } + } + // MARK: - Brush // These properties are non-empty while drawing a stroke. @@ -274,7 +572,7 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { let tryToAppendStrokeSample = { let newSample = self.unitSampleForGestureLocation(gestureRecognizer, shouldClamp: false) if let prevSample = self.currentStrokeSamples.last, - prevSample == newSample { + prevSample == newSample { // Ignore duplicate samples. return } @@ -320,13 +618,22 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { } } + private var unitReferenceView: UIView { + return layersView + } + private func unitSampleForGestureLocation(_ gestureRecognizer: UIGestureRecognizer, shouldClamp: Bool) -> CGPoint { - let referenceView = layersView // TODO: Smooth touch samples before converting into stroke samples. - let location = gestureRecognizer.location(in: referenceView) - var x = CGFloatInverseLerp(location.x, 0, referenceView.bounds.width) - var y = CGFloatInverseLerp(location.y, 0, referenceView.bounds.height) + let location = gestureRecognizer.location(in: unitReferenceView) + return convertToUnit(location: location, + shouldClamp: shouldClamp) + } + + private func convertToUnit(location: CGPoint, + shouldClamp: Bool) -> CGPoint { + var x = CGFloatInverseLerp(location.x, 0, unitReferenceView.bounds.width) + var y = CGFloatInverseLerp(location.y, 0, unitReferenceView.bounds.height) if shouldClamp { x = CGFloatClamp01(x) y = CGFloatClamp01(y) @@ -565,6 +872,12 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { return nil } return strokeLayerForItem(item: strokeItem, viewSize: viewSize) + case .text: + guard let textItem = item as? ImageEditorTextItem else { + owsFailDebug("Item has unexpected type: \(type(of: item)).") + return nil + } + return textLayerForItem(item: textItem, viewSize: viewSize) } } @@ -658,6 +971,50 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { return shapeLayer } + private class func textLayerForItem(item: ImageEditorTextItem, + viewSize: CGSize) -> CALayer? { + AssertIsOnMainThread() + + let layer = EditorTextLayer(itemId: item.itemId) + layer.string = item.text + layer.foregroundColor = item.color.cgColor + layer.font = CGFont(item.font.fontName as CFString) + layer.fontSize = item.font.pointSize + layer.isWrapped = true + layer.alignmentMode = kCAAlignmentCenter + // I don't think we need to enable allowsFontSubpixelQuantization + // or set truncationMode. + + // This text needs to be rendered at a scale that reflects the scaling. + layer.contentsScale = UIScreen.main.scale * item.scaling + + // TODO: Min with measured width. + let maxWidth = viewSize.width * item.unitWidth + let maxSize = CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude) + // TODO: Is there a more accurate way to measure text in a CATextLayer? + // CoreText? + let textBounds = (item.text as NSString).boundingRect(with: maxSize, + options: [ + .usesLineFragmentOrigin, + .usesFontLeading + ], + attributes: [ + .font: item.font + ], + context: nil) + let center = CGPoint(x: viewSize.width * item.unitCenter.x, + y: viewSize.height * item.unitCenter.y) + let layerSize = CGSizeCeil(textBounds.size) + layer.frame = CGRect(origin: CGPoint(x: center.x - layerSize.width * 0.5, + y: center.y - layerSize.height * 0.5), + size: layerSize) + + let transform = CGAffineTransform.identity.scaledBy(x: item.scaling, y: item.scaling).rotated(by: item.rotationRadians) + layer.setAffineTransform(transform) + + return layer + } + // We apply more than one kind of smoothing. // // This (simple) smoothing reduces jitter from the touch sensor. @@ -700,6 +1057,7 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { // Render output at same size as source image. let dstSizePixels = model.srcImageSizePixels + let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points. let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.currentImagePath) @@ -708,37 +1066,94 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { return nil } - let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points. - UIGraphicsBeginImageContextWithOptions(dstSizePixels, !hasAlpha, dstScale) - defer { UIGraphicsEndImageContext() } - - guard let context = UIGraphicsGetCurrentContext() else { - owsFailDebug("Could not create output context.") - return nil - } - context.interpolationQuality = .high - - // Draw source image. - let dstFrame = CGRect(origin: .zero, size: model.srcImageSizePixels) - srcImage.draw(in: dstFrame) - + // We use an UIImageView + UIView.renderAsImage() instead of a CGGraphicsContext + // Because CALayer.renderInContext() doesn't honor CALayer properties like frame, + // transform, etc. + let imageView = UIImageView(image: srcImage) + imageView.frame = CGRect(origin: .zero, size: dstSizePixels) for item in model.items() { guard let layer = layerForItem(item: item, viewSize: dstSizePixels) else { - Logger.error("Couldn't create layer for item.") + Logger.error("Couldn't create layer for item.") + continue + } + layer.contentsScale = dstScale * item.outputScale() + imageView.layer.addSublayer(layer) + } + let image = imageView.renderAsImage(opaque: !hasAlpha, scale: dstScale) + return image + } + + // MARK: - ImageEditorTextViewControllerDelegate + + public func textEditDidComplete(textItem: ImageEditorTextItem, text: String?) { + AssertIsOnMainThread() + + isEditingTextItem = false + + guard let text = text?.ows_stripped(), + text.count > 0 else { + if model.has(itemForId: textItem.itemId) { + 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) + } + } + + public func textEditDidCancel() { + isEditingTextItem = false + } + + // MARK: - UIGestureRecognizerDelegate + + @objc public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard let editorGestureRecognizer = editorGestureRecognizer else { + owsFailDebug("Missing editorGestureRecognizer.") + return false + } + guard editorGestureRecognizer == gestureRecognizer else { + owsFailDebug("Unexpected gesture.") + return false + } + guard editorMode == .none else { + // We only filter touches when in default mode. + return true + } + + let isInTextArea = textLayer(forTouch: touch) != nil + return isInTextArea + } + + private func textLayer(forTouch touch: UITouch) -> EditorTextLayer? { + let point = touch.location(in: layersView) + return textLayer(forLocation: point) + } + + private func textLayer(forGestureRecognizer gestureRecognizer: UIGestureRecognizer) -> EditorTextLayer? { + let point = gestureRecognizer.location(in: layersView) + return textLayer(forLocation: point) + } + + private func textLayer(forLocation point: CGPoint) -> EditorTextLayer? { + guard let sublayers = layersView.layer.sublayers else { + return nil + } + for layer in sublayers { + guard let textLayer = layer as? EditorTextLayer else { continue } - // This might be superfluous, but ensure that the layer renders - // at "point=pixel" scale. - layer.contentsScale = 1.0 - - layer.render(in: context) + if textLayer.hitTest(point) != nil { + return textLayer + } } - - let scaledImage = UIGraphicsGetImageFromCurrentImageContext() - if scaledImage == nil { - owsFailDebug("could not generate dst image.") - } - return scaledImage + return nil } } diff --git a/SignalMessaging/categories/UIView+OWS.h b/SignalMessaging/categories/UIView+OWS.h index 2a7f63224..1f94bab21 100644 --- a/SignalMessaging/categories/UIView+OWS.h +++ b/SignalMessaging/categories/UIView+OWS.h @@ -117,6 +117,8 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value); + (UIView *)verticalStackWithSubviews:(NSArray *)subviews spacing:(int)spacing; +- (nullable UIViewController *)containingViewController; + #pragma mark - Debugging - (void)addBorderWithColor:(UIColor *)color; diff --git a/SignalMessaging/categories/UIView+OWS.m b/SignalMessaging/categories/UIView+OWS.m index babbd3f9f..cff1b3330 100644 --- a/SignalMessaging/categories/UIView+OWS.m +++ b/SignalMessaging/categories/UIView+OWS.m @@ -448,6 +448,19 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value) return container; } +- (nullable UIViewController *)containingViewController +{ + UIResponder *responder = self; + while (responder) { + if ([responder isKindOfClass:[UIViewController class]]) { + UIViewController *viewController = (UIViewController *)responder; + return viewController; + } + responder = responder.nextResponder; + } + return nil; +} + #pragma mark - Debugging - (void)addBorderWithColor:(UIColor *)color