Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/ImageEditor/ImageEditorCropViewController.swift
1 views
//
// 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() {
        super.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)
    }

    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 {
            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 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 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 {
                // 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 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.nonPluralLocalizedStringWithFormat(AspectRatio.aspectRatioXByYFormatString(), OWSFormat.formatInt(4), OWSFormat.formatInt(3))
            case .threeByFour:
                return String.nonPluralLocalizedStringWithFormat(AspectRatio.aspectRatioXByYFormatString(), OWSFormat.formatInt(3), OWSFormat.formatInt(4))
            case .sixteenByNine:
                return String.nonPluralLocalizedStringWithFormat(AspectRatio.aspectRatioXByYFormatString(), OWSFormat.formatInt(16), OWSFormat.formatInt(9))
            case .nineBySixteen:
                return String.nonPluralLocalizedStringWithFormat(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()
        actionSheet.overrideUserInterfaceStyle = .dark
        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 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)
    }
}