First draft of image editor's text tool.

This commit is contained in:
Matthew Chen 2019-02-06 13:50:18 -05:00
parent 4e172fe8b6
commit 3f8ea271b4
9 changed files with 1118 additions and 82 deletions

View File

@ -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 = "<group>"; };
34B6D27220F664C800765BE2 /* OWSUnreadIndicator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSUnreadIndicator.h; sourceTree = "<group>"; };
34B6D27320F664C800765BE2 /* OWSUnreadIndicator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSUnreadIndicator.m; sourceTree = "<group>"; };
34BBC84A220B2CB200857249 /* ImageEditorTextViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorTextViewController.swift; sourceTree = "<group>"; };
34BBC84C220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorPinchGestureRecognizer.swift; sourceTree = "<group>"; };
34BECE291F74C12700D7438D /* DebugUIStress.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DebugUIStress.h; sourceTree = "<group>"; };
34BECE2A1F74C12700D7438D /* DebugUIStress.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DebugUIStress.m; sourceTree = "<group>"; };
34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -1317,6 +1317,8 @@ extension AttachmentPrepViewController: UIScrollViewDelegate {
}
}
// MARK: -
class BottomToolView: UIView {
let mediaMessageTextToolbar: MediaMessageTextToolbar
let galleryRailView: GalleryRailView

View File

@ -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<UITouch>, with event: UIEvent) {
public override func touchesBegan(_ touches: Set<UITouch>, 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<UITouch>, with event: UIEvent) {
public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
switch state {
@ -70,7 +84,7 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer {
}
@objc
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
public override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
switch state {
@ -88,7 +102,7 @@ class ImageEditorGestureRecognizer: UIGestureRecognizer {
}
@objc
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
state = .cancelled

View File

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

View File

@ -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<UITouch>, 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<UITouch>, 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<UITouch>, 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<UITouch>, 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<UITouch>) -> ImageEditorPinchState? {
guard let view = self.view else {
owsFailDebug("Missing view")
return nil
}
guard touches.count == 2 else {
return nil
}
let touchList = Array<UITouch>(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<UITouch>?) -> 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
}
}

View File

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

View File

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

View File

@ -117,6 +117,8 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value);
+ (UIView *)verticalStackWithSubviews:(NSArray<UIView *> *)subviews spacing:(int)spacing;
- (nullable UIViewController *)containingViewController;
#pragma mark - Debugging
- (void)addBorderWithColor:(UIColor *)color;

View File

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