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

1058 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)
}
}