mirror of https://github.com/oxen-io/session-ios
Merge branch 'charlesmchen/imageEditorText'
commit
9c93a03d2e
@ -0,0 +1,207 @@
|
|||||||
|
//
|
||||||
|
// 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 var empty: ImageEditorPinchState {
|
||||||
|
return ImageEditorPinchState(centroid: .zero, distance: 1.0, angleRadians: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This GR:
|
||||||
|
//
|
||||||
|
// * Tries to fail quickly to avoid conflicts with other GRs, especially pans/swipes.
|
||||||
|
// * Captures a bunch of useful "pinch state" that makes using this GR much easier
|
||||||
|
// than UIPinchGestureRecognizer.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@ -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 have 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: Limit the size of the text.
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue