session-ios/SignalUtilitiesKit/ImageEditorView.swift

562 lines
21 KiB
Swift
Raw Normal View History

2018-12-14 21:01:02 +01:00
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
2018-12-14 21:01:02 +01:00
//
import UIKit
2019-02-12 16:03:32 +01:00
@objc
public protocol ImageEditorViewDelegate: class {
func imageEditor(presentFullScreenView viewController: UIViewController,
isTransparent: Bool)
func imageEditorUpdateNavigationBar()
2019-03-12 17:19:09 +01:00
func imageEditorUpdateControls()
2019-02-12 16:03:32 +01:00
}
// MARK: -
2018-12-17 20:34:58 +01:00
// A view for editing outgoing image attachments.
// It can also be used to render the final output.
2018-12-14 21:01:02 +01:00
@objc
2019-02-06 22:00:22 +01:00
public class ImageEditorView: UIView {
2019-02-12 16:03:32 +01:00
weak var delegate: ImageEditorViewDelegate?
2018-12-14 21:01:02 +01:00
private let model: ImageEditorModel
2019-02-06 22:00:22 +01:00
private let canvasView: ImageEditorCanvasView
// TODO: We could hang this on the model or make this static
// if we wanted more color continuity.
2019-02-28 20:23:11 +01:00
private var currentColor = ImageEditorColor.defaultColor()
2018-12-20 15:59:31 +01:00
2018-12-14 21:01:02 +01:00
@objc
2019-02-12 16:03:32 +01:00
public required init(model: ImageEditorModel, delegate: ImageEditorViewDelegate) {
2018-12-14 21:01:02 +01:00
self.model = model
2019-02-12 16:03:32 +01:00
self.delegate = delegate
2019-02-06 22:00:22 +01:00
self.canvasView = ImageEditorCanvasView(model: model)
2018-12-14 21:01:02 +01:00
super.init(frame: .zero)
2019-02-06 22:00:22 +01:00
model.add(observer: self)
2018-12-14 21:01:02 +01:00
}
@available(*, unavailable, message: "use other init() instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
2018-12-18 23:54:36 +01:00
// MARK: - Views
2019-02-06 22:00:22 +01:00
private var moveTextGestureRecognizer: ImageEditorPanGestureRecognizer?
private var tapGestureRecognizer: UITapGestureRecognizer?
private var pinchGestureRecognizer: ImageEditorPinchGestureRecognizer?
2018-12-18 23:54:36 +01:00
@objc
2018-12-19 20:39:48 +01:00
public func configureSubviews() -> Bool {
2019-02-28 19:13:20 +01:00
canvasView.configureSubviews()
2019-02-06 22:00:22 +01:00
self.addSubview(canvasView)
2019-02-26 23:42:27 +01:00
canvasView.autoPinEdgesToSuperviewEdges()
2018-12-18 23:54:36 +01:00
self.isUserInteractionEnabled = true
2019-02-06 22:00:22 +01:00
let moveTextGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleMoveTextGesture(_:)))
moveTextGestureRecognizer.maximumNumberOfTouches = 1
moveTextGestureRecognizer.referenceView = canvasView.gestureReferenceView
moveTextGestureRecognizer.delegate = self
self.addGestureRecognizer(moveTextGestureRecognizer)
self.moveTextGestureRecognizer = moveTextGestureRecognizer
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:)))
self.addGestureRecognizer(tapGestureRecognizer)
self.tapGestureRecognizer = tapGestureRecognizer
let pinchGestureRecognizer = ImageEditorPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
2019-02-06 22:00:22 +01:00
pinchGestureRecognizer.referenceView = canvasView.gestureReferenceView
self.addGestureRecognizer(pinchGestureRecognizer)
self.pinchGestureRecognizer = pinchGestureRecognizer
// De-conflict the GRs.
2019-02-06 22:00:22 +01:00
// editorGestureRecognizer.require(toFail: tapGestureRecognizer)
// editorGestureRecognizer.require(toFail: pinchGestureRecognizer)
2018-12-18 23:54:36 +01:00
return true
}
2019-03-12 17:19:09 +01:00
// MARK: - Navigation Bar
private func updateNavigationBar() {
delegate?.imageEditorUpdateNavigationBar()
2018-12-17 23:05:43 +01:00
}
public func navigationBarItems() -> [UIView] {
2019-03-12 17:19:09 +01:00
guard !shouldHideControls else {
return []
}
let undoButton = navigationBarButton(imageName: "image_editor_undo",
selector: #selector(didTapUndo(sender:)))
let brushButton = navigationBarButton(imageName: "image_editor_brush",
selector: #selector(didTapBrush(sender:)))
let cropButton = navigationBarButton(imageName: "image_editor_crop",
selector: #selector(didTapCrop(sender:)))
let newTextButton = navigationBarButton(imageName: "image_editor_text",
selector: #selector(didTapNewText(sender:)))
var buttons: [UIView]
2019-02-28 20:23:11 +01:00
if model.canUndo() {
buttons = [undoButton, newTextButton, brushButton, cropButton]
2019-02-28 20:23:11 +01:00
} else {
buttons = [newTextButton, brushButton, cropButton]
}
return buttons
}
2019-03-12 17:19:09 +01:00
private func updateControls() {
delegate?.imageEditorUpdateControls()
}
public var shouldHideControls: Bool {
// Hide controls during "text item move".
return movingTextItem != nil
}
// MARK: - Actions
2018-12-17 23:05:43 +01:00
@objc func didTapUndo(sender: UIButton) {
Logger.verbose("")
guard model.canUndo() else {
owsFailDebug("Can't undo.")
return
}
model.undo()
}
2018-12-18 23:54:36 +01:00
@objc func didTapBrush(sender: UIButton) {
Logger.verbose("")
2019-02-28 20:23:11 +01:00
let brushView = ImageEditorBrushViewController(delegate: self, model: model, currentColor: currentColor)
self.delegate?.imageEditor(presentFullScreenView: brushView,
isTransparent: false)
2018-12-18 23:54:36 +01:00
}
@objc func didTapCrop(sender: UIButton) {
Logger.verbose("")
2019-02-06 22:00:22 +01:00
presentCropTool()
2019-01-07 23:44:11 +01:00
}
@objc func didTapNewText(sender: UIButton) {
Logger.verbose("")
2019-03-12 16:41:04 +01:00
createNewTextItem()
}
private func createNewTextItem() {
Logger.verbose("")
2019-02-06 22:00:22 +01:00
let viewSize = canvasView.gestureReferenceView.bounds.size
let imageSize = model.srcImageSizePixels
let imageFrame = ImageEditorCanvasView.imageFrame(forViewSize: viewSize, imageSize: imageSize,
transform: model.currentTransform())
let textWidthPoints = viewSize.width * ImageEditorTextItem.kDefaultUnitWidth
let textWidthUnit = textWidthPoints / imageFrame.size.width
2019-03-01 19:36:09 +01:00
// New items should be aligned "upright", so they should have the _opposite_
// of the current transform rotation.
let rotationRadians = -model.currentTransform().rotationRadians
// Similarly, the size of the text item shuo
let scaling = 1 / model.currentTransform().scaling
2019-02-06 22:00:22 +01:00
let textItem = ImageEditorTextItem.empty(withColor: currentColor,
unitWidth: textWidthUnit,
2019-03-01 19:36:09 +01:00
fontReferenceImageWidth: imageFrame.size.width,
scaling: scaling,
rotationRadians: rotationRadians)
2019-03-01 19:36:09 +01:00
edit(textItem: textItem, isNewItem: true)
}
@objc func didTapDone(sender: UIButton) {
Logger.verbose("")
}
// MARK: - Tap Gesture
@objc
public func handleTapGesture(_ gestureRecognizer: UIGestureRecognizer) {
AssertIsOnMainThread()
guard gestureRecognizer.state == .recognized else {
owsFailDebug("Unexpected state.")
return
}
2019-02-06 22:00:22 +01:00
let location = gestureRecognizer.location(in: canvasView.gestureReferenceView)
guard let textLayer = self.textLayer(forLocation: location) else {
2019-03-12 16:41:04 +01:00
// If there is no text item under the "tap", start a new one.
createNewTextItem()
return
}
guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else {
owsFailDebug("Missing or invalid text item.")
return
}
2019-03-01 19:36:09 +01:00
edit(textItem: textItem, isNewItem: false)
}
// MARK: - Pinch Gesture
// These properties are valid while moving a text item.
private var pinchingTextItem: ImageEditorTextItem?
private var pinchHasChanged = false
2018-12-18 23:54:36 +01:00
@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 textLayer = self.textLayer(forLocation: pinchState.centroid) 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 view = self.canvasView.gestureReferenceView
let viewBounds = view.bounds
2019-02-06 22:00:22 +01:00
let locationStart = gestureRecognizer.pinchStateStart.centroid
let locationNow = gestureRecognizer.pinchStateLast.centroid
2019-02-28 19:13:20 +01:00
let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart,
viewBounds: viewBounds,
model: self.model,
transform: self.model.currentTransform())
2019-02-28 19:13:20 +01:00
let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationNow,
viewBounds: viewBounds,
model: self.model,
transform: self.model.currentTransform())
let gestureDeltaImageUnit = gestureNowImageUnit.minus(gestureStartImageUnit)
let unitCenter = CGPointClamp01(textItem.unitCenter.plus(gestureDeltaImageUnit))
// 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(unitCenter: unitCenter).copy(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
// These properties are valid while moving a text item.
2019-03-12 17:19:09 +01:00
private var movingTextItem: ImageEditorTextItem? {
didSet {
updateNavigationBar()
updateControls()
}
}
2019-02-06 22:00:22 +01:00
private var movingTextStartUnitCenter: CGPoint?
private var movingTextHasMoved = false
private func textLayer(forLocation locationInView: CGPoint) -> EditorTextLayer? {
let viewBounds = self.canvasView.gestureReferenceView.bounds
let affineTransform = self.model.currentTransform().affineTransform(viewSize: viewBounds.size)
let locationInCanvas = locationInView.minus(viewBounds.center).applyingInverse(affineTransform).plus(viewBounds.center)
return canvasView.textLayer(forLocation: locationInCanvas)
}
@objc
2019-02-06 22:00:22 +01:00
public func handleMoveTextGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) {
AssertIsOnMainThread()
// We could undo an in-progress move if the gesture is cancelled, but it seems gratuitous.
switch gestureRecognizer.state {
case .began:
2019-03-01 16:37:36 +01:00
guard let locationStart = gestureRecognizer.locationFirst else {
2019-02-06 22:00:22 +01:00
owsFailDebug("Missing locationStart.")
return
}
guard let textLayer = self.textLayer(forLocation: locationStart) else {
owsFailDebug("No text layer")
return
}
guard let textItem = model.item(forId: textLayer.itemId) as? ImageEditorTextItem else {
owsFailDebug("Missing or invalid text item.")
return
}
movingTextItem = textItem
movingTextStartUnitCenter = textItem.unitCenter
movingTextHasMoved = false
case .changed, .ended:
guard let textItem = movingTextItem else {
return
}
2019-03-01 16:37:36 +01:00
guard let locationStart = gestureRecognizer.locationFirst else {
2019-02-06 22:00:22 +01:00
owsFailDebug("Missing locationStart.")
return
}
guard let movingTextStartUnitCenter = movingTextStartUnitCenter else {
owsFailDebug("Missing movingTextStartUnitCenter.")
return
}
let view = self.canvasView.gestureReferenceView
let viewBounds = view.bounds
let locationInView = gestureRecognizer.location(in: view)
2019-02-28 19:13:20 +01:00
let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart,
viewBounds: viewBounds,
model: self.model,
transform: self.model.currentTransform())
2019-02-28 19:13:20 +01:00
let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView,
viewBounds: viewBounds,
model: self.model,
transform: self.model.currentTransform())
let gestureDeltaImageUnit = gestureNowImageUnit.minus(gestureStartImageUnit)
let unitCenter = CGPointClamp01(movingTextStartUnitCenter.plus(gestureDeltaImageUnit))
let newItem = textItem.copy(unitCenter: 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
}
}
2018-12-18 23:54:36 +01:00
// MARK: - Brush
// These properties are non-empty while drawing a stroke.
private var currentStroke: ImageEditorStrokeItem?
private var currentStrokeSamples = [ImageEditorStrokeItem.StrokeSample]()
@objc
2018-12-18 23:54:36 +01:00
public func handleBrushGesture(_ gestureRecognizer: UIGestureRecognizer) {
AssertIsOnMainThread()
let removeCurrentStroke = {
if let stroke = self.currentStroke {
self.model.remove(item: stroke)
}
self.currentStroke = nil
self.currentStrokeSamples.removeAll()
}
2018-12-19 20:39:48 +01:00
let tryToAppendStrokeSample = {
let view = self.canvasView.gestureReferenceView
let viewBounds = view.bounds
let locationInView = gestureRecognizer.location(in: view)
2019-02-28 19:13:20 +01:00
let newSample = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView,
viewBounds: viewBounds,
model: self.model,
transform: self.model.currentTransform())
2018-12-19 20:39:48 +01:00
if let prevSample = self.currentStrokeSamples.last,
prevSample == newSample {
2018-12-19 20:39:48 +01:00
// Ignore duplicate samples.
return
}
self.currentStrokeSamples.append(newSample)
}
2019-02-28 20:23:11 +01:00
let strokeColor = currentColor.color
2018-12-17 20:17:40 +01:00
// TODO: Tune stroke width.
let unitStrokeWidth = ImageEditorStrokeItem.defaultUnitStrokeWidth()
switch gestureRecognizer.state {
case .began:
removeCurrentStroke()
2018-12-19 20:39:48 +01:00
tryToAppendStrokeSample()
2018-12-18 15:12:50 +01:00
let stroke = ImageEditorStrokeItem(color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth)
model.append(item: stroke)
currentStroke = stroke
case .changed, .ended:
2018-12-19 20:39:48 +01:00
tryToAppendStrokeSample()
guard let lastStroke = self.currentStroke else {
owsFailDebug("Missing last stroke.")
removeCurrentStroke()
return
}
// Model items are immutable; we _replace_ the
// stroke item rather than modify it.
2018-12-18 15:12:50 +01:00
let stroke = ImageEditorStrokeItem(itemId: lastStroke.itemId, color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth)
2018-12-18 15:16:32 +01:00
model.replace(item: stroke, suppressUndo: true)
if gestureRecognizer.state == .ended {
2018-12-18 15:12:50 +01:00
currentStroke = nil
currentStrokeSamples.removeAll()
} else {
currentStroke = stroke
}
default:
removeCurrentStroke()
}
}
2019-02-06 22:00:22 +01:00
// MARK: - Edit Text Tool
2018-12-18 23:54:36 +01:00
2019-03-01 19:36:09 +01:00
private func edit(textItem: ImageEditorTextItem, isNewItem: Bool) {
2019-02-06 22:00:22 +01:00
Logger.verbose("")
2019-02-06 22:00:22 +01:00
// TODO:
let maxTextWidthPoints = model.srcImageSizePixels.width * ImageEditorTextItem.kDefaultUnitWidth
// let maxTextWidthPoints = canvasView.imageView.width() * ImageEditorTextItem.kDefaultUnitWidth
2019-02-28 20:23:11 +01:00
let textEditor = ImageEditorTextViewController(delegate: self,
model: model,
textItem: textItem,
2019-03-01 19:36:09 +01:00
isNewItem: isNewItem,
2019-02-28 20:23:11 +01:00
maxTextWidthPoints: maxTextWidthPoints)
self.delegate?.imageEditor(presentFullScreenView: textEditor,
isTransparent: false)
}
2019-02-06 22:00:22 +01:00
// MARK: - Crop Tool
2019-02-06 22:00:22 +01:00
private func presentCropTool() {
Logger.verbose("")
2019-02-06 22:00:22 +01:00
guard let srcImage = canvasView.loadSrcImage() else {
owsFailDebug("Couldn't load src image.")
return
}
2019-02-06 22:00:22 +01:00
// We want to render a preview image that "flattens" all of the brush strokes, text items,
// into the background image without applying the transform (e.g. rotating, etc.), so we
// use a default transform.
let previewTransform = ImageEditorTransform.defaultTransform(srcImageSizePixels: model.srcImageSizePixels)
guard let previewImage = ImageEditorCanvasView.renderForOutput(model: model, transform: previewTransform) else {
owsFailDebug("Couldn't generate preview image.")
return
}
2019-02-06 22:00:22 +01:00
let cropTool = ImageEditorCropViewController(delegate: self, model: model, srcImage: srcImage, previewImage: previewImage)
self.delegate?.imageEditor(presentFullScreenView: cropTool,
isTransparent: false)
}
}
2019-02-06 22:00:22 +01:00
// MARK: -
2019-02-06 22:00:22 +01:00
extension ImageEditorView: UIGestureRecognizerDelegate {
2019-02-06 22:00:22 +01:00
@objc public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard moveTextGestureRecognizer == gestureRecognizer else {
owsFailDebug("Unexpected gesture.")
return false
}
2019-02-06 22:00:22 +01:00
let location = touch.location(in: canvasView.gestureReferenceView)
let isInTextArea = self.textLayer(forLocation: location) != nil
2019-02-06 22:00:22 +01:00
return isInTextArea
}
2019-02-06 22:00:22 +01:00
}
2019-02-06 22:00:22 +01:00
// MARK: -
2018-12-17 21:19:09 +01:00
2019-02-06 22:00:22 +01:00
extension ImageEditorView: ImageEditorModelObserver {
2018-12-17 21:19:09 +01:00
2019-02-06 22:00:22 +01:00
public func imageEditorModelDidChange(before: ImageEditorContents,
after: ImageEditorContents) {
2019-03-12 17:19:09 +01:00
updateNavigationBar()
2018-12-17 21:19:09 +01:00
}
2019-02-06 22:00:22 +01:00
public func imageEditorModelDidChange(changedItemIds: [String]) {
2019-03-12 17:19:09 +01:00
updateNavigationBar()
}
2019-02-06 22:00:22 +01:00
}
// MARK: -
2019-02-06 22:00:22 +01:00
extension ImageEditorView: ImageEditorTextViewControllerDelegate {
public func textEditDidComplete(textItem: ImageEditorTextItem) {
AssertIsOnMainThread()
// Model items are immutable; we _replace_ the item rather than modify it.
if model.has(itemForId: textItem.itemId) {
model.replace(item: textItem, suppressUndo: false)
} else {
model.append(item: textItem)
}
self.currentColor = textItem.color
}
public func textEditDidDelete(textItem: ImageEditorTextItem) {
AssertIsOnMainThread()
if model.has(itemForId: textItem.itemId) {
model.remove(item: textItem)
}
}
public func textEditDidCancel() {
}
2019-02-06 22:00:22 +01:00
}
2019-02-06 22:00:22 +01:00
// MARK: -
2019-02-06 22:00:22 +01:00
extension ImageEditorView: ImageEditorCropViewControllerDelegate {
public func cropDidComplete(transform: ImageEditorTransform) {
// TODO: Ignore no-change updates.
model.replace(transform: transform)
}
2019-02-06 22:00:22 +01:00
public func cropDidCancel() {
// TODO:
}
2018-12-14 21:01:02 +01:00
}
2019-02-27 16:29:30 +01:00
// MARK: -
2019-02-28 19:13:20 +01:00
extension ImageEditorView: ImageEditorBrushViewControllerDelegate {
public func brushDidComplete(currentColor: ImageEditorColor) {
self.currentColor = currentColor
2019-02-28 19:13:20 +01:00
}
}