TM-SGNL-iOS/SignalUI/ImageEditor/ImageEditorView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

566 lines
22 KiB
Swift

//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
protocol ImageEditorViewDelegate: AnyObject {
func imageEditorView(_ imageEditorView: ImageEditorView, didRequestAddTextItem textItem: ImageEditorTextItem)
func imageEditorView(_ imageEditorView: ImageEditorView, didTapTextItem textItem: ImageEditorTextItem)
func imageEditorView(_ imageEditorView: ImageEditorView, didMoveTextItem textItem: ImageEditorTextItem)
func imageEditorViewDidUpdateSelection(_ imageEditorView: ImageEditorView)
func imageEditorDidRequestToolbarVisibilityUpdate(_ imageEditorView: ImageEditorView)
}
// MARK: -
// A view for editing outgoing image attachments.
// It can also be used to render the final output.
class ImageEditorView: UIView {
weak var delegate: ImageEditorViewDelegate?
let model: ImageEditorModel
let canvasView: ImageEditorCanvasView
private let trashViewSize: CGFloat = 42
private lazy var trashView: UIView = {
let backgroundView = UIView()
backgroundView.layoutMargins = .init(margin: 9)
let image = UIImage(named: "trash")
let imageView = UIImageView(image: image)
imageView.tintColor = .white
imageView.contentMode = .scaleAspectFill
imageView.isUserInteractionEnabled = false
backgroundView.layer.cornerRadius = trashViewSize / 2
backgroundView.backgroundColor = .ows_blackAlpha40
backgroundView.isUserInteractionEnabled = false
backgroundView.addSubview(imageView)
imageView.autoPinEdgesToSuperviewMargins()
return backgroundView
}()
private var isTrashShowing: Bool {
get {
trashView.alpha > 0
}
set {
trashView.alpha = newValue ? 1 : 0
}
}
private var isHoveringOverTrash = false {
didSet {
guard isHoveringOverTrash != oldValue else { return }
updateTrash(isHoveringOverTrash: isHoveringOverTrash)
}
}
init(model: ImageEditorModel, delegate: ImageEditorViewDelegate?) {
self.model = model
self.delegate = delegate
self.canvasView = ImageEditorCanvasView(model: model)
super.init(frame: .zero)
model.add(observer: self)
}
@available(*, unavailable, message: "use other init() instead.")
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Views
private lazy var moveTextGestureRecognizer: ImageEditorPanGestureRecognizer = {
let gestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleMoveTextGesture(_:)))
gestureRecognizer.maximumNumberOfTouches = 1
gestureRecognizer.referenceView = gestureReferenceView
gestureRecognizer.delegate = self
return gestureRecognizer
}()
private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:)))
private lazy var pinchGestureRecognizer: ImageEditorPinchGestureRecognizer = {
let gestureRecognizer = ImageEditorPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
gestureRecognizer.referenceView = gestureReferenceView
return gestureRecognizer
}()
func configureSubviews() {
canvasView.configureSubviews()
addSubview(canvasView)
canvasView.autoPinEdgesToSuperviewEdges()
canvasView.contentView.addSubview(trashView)
// Center trash view instead of aligning the bottom so that it
// resizes from the center when hovering over it.
// 20 spacing to bottom + half the height for the center point.
let distanceFromCenterToBottom = 20 + trashViewSize / 2
trashView.centerYAnchor.constraint(
equalTo: canvasView.contentView.bottomAnchor,
constant: -distanceFromCenterToBottom
)
.isActive = true
trashView.autoHCenterInSuperview()
trashView.autoSetDimensions(to: .square(trashViewSize))
trashView.layer.zPosition = ImageEditorCanvasView.trashLazerZ
isTrashShowing = false
addGestureRecognizer(moveTextGestureRecognizer)
addGestureRecognizer(tapGestureRecognizer)
addGestureRecognizer(pinchGestureRecognizer)
updateGestureRecognizers()
let doubleTapGesture = UITapGestureRecognizer(target: nil, action: nil)
doubleTapGesture.numberOfTapsRequired = 2
addGestureRecognizer(doubleTapGesture)
tapGestureRecognizer.require(toFail: doubleTapGesture)
}
private func updateGestureRecognizers() {
// Remove all gesture recognizers when interaction with text objects is disabled
// so that they don't interfere with gesture recognizers added in view controller.
moveTextGestureRecognizer.isEnabled = textInteractionModes.contains(.move)
tapGestureRecognizer.isEnabled = textInteractionModes.contains(.tap)
pinchGestureRecognizer.isEnabled = textInteractionModes.contains(.resize)
}
final var gestureReferenceView: UIView {
canvasView.gestureReferenceView
}
// MARK: - Navigation Bar
private func updateControls() {
delegate?.imageEditorDidRequestToolbarVisibilityUpdate(self)
let shouldShowTrash: Bool
switch movingItem {
case is ImageEditorStickerItem, is ImageEditorTextItem:
shouldShowTrash = true
default:
shouldShowTrash = false
}
guard shouldShowTrash != isTrashShowing else { return }
UIView.animate(withDuration: 0.15) {
self.isTrashShowing = shouldShowTrash
}
}
private func updateTrash(isHoveringOverTrash: Bool) {
canvasView.shouldFadeTransformableItem = isHoveringOverTrash
UIView.animate(withDuration: 0.15) {
self.trashView.transform = isHoveringOverTrash ? .scale(4/3) : .identity
}
if isHoveringOverTrash {
ImpactHapticFeedback.impactOccurred(style: .light)
}
}
var shouldHideControls: Bool {
// Hide controls during "text item move".
return movingItem != nil
}
struct TextInteractionModes: OptionSet {
let rawValue: Int
static let tap = TextInteractionModes(rawValue: 1 << 0)
static let select = TextInteractionModes(rawValue: 1 << 1 | 1 << 0) // "select" requires "tap" to be supported
static let move = TextInteractionModes(rawValue: 1 << 2)
static let resize = TextInteractionModes(rawValue: 1 << 3)
static let all: TextInteractionModes = [ .tap, .select, .move, .resize ]
}
var textInteractionModes: TextInteractionModes = [] {
didSet {
updateGestureRecognizers()
}
}
// MARK: - Tap Gesture
var selectedTransformableItemID: String? {
get {
canvasView.selectedTransformableItemID
}
set {
let newValueIsDifferent = canvasView.selectedTransformableItemID != newValue
canvasView.selectedTransformableItemID = newValue
// Update the tooltip when a new item is selected.
// Dragging a sticker hides the tooltip, so avoid
// showing it if it was selected by a drag.
if newValueIsDifferent && movingItem == nil {
canvasView.updateTooltip()
}
}
}
func updateSelectedTextItem(withColor color: ColorPickerBarColor) {
if let selectedTextItemId = selectedTransformableItemID,
let textItem = model.item(forId: selectedTextItemId) as? ImageEditorTextItem {
let newTextItem = textItem.copy(color: color)
model.replace(item: newTextItem)
}
}
func createNewTextItem(withColor color: ColorPickerBarColor = ColorPickerBarColor.white,
textStyle: MediaTextView.TextStyle = .regular,
decorationStyle: MediaTextView.DecorationStyle = .none) -> ImageEditorTextItem {
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
// 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
let textItem = ImageEditorTextItem.empty(withColor: color,
textStyle: textStyle,
decorationStyle: decorationStyle,
unitWidth: textWidthUnit,
fontReferenceImageWidth: imageFrame.size.width,
scaling: scaling,
rotationRadians: rotationRadians)
return textItem
}
func createNewStickerItem(with sticker: EditorSticker) -> ImageEditorStickerItem {
let viewSize = canvasView.gestureReferenceView.bounds.size
let imageSize = model.srcImageSizePixels
let imageFrame = ImageEditorCanvasView.imageFrame(
forViewSize: viewSize,
imageSize: imageSize,
transform: model.currentTransform()
)
let rotationRadians = -model.currentTransform().rotationRadians
let scaling = 1 / model.currentTransform().scaling
return ImageEditorStickerItem(
sticker: sticker,
referenceImageWidth: imageFrame.size.width,
rotationRadians: rotationRadians,
scaling: scaling
)
}
@objc
private func handleTapGesture(_ gestureRecognizer: UIGestureRecognizer) {
AssertIsOnMainThread()
guard gestureRecognizer.state == .recognized else {
owsFailDebug("Unexpected state.")
return
}
guard textInteractionModes.contains(.tap) else {
owsFailDebug("Unexpected text interaction mode [\(textInteractionModes)].")
return
}
let location = gestureRecognizer.location(in: canvasView.gestureReferenceView)
guard let textLayer = transformableLayer(forLocation: location) else {
// Different behavior when user taps on an empty area.
// 1. Text objects are selectable: deselect anything previously selected.
if textInteractionModes.contains(.select) {
if selectedTransformableItemID != nil {
selectedTransformableItemID = nil
delegate?.imageEditorViewDidUpdateSelection(self)
}
return
}
// 2. Text objects aren't selectable: add a new text object.
let newTextItem = createNewTextItem()
delegate?.imageEditorView(self, didRequestAddTextItem: newTextItem)
return
}
guard let itemID = textLayer.name,
let item = model.item(forId: itemID) as? ImageEditorTransformable else {
owsFailDebug("Missing or invalid text item.")
return
}
// Text objects are selectable: select object if not selected yet...
if textInteractionModes.contains(.select) && item.itemId != selectedTransformableItemID {
selectedTransformableItemID = item.itemId
delegate?.imageEditorViewDidUpdateSelection(self)
}
// ..otherwise report tap to delegate (this includes taps on selected text objects).
else if let textItem = item as? ImageEditorTextItem {
delegate?.imageEditorView(self, didTapTextItem: textItem)
}
// Change special sticker style
else if
let stickerItem = item as? ImageEditorStickerItem,
case .story(let storySticker) = stickerItem.sticker
{
switch storySticker {
case .clockDigital(let clockStyle):
let newSticker = clockStyle.stickerWithNextStyle()
let newStickerItem = stickerItem.copy(sticker: newSticker)
model.replace(item: newStickerItem)
case .clockAnalog(let clockStyle):
let newSticker = clockStyle.stickerWithNextStyle()
let newStickerItem = stickerItem.copy(sticker: newSticker)
model.replace(item: newStickerItem)
}
ImpactHapticFeedback.impactOccurred(style: .medium)
canvasView.hideTooltip()
}
}
// MARK: - Pinch Gesture
// These properties are valid while moving a text item.
private var pinchingItem: (any ImageEditorTransformable)?
private var pinchHasChanged = false
@objc
private 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 = transformableLayer(forLocation: pinchState.centroid),
let itemID = textLayer.name,
itemID == selectedTransformableItemID else {
// The pinch needs to start centered on selected text item.
return
}
guard let item = model.item(forId: itemID) as? ImageEditorTransformable else {
owsFailDebug("Missing or invalid text item.")
return
}
pinchingItem = item
pinchHasChanged = false
case .changed, .ended:
guard let item = pinchingItem else {
return
}
let view = canvasView.gestureReferenceView
let viewBounds = view.bounds
let locationStart = gestureRecognizer.pinchStateStart.centroid
let locationNow = gestureRecognizer.pinchStateLast.centroid
let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart,
viewBounds: viewBounds,
model: model,
transform: model.currentTransform())
let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationNow,
viewBounds: viewBounds,
model: model,
transform: model.currentTransform())
let gestureDeltaImageUnit = gestureNowImageUnit.minus(gestureStartImageUnit)
let unitCenter = CGPoint.clamp01(item.unitCenter.plus(gestureDeltaImageUnit))
// NOTE: We use max(1, ...) to avoid divide-by-zero.
let newScaling = CGFloat.clamp(item.scaling * gestureRecognizer.pinchStateLast.distance / max(1.0, gestureRecognizer.pinchStateStart.distance),
min: ImageEditorTextItem.kMinScaling,
max: ImageEditorTextItem.kMaxScaling)
let newRotationRadians = item.rotationRadians + gestureRecognizer.pinchStateLast.angleRadians - gestureRecognizer.pinchStateStart.angleRadians
let newItem = item.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 {
pinchingItem = nil
}
default:
pinchingItem = nil
}
}
// MARK: - Pan Gesture
// These properties are valid while moving a text item.
private var movingItem: (any ImageEditorTransformable)? {
didSet {
updateControls()
}
}
private var movingTextStartUnitCenter: CGPoint?
private var movingTextHasMoved = false
private func transformableLayer(forLocation locationInView: CGPoint) -> CALayer? {
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.transformableLayer(forLocation: locationInCanvas)
}
@objc
private func handleMoveTextGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) {
AssertIsOnMainThread()
guard textInteractionModes.contains(.move) else {
owsFailDebug("Unexpected text interaction mode [\(textInteractionModes)].")
return
}
// We could undo an in-progress move if the gesture is cancelled, but it seems gratuitous.
switch gestureRecognizer.state {
case .began:
guard let locationStart = gestureRecognizer.locationFirst else {
owsFailDebug("Missing locationStart.")
return
}
guard let textLayer = transformableLayer(forLocation: locationStart) else {
owsFailDebug("No text layer")
return
}
guard let itemID = textLayer.name,
let item = model.item(forId: itemID) as? ImageEditorTransformable else {
owsFailDebug("Missing or invalid text item.")
return
}
movingItem = item
movingTextStartUnitCenter = item.unitCenter
movingTextHasMoved = false
canvasView.hideTooltip()
// Automatically make item selected if selections are allowed.
if textInteractionModes.contains(.select) {
selectedTransformableItemID = item.itemId
}
case .changed, .ended:
guard let item = movingItem else {
return
}
guard let locationStart = gestureRecognizer.locationFirst else {
owsFailDebug("Missing locationStart.")
return
}
guard let movingTextStartUnitCenter = movingTextStartUnitCenter else {
owsFailDebug("Missing movingTextStartUnitCenter.")
return
}
let view = canvasView.gestureReferenceView
let viewBounds = view.bounds
let locationInView = gestureRecognizer.location(in: view)
let gestureStartImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationStart,
viewBounds: viewBounds,
model: model,
transform: model.currentTransform())
let gestureNowImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView,
viewBounds: viewBounds,
model: model,
transform: model.currentTransform())
let gestureDeltaImageUnit = gestureNowImageUnit.minus(gestureStartImageUnit)
let unitCenter = CGPoint.clamp01(movingTextStartUnitCenter.plus(gestureDeltaImageUnit))
let newItem = item.copy(unitCenter: unitCenter)
if movingTextHasMoved {
model.replace(item: newItem, suppressUndo: true)
} else {
model.replace(item: newItem, suppressUndo: false)
movingTextHasMoved = true
}
isHoveringOverTrash = trashView.containsGestureLocation(gestureRecognizer)
if gestureRecognizer.state == .ended {
// Report that text object was moved.
if let movingTextItem = movingItem as? ImageEditorTextItem {
delegate?.imageEditorView(self, didMoveTextItem: movingTextItem)
}
if isHoveringOverTrash, isTrashShowing {
// The last operation was moving the image over the trash.
// Pop that off the stack, so when the user presses undo
// after trashing an item, it goes to the position before
// the trash, instead of appearing over the trash.
model.undo()
model.remove(item: newItem)
}
movingItem = nil
isHoveringOverTrash = false
}
default:
movingItem = nil
}
}
}
// MARK: - Corner Radius
extension ImageEditorView {
static let defaultCornerRadius: CGFloat = 18
func setHasRoundCorners(_ roundCorners: Bool, animationDuration: TimeInterval = 0) {
canvasView.setCornerRadius(roundCorners ? ImageEditorView.defaultCornerRadius : 0,
animationDuration: animationDuration)
}
}
// MARK: -
extension ImageEditorView: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard moveTextGestureRecognizer == gestureRecognizer else {
owsFailDebug("Unexpected gesture.")
return false
}
let location = touch.location(in: canvasView.gestureReferenceView)
let isInTextArea = self.transformableLayer(forLocation: location) != nil
return isInTextArea
}
}
// MARK: -
extension ImageEditorView: ImageEditorModelObserver {
func imageEditorModelDidChange(before: ImageEditorContents, after: ImageEditorContents) {
}
func imageEditorModelDidChange(changedItemIds: [String]) {
}
}