Signal-iOS/SignalUI/ImageEditor/ImageEditorCropViewController.swift
Adam Sharp b42710cf90 Work around use of deprecated UIButton API
The old UIButton API is still functional as long as we don't use
UIButton.Configuration, so we can safely ignore these warnings until we're
ready to adopt the configuration API across the codebase.
2024-07-03 14:27:48 -04:00

1059 lines
46 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import UIKit
private extension CGFloat {
var degreesToRadians: CGFloat {
return self / 180 * .pi
}
var radiansToDegrees: CGFloat {
return self * 180 / .pi
}
}
// MARK: -
// A view for editing text item in image editor.
class ImageEditorCropViewController: OWSViewController {
private let model: ImageEditorModel
private let srcImage: UIImage
private let previewImage: UIImage
private var transform: ImageEditorTransform
// Transparent view whose frame reflects the current state of cropping.
// Size of `clipView` is defined by both transform (defines aspect ratio) and
// layout guide that `clipView` is currently constrained to (defines position and max size).
// `clipView` also serves as the reference view for gesture recognizers.
private let clipView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.clipsToBounds = true
view.isOpaque = false
return view
}()
// This constraint reflects current aspec ratio of the clip rectangle.
// This constraint gets updated using values from `transform` whenever user makes changes.
private var clipViewAspectRatioConstraint: NSLayoutConstraint?
private lazy var imageView = UIImageView(image: previewImage)
// The purpose of these two layout guides is to make animation of transition to/from crop view seamless.
// Seamlessness is achieved when image center stays the same in both "review" and "crop" screens.
// Two layout guides define size and position of the visible content:
// `initialStateContentLayoutGuide` designed to position content exacty as in `AttachmentPrepContentView`.
// `finalStateContentLayoutGuide` has the same center that `initialStateContentLayoutGuide` has,
// but with non-zero margins on the sides and its height sized to clear rotation control at the bottom.
// When VC's view appears on the screen initially (with no animation) content is constrained to `initialStateContentLayoutGuide`.
// Once view is visible content is resized with animation to match `finalStateContentLayoutGuide`.
private let initialStateContentLayoutGuide = UILayoutGuide()
private let finalStateContentLayoutGuide = UILayoutGuide()
// Constraints between `clipView` and one of the layout guides from above.
// These constraints are updated when UI is switched from `initial` to `final` and vice versa
// during present / dismiss animations.
private var contentLayoutGuideConstraints = [NSLayoutConstraint]()
// Full-screen view that serves purely as indication of current crop rectangle.
// This view displays crop handles and grid and also dims cropped content.
private let cropView = CropView(frame: UIScreen.main.bounds)
// These insets control position of the visible crop frame within `clipView` via a set of four layout constraints below.
// Insets are non-zero only temporarily:
// when user is resizing crop rectangle using crop handles.
// when animating change to a predefined aspect ratio.
private var cropViewFrameInsets = UIEdgeInsets.zero {
didSet {
cropViewFrameLeading.constant = cropViewFrameInsets.leading
cropViewFrameTop.constant = cropViewFrameInsets.top
cropViewFrameTrailing.constant = -cropViewFrameInsets.trailing
cropViewFrameBottom.constant = -cropViewFrameInsets.bottom
}
}
private lazy var cropViewFrameLeading = cropView.cropFrameLayoutGuide.leadingAnchor.constraint(equalTo: clipView.leadingAnchor,
constant: cropViewFrameInsets.leading)
private lazy var cropViewFrameTop = cropView.cropFrameLayoutGuide.topAnchor.constraint(equalTo: clipView.topAnchor,
constant: cropViewFrameInsets.top)
private lazy var cropViewFrameTrailing = cropView.cropFrameLayoutGuide.trailingAnchor.constraint(equalTo: clipView.trailingAnchor,
constant: -cropViewFrameInsets.trailing)
private lazy var cropViewFrameBottom = cropView.cropFrameLayoutGuide.bottomAnchor.constraint(equalTo: clipView.bottomAnchor,
constant: -cropViewFrameInsets.bottom)
// Controls.
private lazy var resetButton: UIButton = {
let button = RoundMediaButton(image: nil, backgroundStyle: .blur)
let buttonTitle = OWSLocalizedString("MEDIA_EDITOR_RESET", comment: "Title for the button that resets photo to its initial state.")
button.setTitle(buttonTitle, for: .normal)
button.ows_contentEdgeInsets = UIEdgeInsets(hMargin: 26, vMargin: 15) // Make button 36pts tall at default text size.
button.addTarget(self, action: #selector(didTapReset), for: .touchUpInside)
return button
}()
private lazy var footerView: UIView = {
let footerView = UIView()
footerView.preservesSuperviewLayoutMargins = true
if UIDevice.current.hasIPhoneXNotch {
// No additional bottom margin if there's non-zero safe area.
footerView.layoutMargins.bottom = 0
}
footerView.addSubview(rotationControl)
rotationControl.autoPinTopToSuperviewMargin()
rotationControl.autoHCenterInSuperview()
rotationControl.autoPinEdge(.leading, to: .leading, of: footerView, withOffset: 0, relation: .greaterThanOrEqual)
footerView.addSubview(bottomBar)
bottomBar.autoPinWidthToSuperview()
bottomBar.autoPinEdge(toSuperviewEdge: .bottom)
bottomBar.autoPinEdge(.top, to: .bottom, of: rotationControl, withOffset: 18)
return footerView
}()
private lazy var rotationControl = RotationControl()
private lazy var bottomBar: ImageEditorBottomBar = {
let bottomBar = ImageEditorBottomBar(buttonProvider: self)
bottomBar.cancelButton.addTarget(self, action: #selector(didTapCancel), for: .touchUpInside)
bottomBar.doneButton.addTarget(self, action: #selector(didTapDone), for: .touchUpInside)
return bottomBar
}()
init(model: ImageEditorModel, srcImage: UIImage, previewImage: UIImage) {
self.model = model
self.srcImage = srcImage
self.previewImage = previewImage
self.transform = model.currentTransform()
super.init()
}
// MARK: - UIViewController
override func viewDidLoad() {
view.backgroundColor = .black
// MARK: - Clip view & content.
view.addSubview(clipView)
updateClipViewAspectRatio()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.layer.masksToBounds = true
view.addSubview(imageView)
// Image view is always co-centered with the clip view,
// has aspect ratio of the image it displays and resized to fit current
// content layout guide's frame (just like the clip view).
// Everything user does to an image is applied as `UIView.transform` in `updateImageViewTransform`.
let imageAspectRatio = previewImage.size.width / previewImage.size.height
view.addConstraints([
imageView.centerXAnchor.constraint(equalTo: clipView.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: clipView.centerYAnchor),
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor, multiplier: imageAspectRatio)
])
// MARK: - Crop frame
view.addSubview(cropView)
cropView.autoPinEdgesToSuperviewEdges()
// Visible crop frame is constrained to clipView using auto layout.
view.addConstraints([ cropViewFrameLeading, cropViewFrameTop, cropViewFrameTrailing, cropViewFrameBottom ])
// MARK: - Footer
view.addSubview(footerView)
footerView.autoPinWidthToSuperview()
footerView.autoPinEdge(toSuperviewEdge: .bottom)
setupRotationControlActions()
// MARK: - Layout guides for clip view
initialStateContentLayoutGuide.identifier = "Content - Initial State"
view.addLayoutGuide(initialStateContentLayoutGuide)
let topConstraint: NSLayoutConstraint = {
if UIDevice.current.hasIPhoneXNotch || UIDevice.current.isIPad {
return initialStateContentLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
} else {
return initialStateContentLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor)
}
}()
view.addConstraints([
topConstraint,
initialStateContentLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
initialStateContentLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
initialStateContentLayoutGuide.bottomAnchor.constraint(equalTo: bottomBar.topAnchor) ])
finalStateContentLayoutGuide.identifier = "Content - Final State"
view.addLayoutGuide(finalStateContentLayoutGuide)
view.addConstraints([
finalStateContentLayoutGuide.centerYAnchor.constraint(equalTo: initialStateContentLayoutGuide.centerYAnchor),
finalStateContentLayoutGuide.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
finalStateContentLayoutGuide.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
finalStateContentLayoutGuide.bottomAnchor.constraint(equalTo: footerView.topAnchor) ])
// MARK: - Reset Button
resetButton.translatesAutoresizingMaskIntoConstraints = false
let mediaTopBar = MediaTopBar()
mediaTopBar.addSubview(resetButton)
mediaTopBar.addConstraints([ resetButton.topAnchor.constraint(equalTo: mediaTopBar.controlsLayoutGuide.topAnchor),
resetButton.trailingAnchor.constraint(equalTo: mediaTopBar.controlsLayoutGuide.trailingAnchor),
resetButton.bottomAnchor.constraint(equalTo: mediaTopBar.controlsLayoutGuide.bottomAnchor) ])
mediaTopBar.install(in: view)
updateResetButtonAppearance(animated: false)
transitionUI(toState: .initial, animated: false)
configureGestureRecognizers()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
transitionUI(toState: .final, animated: true)
}
public override var prefersStatusBarHidden: Bool {
!UIDevice.current.hasIPhoneXNotch && !UIDevice.current.isIPad && !DependenciesBridge.shared.currentCallProvider.hasCurrentCall
}
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
// MARK: - Layout
private func updateResetButtonAppearance(animated: Bool) {
if transform.isNonDefault {
resetButton.setIsHidden(false, animated: animated)
return
}
// Transform might still report as `default` after cropping using pre-selected choices.
let imageAspectRatio = srcImage.pixelSize.width / srcImage.pixelSize.height
let cropRectAspectRation = transform.outputSizePixels.width / transform.outputSizePixels.height
let hasChanges = abs(imageAspectRatio - cropRectAspectRation) > 0.005
resetButton.setIsHidden(!hasChanges, animated: animated)
}
private func constrainContent(to layoutGuide: UILayoutGuide) {
view.removeConstraints(contentLayoutGuideConstraints)
var constraints = [NSLayoutConstraint]()
// Center in the layout guide's frame.
constraints.append(clipView.centerXAnchor.constraint(equalTo: layoutGuide.centerXAnchor))
constraints.append(clipView.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor))
// Constrain width and height to be within layout guide's frame.
constraints.append(clipView.widthAnchor.constraint(lessThanOrEqualTo: layoutGuide.widthAnchor))
constraints.append(clipView.heightAnchor.constraint(lessThanOrEqualTo: layoutGuide.heightAnchor))
// Constrain width and height to take as much space as possible.
constraints.append(contentsOf: { () -> [NSLayoutConstraint] in
let c1 = clipView.widthAnchor.constraint(equalTo: layoutGuide.widthAnchor)
c1.priority = .defaultHigh
let c2 = clipView.heightAnchor.constraint(equalTo: layoutGuide.heightAnchor)
c2.priority = .defaultHigh
return [ c1, c2 ]
}())
// Constrain image view to fit the current layout guide's frame.
// Note that imageView isn't constrained to clipView (except for the center)
// so that model's transform can easily be applied to imageView.
constraints.append(imageView.widthAnchor.constraint(lessThanOrEqualTo: layoutGuide.widthAnchor))
constraints.append(imageView.heightAnchor.constraint(lessThanOrEqualTo: layoutGuide.heightAnchor))
view.addConstraints(constraints)
contentLayoutGuideConstraints = constraints
}
private func updateClipViewAspectRatio() {
// The only thing about clipView that changes as user performs crop/rotate operations
// is clipView's aspect ratio, which is defined by the current transform.
//
// Constraint needs to be re-created because NSLayoutConstraint.multiplier is read-only.
if let clipViewAspectRatioConstraint = clipViewAspectRatioConstraint {
view.removeConstraint(clipViewAspectRatioConstraint)
}
let aspectRatio = transform.outputSizePixels
let constraint = clipView.widthAnchor.constraint(equalTo: clipView.heightAnchor, multiplier: aspectRatio.width / aspectRatio.height)
view.addConstraint(constraint)
clipViewAspectRatioConstraint = constraint
}
private func applyTransformWithoutAnimation(_ transform: ImageEditorTransform) {
self.transform = transform
if !rotationControl.isTracking {
rotationControl.angle = transform.rotationRadians.radiansToDegrees
}
UIView.performWithoutAnimation {
updateClipViewAspectRatio()
resetCropFrameInsets()
updateImageViewTransform()
}
}
private func applyTransformWithAnimation(_ transform: ImageEditorTransform, completion: ((Bool) -> Void)? = nil) {
self.transform = transform
if !rotationControl.isTracking {
rotationControl.angle = transform.rotationRadians.radiansToDegrees
}
UIView.animate(withDuration: 0.25,
animations: {
self.updateClipViewAspectRatio()
self.resetCropFrameInsets()
self.updateImageViewTransform()
self.updateResetButtonAppearance(animated: false)
}, completion: completion)
}
private func applyTransformHidingCropFrame(_ transform: ImageEditorTransform) {
cropView.setIsHidden(true, animated: true) { _ in
self.applyTransformWithAnimation(transform) { _ in
self.updateResetButtonAppearance(animated: true)
self.cropView.setIsHidden(false, animated: true)
}
}
}
private func updateImageViewTransform() {
// Force all pendging layouts to be done now because we're grabbing the size of `clipView`.
view.layoutIfNeeded()
let viewSize = clipView.bounds.size
let imageSize = imageView.bounds.size
guard viewSize.width > 0 && viewSize.height > 0 else { return }
guard imageSize.width > 0 && imageSize.height > 0 else { return }
// Re-use this method that calculates bounding box rect for image with transform applied to it.
// We only need size of the result returned by this method.
let transformedFrame = ImageEditorCanvasView.imageFrame(forViewSize: viewSize, imageSize: imageSize, transform: transform)
// Apply additional scaling to the image so that there's no empty areas when rotation is non-zero.
var scaleX = transformedFrame.width / imageSize.width
// Flip if necessary.
if transform.isFlipped {
scaleX *= -1
}
let scaleY = transformedFrame.height / imageSize.height
let imageTransform = transform.affineTransform(viewSize: viewSize)
imageView.transform = imageTransform.scaledBy(x: scaleX, y: scaleY)
}
// MARK: - Crop Frame
private func setCropFrameInsets(fromClipViewRect rect: CGRect) {
var insets = UIEdgeInsets.zero
insets.left = rect.minX
insets.top = rect.minY
insets.right = clipView.bounds.maxX - rect.maxX
insets.bottom = clipView.bounds.maxY - rect.maxY
cropViewFrameInsets = insets
}
private func resetCropFrameInsets() {
cropViewFrameInsets = .zero
}
private var setGridHiddenTimer: Timer?
private func setCropFrameGridLines(hidden: Bool, animated: Bool, completion: ((Bool) -> Void)? = nil) {
if let timer = setGridHiddenTimer {
timer.invalidate()
setGridHiddenTimer = nil
}
cropView.setState(hidden ? .normal : .resizing, animated: animated, completion: completion)
}
private func setCropFrameGridLines(hidden: Bool, animated: Bool, afterDelay delay: TimeInterval) {
guard delay > 0 else {
setCropFrameGridLines(hidden: hidden, animated: animated)
return
}
if let timer = setGridHiddenTimer {
timer.invalidate()
setGridHiddenTimer = nil
}
let timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
guard let self = self else { return }
self.setCropFrameGridLines(hidden: hidden, animated: animated)
}
setGridHiddenTimer = timer
}
// MARK: - Present/dismiss animations
private enum UIState {
case initial
case final
}
private func transitionUI(toState state: UIState, animated: Bool, completion: ((Bool) -> Void)? = nil) {
let layoutGuide: UILayoutGuide = {
switch state {
case .initial: return initialStateContentLayoutGuide
case .final: return finalStateContentLayoutGuide
}
}()
let hideControls = state == .initial
let setControlsHiddenBlock = {
let alpha: CGFloat = hideControls ? 0 : 1
self.footerView.alpha = alpha
self.cropView.setState(state == .initial ? .initial : .normal, animated: false)
self.bottomBar.setControls(hidden: hideControls)
}
let animationDuration: TimeInterval = 0.15
let imageCornerRadius: CGFloat = state == .initial ? ImageEditorView.defaultCornerRadius : 0
if animated {
let animation = CABasicAnimation(keyPath: #keyPath(CALayer.cornerRadius))
animation.fromValue = imageView.layer.cornerRadius
animation.toValue = imageCornerRadius
animation.duration = animationDuration
imageView.layer.add(animation, forKey: "cornerRadius")
}
imageView.layer.cornerRadius = imageCornerRadius
if animated {
UIView.animate(withDuration: animationDuration,
animations: {
setControlsHiddenBlock()
self.constrainContent(to: layoutGuide)
self.updateImageViewTransform()
// Animate layout changes made within bottomBar.setControls(hidden:).
self.view.setNeedsDisplay()
self.view.layoutIfNeeded()
},
completion: completion)
} else {
setControlsHiddenBlock()
constrainContent(to: layoutGuide)
updateImageViewTransform()
completion?(true)
}
}
// MARK: - Gestures
private func configureGestureRecognizers() {
let pinchGestureRecognizer = ImageEditorPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
pinchGestureRecognizer.referenceView = clipView
// Use this VC as a delegate to ensure that pinches only
// receive touches that start inside of the cropped image bounds.
pinchGestureRecognizer.delegate = self
view.addGestureRecognizer(pinchGestureRecognizer)
let panGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
panGestureRecognizer.maximumNumberOfTouches = 1
panGestureRecognizer.referenceView = clipView
// _DO NOT_ use this VC as a delegate to filter touches;
// pan gestures can start outside the cropped image bounds.
// Otherwise the edges of the crop rect are difficult to
// "grab".
view.addGestureRecognizer(panGestureRecognizer)
// De-conflict the gestures; the pan gesture has priority.
panGestureRecognizer.shouldBeRequiredToFail(by: pinchGestureRecognizer)
}
private class func unitTranslation(oldLocationView: CGPoint,
newLocationView: CGPoint,
viewBounds: CGRect,
oldTransform: ImageEditorTransform) -> CGPoint {
// The beauty of using an SRT (scale-rotate-translation) transform ordering
// is that the translation is applied last, so it's trivial to convert
// translations from view coordinates to transform translation.
// Our (view bounds == canvas bounds) so no need to convert.
let translation = newLocationView.minus(oldLocationView)
let translationUnit = translation.toUnitCoordinates(viewSize: viewBounds.size, shouldClamp: false)
let newUnitTranslation = oldTransform.unitTranslation.plus(translationUnit)
return newUnitTranslation
}
// MARK: - Pinch Gesture
@objc
private func handlePinchGesture(_ gestureRecognizer: ImageEditorPinchGestureRecognizer) {
AssertIsOnMainThread()
switch gestureRecognizer.state {
case .began:
gestureStartTransform = transform
case .changed, .ended:
guard let gestureStartTransform = gestureStartTransform else {
owsFailDebug("Missing pinchTransform.")
return
}
let unitTranslation =
ImageEditorCropViewController.unitTranslation(oldLocationView: gestureRecognizer.pinchStateStart.centroid,
newLocationView: gestureRecognizer.pinchStateLast.centroid,
viewBounds: clipView.bounds,
oldTransform: gestureStartTransform)
// NOTE: We use max(1, ...) to avoid divide-by-zero.
let scaling = gestureStartTransform.scaling * gestureRecognizer.pinchStateLast.distance / max(1.0, gestureRecognizer.pinchStateStart.distance)
let clampedScaling = scaling.clamp(ImageEditorTextItem.kMinScaling, ImageEditorTextItem.kMaxScaling)
let newTransform = ImageEditorTransform(outputSizePixels: gestureStartTransform.outputSizePixels,
unitTranslation: unitTranslation,
rotationRadians: gestureStartTransform.rotationRadians,
scaling: clampedScaling,
isFlipped: gestureStartTransform.isFlipped)
applyTransformWithoutAnimation(newTransform.normalize(srcImageSizePixels: model.srcImageSizePixels))
updateResetButtonAppearance(animated: true)
default:
break
}
// Show grid lines immediately when gesture starts and hide with a small delay after gesture ends.
switch gestureRecognizer.state {
case .began:
setCropFrameGridLines(hidden: false, animated: true)
case .ended, .cancelled:
setCropFrameGridLines(hidden: true, animated: true, afterDelay: 0.5)
default:
break
}
}
// MARK: - Pan Gesture
private var gestureStartTransform: ImageEditorTransform?
private var panCropRegion: CropRegion?
private var isCropGestureActive: Bool {
return panCropRegion != nil
}
@objc
private func handlePanGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) {
AssertIsOnMainThread()
// Ignore gestures that begin inside of the controls area at the bottom.
// Upon cancellation gesture recognizer will send one last event with the state==.cancelled - should be ignored too.
if footerView.point(inside: gestureRecognizer.location(in: footerView), with: nil) {
switch gestureRecognizer.state {
case .began:
gestureRecognizer.isEnabled = false
gestureRecognizer.isEnabled = true
return
case .cancelled:
return
default:
break
}
}
// We could undo an in-progress pinch if the gesture is cancelled, but it seems gratuitous.
// Handle the GR if necessary.
switch gestureRecognizer.state {
case .began:
gestureStartTransform = transform
// Pans that start near the crop rectangle should be treated as crop gestures.
panCropRegion = cropRegion(forGestureRecognizer: gestureRecognizer)
case .changed, .ended:
if let panCropRegion = panCropRegion {
// Crop pan gesture
handleCropPanGesture(gestureRecognizer, panCropRegion: panCropRegion)
} else {
handleNormalPanGesture(gestureRecognizer)
}
default:
break
}
// Reset the GR if necessary.
switch gestureRecognizer.state {
case .ended, .failed, .cancelled, .possible:
panCropRegion = nil
default:
break
}
// Show grid lines immediately when gesture starts and hide with a small delay after gesture ends.
switch gestureRecognizer.state {
case .began:
setCropFrameGridLines(hidden: false, animated: true)
case .ended, .cancelled:
setCropFrameGridLines(hidden: true, animated: true, afterDelay: 0.5)
default:
break
}
}
private func handleCropPanGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer,
panCropRegion: CropRegion) {
AssertIsOnMainThread()
guard let locationStart = gestureRecognizer.locationFirst else {
owsFailDebug("Missing locationStart.")
return
}
let locationNow = gestureRecognizer.location(in: clipView)
// Crop pan gesture
let locationDelta = CGPoint.subtract(locationNow, locationStart)
let cropRectangleStart = clipView.bounds
var cropRectangleNow = cropRectangleStart
// Derive the new crop rectangle.
// We limit the crop rectangle's minimum size for two reasons.
//
// * To ensure that the crop rectangles "corner handles"
// can always be safely drawn.
// * To avoid awkward interactions when the crop rectangle
// is very small. Users can always crop multiple times.
let maxDeltaX = cropRectangleNow.size.width - cropView.cornerSize.width * 2
let maxDeltaY = cropRectangleNow.size.height - cropView.cornerSize.height * 2
switch panCropRegion {
case .left, .topLeft, .bottomLeft:
let delta = min(maxDeltaX, max(0, locationDelta.x))
cropRectangleNow.origin.x += delta
cropRectangleNow.size.width -= delta
case .right, .topRight, .bottomRight:
let delta = min(maxDeltaX, max(0, -locationDelta.x))
cropRectangleNow.size.width -= delta
default:
break
}
switch panCropRegion {
case .top, .topLeft, .topRight:
let delta = min(maxDeltaY, max(0, locationDelta.y))
cropRectangleNow.origin.y += delta
cropRectangleNow.size.height -= delta
case .bottom, .bottomLeft, .bottomRight:
let delta = min(maxDeltaY, max(0, -locationDelta.y))
cropRectangleNow.size.height -= delta
default:
break
}
setCropFrameInsets(fromClipViewRect: cropRectangleNow)
switch gestureRecognizer.state {
case .ended:
crop(toRect: cropRectangleNow, animated: true)
default:
break
}
}
private func crop(toRect cropRect: CGRect, animated: Bool) {
let viewBounds = clipView.bounds
// TODO: The output size should be rounded, although this can cause crop to be slightly not WYSIWYG.
let croppedOutputSizePixels = CGSize.round(CGSize(width: transform.outputSizePixels.width * cropRect.width / viewBounds.width,
height: transform.outputSizePixels.height * cropRect.height / viewBounds.height))
// We need to update the transform's unitTranslation and scaling properties
// to reflect the crop.
//
// Cropping involves changing the output size AND aspect ratio. The output aspect ratio
// has complicated effects on the rendering behavior of the image background, since the
// default rendering size of the image is an "aspect fill" of the output bounds.
// Therefore, the simplest and more reliable way to update the scaling is to measure
// the difference between the "before crop"/"after crop" image frames and adjust the
// scaling accordingly.
let naiveTransform = ImageEditorTransform(outputSizePixels: croppedOutputSizePixels,
unitTranslation: transform.unitTranslation,
rotationRadians: transform.rotationRadians,
scaling: transform.scaling,
isFlipped: transform.isFlipped)
let naiveImageFrameOld = ImageEditorCanvasView.imageFrame(forViewSize: transform.outputSizePixels, imageSize: model.srcImageSizePixels, transform: naiveTransform)
let naiveImageFrameNew = ImageEditorCanvasView.imageFrame(forViewSize: croppedOutputSizePixels, imageSize: model.srcImageSizePixels, transform: naiveTransform)
let scalingDeltaX = naiveImageFrameNew.width / naiveImageFrameOld.width
let scalingDeltaY = naiveImageFrameNew.height / naiveImageFrameOld.height
// scalingDeltaX and scalingDeltaY should only differ by rounding error.
let scalingDelta = (scalingDeltaX + scalingDeltaY) * 0.5
let scaling = transform.scaling / scalingDelta
// We also need to update the transform's translation, to ensure that the correct
// content (background image and items) ends up in the crop region.
//
// To do this, we use the center of the image content. Due to
// scaling and rotation of the image content, it's far simpler to
// use the center.
let oldAffineTransform = transform.affineTransform(viewSize: viewBounds.size)
// We determine the pre-crop render frame for the image.
let oldImageFrameCanvas = ImageEditorCanvasView.imageFrame(forViewSize: viewBounds.size, imageSize: model.srcImageSizePixels, transform: transform)
// We project it into pre-crop view coordinates (the coordinate
// system of the crop rectangle). Note that a CALayer's transform
// is applied using its "anchor point", the center of the layer.
// so we translate before and after the projection to be consistent.
let oldImageCenterView = oldImageFrameCanvas.center.minus(viewBounds.center).applying(oldAffineTransform).plus(viewBounds.center)
// We transform the "image content center" into the unit coordinates
// of the crop rectangle.
let newImageCenterUnit = oldImageCenterView.toUnitCoordinates(viewBounds: cropRect, shouldClamp: false)
// The transform's "unit translation" represents a deviation from
// the center of the output canvas, so we need to subtract the
// unit midpoint.
let unitTranslation = newImageCenterUnit.minus(CGPoint.unitMidpoint)
let newTransform = ImageEditorTransform(outputSizePixels: croppedOutputSizePixels,
unitTranslation: unitTranslation,
rotationRadians: transform.rotationRadians,
scaling: scaling,
isFlipped: transform.isFlipped)
applyTransformWithAnimation(newTransform.normalize(srcImageSizePixels: model.srcImageSizePixels))
}
private func handleNormalPanGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) {
AssertIsOnMainThread()
guard let gestureStartTransform = gestureStartTransform else {
owsFailDebug("Missing pinchTransform.")
return
}
guard let startLocation = gestureRecognizer.locationFirst else {
owsFailDebug("Missing locationStart.")
return
}
let currentLocation = gestureRecognizer.location(in: clipView)
let unitTranslation = ImageEditorCropViewController.unitTranslation(oldLocationView: startLocation,
newLocationView: currentLocation,
viewBounds: clipView.bounds,
oldTransform: gestureStartTransform)
let newTransform = ImageEditorTransform(outputSizePixels: gestureStartTransform.outputSizePixels,
unitTranslation: unitTranslation,
rotationRadians: gestureStartTransform.rotationRadians,
scaling: gestureStartTransform.scaling,
isFlipped: gestureStartTransform.isFlipped)
applyTransformWithoutAnimation(newTransform.normalize(srcImageSizePixels: model.srcImageSizePixels))
updateResetButtonAppearance(animated: true)
}
private func cropRegion(forGestureRecognizer gestureRecognizer: ImageEditorPanGestureRecognizer) -> CropRegion? {
guard let location = gestureRecognizer.locationFirst else {
owsFailDebug("Missing locationStart.")
return nil
}
let tolerance: CGFloat = CropView.desiredCornerSize * 2.0
let left = tolerance
let top = tolerance
let right = clipView.width - tolerance
let bottom = clipView.height - tolerance
// We could ignore touches far outside the crop rectangle.
if location.x < left {
if location.y < top {
return .topLeft
} else if location.y > bottom {
return .bottomLeft
} else {
return .left
}
} else if location.x > right {
if location.y < top {
return .topRight
} else if location.y > bottom {
return .bottomRight
} else {
return .right
}
} else {
if location.y < top {
return .top
} else if location.y > bottom {
return .bottom
} else {
return nil
}
}
}
}
// MARK: - ImageEditorBottomBarButtonProvider
extension ImageEditorCropViewController: ImageEditorBottomBarButtonProvider {
var middleButtons: [UIButton] {
let rotateButton = RoundMediaButton(
image: UIImage(imageLiteralResourceName: "rotate-28"),
backgroundStyle: .none
)
rotateButton.addTarget(self, action: #selector(didTapRotateImage), for: .touchUpInside)
let flipButton = RoundMediaButton(
image: UIImage(imageLiteralResourceName: "flip-28"),
backgroundStyle: .none
)
flipButton.addTarget(self, action: #selector(didTapFlipImage), for: .touchUpInside)
let aspectRatioButton = RoundMediaButton(
image: UIImage(imageLiteralResourceName: "ratio-28"),
backgroundStyle: .none
)
aspectRatioButton.addTarget(self, action: #selector(didTapChooseAspectRatio), for: .touchUpInside)
return [ rotateButton, flipButton, aspectRatioButton ]
}
}
// MARK: - Aspect Ratio
extension ImageEditorCropViewController {
enum AspectRatio: CaseIterable {
case original
case square
case fourByThree
case threeByFour
case sixteenByNine
case nineBySixteen
private static func aspectRatioXByYFormatString() -> String {
return OWSLocalizedString("ASPECT_RATIO_X_BY_Y", comment: "Variable aspect ratio, eg 3:4. %1$@ and %2$@ are numbers.")
}
func localizedTitle() -> String {
switch self {
case .original:
return OWSLocalizedString("ASPECT_RATIO_ORIGINAL", comment: "One of the choices for pre-defined aspect ratio of a photo in media editor.")
case .square:
return OWSLocalizedString("ASPECT_RATIO_SQUARE", comment: "One of the choices for pre-defined aspect ratio of a photo in media editor.")
case .fourByThree:
return String(format: AspectRatio.aspectRatioXByYFormatString(), OWSFormat.formatInt(4), OWSFormat.formatInt(3))
case .threeByFour:
return String(format: AspectRatio.aspectRatioXByYFormatString(), OWSFormat.formatInt(3), OWSFormat.formatInt(4))
case .sixteenByNine:
return String(format: AspectRatio.aspectRatioXByYFormatString(), OWSFormat.formatInt(16), OWSFormat.formatInt(9))
case .nineBySixteen:
return String(format: AspectRatio.aspectRatioXByYFormatString(), OWSFormat.formatInt(9), OWSFormat.formatInt(16))
}
}
}
private func isCurrentImageCompatibleWith(aspectRatio: AspectRatio) -> Bool {
let currentAspectRatio = transform.outputSizePixels
switch aspectRatio {
case .original, .square:
return true
case .fourByThree, .sixteenByNine:
return currentAspectRatio.width >= currentAspectRatio.height
case .threeByFour, .nineBySixteen:
return currentAspectRatio.height >= currentAspectRatio.width
}
}
private func cropTo(aspectRatio: AspectRatio) {
let imageSize = model.srcImageSizePixels
let imageAspectRatio = imageSize.width / imageSize.height
var currentCropRect = clipView.bounds
var currentAspectRatio = currentCropRect.width / currentCropRect.height
let aspectRatioEpsilon: CGFloat = 0.005
// If image is already cropped we need to extend "source" cropping rect
// to capture as much cropped content as possible.
if currentAspectRatio - imageAspectRatio > aspectRatioEpsilon {
// Image is cropped at top and bottom - extend source cropping frame vertically.
let heightDiff = currentCropRect.height - currentCropRect.width / imageAspectRatio
currentCropRect = currentCropRect.insetBy(dx: 0, dy: heightDiff/2)
} else if imageAspectRatio - currentAspectRatio > aspectRatioEpsilon {
// Image is cropped at left and right - extend source cropping frame horizontally.
let widthDiff = currentCropRect.width - currentCropRect.height * imageAspectRatio
currentCropRect = currentCropRect.insetBy(dx: widthDiff/2, dy: 0)
}
currentAspectRatio = currentCropRect.width / currentCropRect.height
// Now resize the "source" cropping rectangle, which might be larger than
// what actually is seen on the screen, to the new aspect ratio.
let newAspectRatio: CGFloat = {
switch aspectRatio {
case .original:
return imageAspectRatio
case .square:
return 1
case .fourByThree:
return 4/3
case .threeByFour:
return 3/4
case .sixteenByNine:
return 16/9
case .nineBySixteen:
return 9/16
}
}()
var newCropRect: CGRect
if newAspectRatio - currentAspectRatio > aspectRatioEpsilon {
let heightDiff = currentCropRect.height - currentCropRect.width / newAspectRatio
newCropRect = currentCropRect.insetBy(dx: 0, dy: heightDiff/2)
} else if currentAspectRatio - newAspectRatio > aspectRatioEpsilon {
let widthDiff = currentCropRect.width - currentCropRect.height * newAspectRatio
newCropRect = currentCropRect.insetBy(dx: widthDiff/2, dy: 0)
} else {
newCropRect = currentCropRect
}
// Resize crop frame first and then update everything else.
UIView.animate(withDuration: 0.15) {
self.setCropFrameInsets(fromClipViewRect: newCropRect)
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
} completion: { _ in
// Looks better if there's a very slight delay in between animations.
DispatchQueue.main.async {
self.crop(toRect: newCropRect, animated: true)
}
}
}
}
// MARK: - Events
extension ImageEditorCropViewController {
@objc
private func didTapCancel() {
transitionUI(toState: .initial, animated: true) { finished in
guard finished else { return }
self.dismiss(animated: false)
}
}
@objc
private func didTapDone() {
model.replace(transform: transform)
transitionUI(toState: .initial, animated: true) { finished in
guard finished else { return }
self.dismiss(animated: false)
}
}
@objc
private func didTapRotateImage() {
let outputSizePixels = CGSize(width: transform.outputSizePixels.height, height: transform.outputSizePixels.width)
let rotationRadians = transform.rotationRadians - CGFloat.pi / 2
let newTransform = ImageEditorTransform(outputSizePixels: outputSizePixels,
unitTranslation: transform.unitTranslation,
rotationRadians: rotationRadians,
scaling: transform.scaling,
isFlipped: transform.isFlipped)
applyTransformHidingCropFrame(newTransform.normalize(srcImageSizePixels: model.srcImageSizePixels))
}
@objc
private func didTapFlipImage() {
let newTransform = ImageEditorTransform(outputSizePixels: transform.outputSizePixels,
unitTranslation: transform.unitTranslation,
rotationRadians: transform.rotationRadians,
scaling: transform.scaling,
isFlipped: !transform.isFlipped)
applyTransformHidingCropFrame(newTransform.normalize(srcImageSizePixels: model.srcImageSizePixels))
}
@objc
private func didTapReset() {
let newTransform = ImageEditorTransform.defaultTransform(srcImageSizePixels: model.srcImageSizePixels)
applyTransformWithAnimation(newTransform)
}
@objc
private func didTapChooseAspectRatio() {
let actionSheet = ActionSheetController(theme: .translucentDark)
for aspectRatio in AspectRatio.allCases {
guard isCurrentImageCompatibleWith(aspectRatio: aspectRatio) else { continue }
actionSheet.addAction(
ActionSheetAction(title: aspectRatio.localizedTitle(),
style: .default,
handler: { [weak self] _ in
guard let self = self else { return }
self.cropTo(aspectRatio: aspectRatio)
}))
}
actionSheet.addAction(ActionSheetAction(title: CommonStrings.cancelButton, style: .cancel))
presentActionSheet(actionSheet)
}
}
// MARK: - Rotation Control
extension ImageEditorCropViewController {
private func setupRotationControlActions() {
rotationControl.addTarget(self, action: #selector(rotationControlValueChanged), for: .valueChanged)
rotationControl.addTarget(self, action: #selector(rotationControlDidBeginEditing), for: .editingDidBegin)
rotationControl.addTarget(self, action: #selector(rotationControlDidEndEditing), for: .editingDidEnd)
}
@objc
private func rotationControlValueChanged(_ sender: RotationControl) {
let newAngle = sender.angle.degreesToRadians
let newTransform = ImageEditorTransform(outputSizePixels: transform.outputSizePixels,
unitTranslation: transform.unitTranslation,
rotationRadians: newAngle,
scaling: transform.scaling,
isFlipped: transform.isFlipped)
applyTransformWithoutAnimation(newTransform.normalize(srcImageSizePixels: model.srcImageSizePixels))
updateResetButtonAppearance(animated: true)
}
@objc
private func rotationControlDidBeginEditing(_ sender: RotationControl) {
setCropFrameGridLines(hidden: false, animated: true)
}
@objc
private func rotationControlDidEndEditing(_ sender: RotationControl) {
setCropFrameGridLines(hidden: true, animated: true, afterDelay: 0.2)
}
}
// MARK: -
extension ImageEditorCropViewController: UIGestureRecognizerDelegate {
@objc
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
// Until the GR recognizes, it should only see touches that start within the content.
guard gestureRecognizer.state == .possible else {
return true
}
let location = touch.location(in: clipView)
return clipView.bounds.contains(location)
}
}