Signal-iOS/SignalUI/Views/ImageEditor/ImageEditorCropViewController.swift
Igor Solomennikov 2fd17eda12 Animate controls on the crop screens in and out.
This change also fixes a bug when bottom buttons in the crop screen
were not visible. That happened because ImageEditorBottomBar by default
had its controls hidden (with the intent that they are animated in once
view controller's view is visible). That was a poor design choice on my
end and is fixed in this commit.
2022-07-07 08:37:34 -07:00

971 lines
38 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import SignalCoreKit
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
let clipView = OWSLayerView()
let croppedContentView = OWSLayerView()
let uncroppedContentView = UIView()
private var imageLayer = CALayer()
private let cropView = CropView(frame: UIScreen.main.bounds)
private let rotationControl = RotationControl()
private lazy var bottomBar = ImageEditorBottomBar(buttonProvider: self)
// Holds both toolbar and rotation control.
private let footerView = UIView()
private var isGridHidden = true
private var setGridHiddenTimer: Timer?
init(model: ImageEditorModel, srcImage: UIImage, previewImage: UIImage) {
self.model = model
self.srcImage = srcImage
self.previewImage = previewImage
transform = model.currentTransform()
super.init()
}
// MARK: - View Lifecycle
private var resetButton: UIButton?
override func loadView() {
self.view = UIView()
self.view.backgroundColor = .black
self.view.layoutMargins = .zero
// MARK: - Buttons
let resetButtonTitle = OWSLocalizedString("MEDIA_EDITOR_RESET", comment: "Title for the button that resets photo to its initial state.")
let resetButton = RoundMediaButton(image: nil, backgroundStyle: .blur)
resetButton.setTitle(resetButtonTitle, for: .normal)
resetButton.contentEdgeInsets = UIEdgeInsets(hMargin: 18, vMargin: 7) // Make button 36pts tall at default text size.
resetButton.layoutMargins = .zero
resetButton.addTarget(self, action: #selector(didTapReset), for: .touchUpInside)
// MARK: - Canvas & Wrapper
let wrapperView = UIView.container()
wrapperView.isOpaque = false
clipView.clipsToBounds = true
clipView.isOpaque = false
clipView.layoutCallback = { [weak self] (_) in
guard let strongSelf = self else {
return
}
strongSelf.updateCropViewLayout()
}
wrapperView.addSubview(clipView)
clipView.setContentHuggingLow()
croppedContentView.layoutCallback = { [weak self] (_) in
guard let strongSelf = self else {
return
}
strongSelf.updateContent()
}
clipView.addSubview(croppedContentView)
croppedContentView.autoPinEdgesToSuperviewEdges()
imageLayer.contents = previewImage.cgImage
imageLayer.contentsScale = previewImage.scale
uncroppedContentView.isOpaque = false
uncroppedContentView.layer.addSublayer(imageLayer)
wrapperView.addSubview(uncroppedContentView)
uncroppedContentView.autoPin(toEdgesOf: croppedContentView)
wrapperView.setContentHuggingLow()
view.addSubview(wrapperView)
wrapperView.autoPinTopToSuperviewMargin()
wrapperView.autoPinWidthToSuperviewMargins()
// MARK: - Crop View
cropView.setContentHuggingLow()
cropView.setCompressionResistanceLow()
view.addSubview(cropView)
cropView.autoPinEdgesToSuperviewEdges()
// MARK: - Footer
footerView.addSubview(rotationControl)
rotationControl.autoPinTopToSuperviewMargin()
rotationControl.autoHCenterInSuperview()
rotationControl.autoPinEdge(.leading, to: .leading, of: footerView, withOffset: 0, relation: .greaterThanOrEqual)
bottomBar.cancelButton.addTarget(self, action: #selector(didTapCancel), for: .touchUpInside)
bottomBar.doneButton.addTarget(self, action: #selector(didTapDone), for: .touchUpInside)
footerView.addSubview(bottomBar)
bottomBar.autoPinWidthToSuperview()
bottomBar.autoPinEdge(toSuperviewEdge: .bottom)
bottomBar.autoPinEdge(.top, to: .bottom, of: rotationControl, withOffset: 18)
footerView.preservesSuperviewLayoutMargins = true
view.addSubview(footerView)
footerView.autoPinEdge(.top, to: .bottom, of: wrapperView)
footerView.autoPinWidthToSuperview()
footerView.autoPinEdge(toSuperviewEdge: .bottom)
// MARK: - Reset Button
view.addSubview(resetButton)
resetButton.autoPinTopToSuperviewMargin()
resetButton.autoPinTrailingToSuperviewMargin()
self.resetButton = resetButton
updateClipViewLayout()
configureGestures()
updateResetButtonAppearance()
setupRotationControlActions()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
setControls(hidden: true, animated: false)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
setControls(hidden: false, animated: true)
}
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
private func updateResetButtonAppearance() {
if transform.isNonDefault {
resetButton?.isHidden = false
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
resetButton?.isHidden = abs(imageAspectRatio - cropRectAspectRation) < 0.005
}
private var clipViewConstraints = [NSLayoutConstraint]()
private func updateClipViewLayout() {
NSLayoutConstraint.deactivate(clipViewConstraints)
clipViewConstraints = ImageEditorCanvasView.updateContentLayout(transform: transform,
contentView: clipView)
clipView.superview?.setNeedsLayout()
clipView.superview?.layoutIfNeeded()
updateCropViewLayout()
}
private func updateCropViewLayout() {
cropView.updateLayout(using: clipView)
if !isCropGestureActive {
cropView.cropFrame = clipView.convert(clipView.bounds, to: cropView)
}
if !rotationControl.isTracking {
rotationControl.angle = transform.rotationRadians.radiansToDegrees
}
}
func updateContent() {
AssertIsOnMainThread()
Logger.verbose("")
let viewSize = croppedContentView.bounds.size
guard viewSize.width > 0, viewSize.height > 0 else {
return
}
updateTransform(transform)
}
private func updateTransform(_ transform: ImageEditorTransform, animated: Bool = false) {
self.transform = transform
CATransaction.begin()
if animated {
// Note that animation duration is longer than crop fade-in/fade-out animation.
// The animation sequence is:
// quickly hide crop frame
// apply image transform
// quickly show crop frame.
CATransaction.setAnimationDuration(0.3)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut))
CATransaction.setCompletionBlock {
UIView.animate(withDuration: 0.1) {
self.cropView.alpha = 1
self.updateResetButtonAppearance()
}
}
} else {
CATransaction.setDisableActions(true)
}
applyTransform(animated: animated)
if animated {
// Fade out the crop frame and update it's shape/position
// while the crop frame is not visible.
// Once image transform animation completes the crop frame will be
// shown in its final position.
UIView.animate(withDuration: 0.1,
animations: {
self.cropView.alpha = 0
},
completion: { _ in
self.updateClipViewLayout()
})
} else {
updateClipViewLayout()
updateResetButtonAppearance()
}
updateImageLayer()
CATransaction.commit()
}
private func applyTransform(animated: Bool) {
let viewSize = croppedContentView.bounds.size
let newTransform = transform.transform3D(viewSize: viewSize)
var animation: CABasicAnimation?
if animated {
animation = CABasicAnimation()
animation?.fromValue = croppedContentView.layer.transform // always the same as `uncroppedContentView.layer.transform`
animation?.toValue = newTransform
}
croppedContentView.layer.transform = newTransform
uncroppedContentView.layer.transform = newTransform
if let animation = animation {
croppedContentView.layer.add(animation, forKey: #keyPath(CALayer.transform))
uncroppedContentView.layer.add(animation, forKey: #keyPath(CALayer.transform))
}
}
private func updateImageLayer() {
let viewSize = croppedContentView.bounds.size
ImageEditorCanvasView.updateImageLayer(imageLayer: imageLayer, viewSize: viewSize, imageSize: model.srcImageSizePixels, transform: transform)
}
private func configureGestures() {
self.view.isUserInteractionEnabled = true
let pinchGestureRecognizer = ImageEditorPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
pinchGestureRecognizer.referenceView = self.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 = self.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 func setControls(hidden: Bool, animated: Bool, completion: ((Bool) -> Void)? = nil) {
if animated {
UIView.animate(withDuration: 0.15,
animations: {
self.setControls(hidden: hidden)
// Animate layout changes made within bottomBar.setControls(hidden:).
self.bottomBar.setNeedsDisplay()
self.bottomBar.layoutIfNeeded()
},
completion: completion)
} else {
setControls(hidden: hidden)
if let completion = completion {
completion(true)
}
}
}
private func setControls(hidden: Bool) {
let alpha: CGFloat = hidden ? 0 : 1
footerView.alpha = alpha
bottomBar.setControls(hidden: hidden)
}
// MARK: - Gestures
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()
Logger.verbose("")
// We could undo an in-progress pinch if the gesture is cancelled, but it seems gratuitous.
switch gestureRecognizer.state {
case .began:
gestureStartTransform = transform
case .changed, .ended:
guard let gestureStartTransform = gestureStartTransform else {
owsFailDebug("Missing pinchTransform.")
return
}
let newUnitTranslation = ImageEditorCropViewController.unitTranslation(oldLocationView: gestureRecognizer.pinchStateStart.centroid,
newLocationView: gestureRecognizer.pinchStateLast.centroid,
viewBounds: clipView.bounds,
oldTransform: gestureStartTransform)
var newRotationRadians = gestureStartTransform.rotationRadians + gestureRecognizer.pinchStateLast.angleRadians - gestureRecognizer.pinchStateStart.angleRadians
// Convert to degrees and back to avoid any rounding issues.
let newRotationDegrees = newRotationRadians.radiansToDegrees.clamp(-45, 45)
newRotationRadians = newRotationDegrees.degreesToRadians
// NOTE: We use max(1, ...) to avoid divide-by-zero.
//
// TODO: The clamp limits are wrong.
let newScaling = CGFloatClamp(gestureStartTransform.scaling * gestureRecognizer.pinchStateLast.distance / max(1.0, gestureRecognizer.pinchStateStart.distance),
ImageEditorTextItem.kMinScaling,
ImageEditorTextItem.kMaxScaling)
updateTransform(ImageEditorTransform(outputSizePixels: gestureStartTransform.outputSizePixels,
unitTranslation: newUnitTranslation,
rotationRadians: newRotationRadians,
scaling: newScaling,
isFlipped: gestureStartTransform.isFlipped).normalize(srcImageSizePixels: model.srcImageSizePixels))
default:
break
}
switch gestureRecognizer.state {
case .began:
setGridHidden(false, animated: true)
case .ended, .cancelled:
setGridHidden(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
}
}
Logger.verbose("")
// 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:
Logger.verbose("began: \(transform.unitTranslation)")
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:
if panCropRegion != nil {
panCropRegion = nil
// Don't animate changes.
CATransaction.begin()
CATransaction.setDisableActions(true)
updateCropViewLayout()
CATransaction.commit()
}
default:
break
}
// Show/hide grid lines.
switch gestureRecognizer.state {
case .began:
setGridHidden(false, animated: true)
case .ended, .cancelled:
setGridHidden(true, animated: true, afterDelay: 0.5)
default:
break
}
}
private func handleCropPanGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer,
panCropRegion: CropRegion) {
AssertIsOnMainThread()
Logger.verbose("")
guard let locationStart = gestureRecognizer.locationFirst else {
owsFailDebug("Missing locationStart.")
return
}
let locationNow = gestureRecognizer.location(in: self.clipView)
// Crop pan gesture
let locationDelta = CGPointSubtract(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
}
cropView.cropFrame = view.convert(cropRectangleNow, from: clipView)
switch gestureRecognizer.state {
case .ended:
crop(toRect: cropRectangleNow)
default:
break
}
}
private func crop(toRect cropRect: CGRect) {
let viewBounds = clipView.bounds
// TODO: The output size should be rounded, although this can
// cause crop to be slightly not WYSIWYG.
let croppedOutputSizePixels = CGSizeRound(CGSize(width: transform.outputSizePixels.width * cropRect.width / clipView.width,
height: transform.outputSizePixels.height * cropRect.height / clipView.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)
// Clear the panCropRegion now so that the crop bounds are updated
// immediately.
panCropRegion = nil
updateTransform(ImageEditorTransform(outputSizePixels: croppedOutputSizePixels,
unitTranslation: unitTranslation,
rotationRadians: transform.rotationRadians,
scaling: scaling,
isFlipped: transform.isFlipped).normalize(srcImageSizePixels: model.srcImageSizePixels))
}
private func handleNormalPanGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) {
AssertIsOnMainThread()
guard let gestureStartTransform = gestureStartTransform else {
owsFailDebug("Missing pinchTransform.")
return
}
guard let oldLocationView = gestureRecognizer.locationFirst else {
owsFailDebug("Missing locationStart.")
return
}
let newLocationView = gestureRecognizer.location(in: self.clipView)
let newUnitTranslation = ImageEditorCropViewController.unitTranslation(oldLocationView: oldLocationView,
newLocationView: newLocationView,
viewBounds: clipView.bounds,
oldTransform: gestureStartTransform)
updateTransform(ImageEditorTransform(outputSizePixels: gestureStartTransform.outputSizePixels,
unitTranslation: newUnitTranslation,
rotationRadians: gestureStartTransform.rotationRadians,
scaling: gestureStartTransform.scaling,
isFlipped: gestureStartTransform.isFlipped).normalize(srcImageSizePixels: model.srcImageSizePixels))
}
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: #imageLiteral(resourceName: "media-editor-toolbar-rotate"), backgroundStyle: .none)
rotateButton.addTarget(self, action: #selector(didTapRotateImage), for: .touchUpInside)
let flipButton = RoundMediaButton(image: #imageLiteral(resourceName: "media-editor-toolbar-flip"), backgroundStyle: .none)
flipButton.addTarget(self, action: #selector(didTapFlipImage), for: .touchUpInside)
let aspectRatioButton = RoundMediaButton(image: #imageLiteral(resourceName: "media-editor-toolbar-aspect"), backgroundStyle: .none)
aspectRatioButton.addTarget(self, action: #selector(didTapChooseAspectRatio), for: .touchUpInside)
return [ rotateButton, flipButton, aspectRatioButton ]
}
}
// MARK: - Grid
extension ImageEditorCropViewController {
private func setGridHidden(_ hidden: Bool, animated: Bool) {
if let timer = setGridHiddenTimer {
timer.invalidate()
setGridHiddenTimer = nil
}
isGridHidden = hidden
cropView.setGrid(hidden: hidden, animated: animated)
}
private func setGridHidden(_ hidden: Bool, animated: Bool, afterDelay delay: TimeInterval) {
guard delay > 0 else {
setGridHidden(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.setGridHidden(hidden, animated: animated)
}
setGridHiddenTimer = timer
}
}
// 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
}
cropView.cropFrame = view.convert(newCropRect, from: clipView)
crop(toRect: newCropRect)
updateClipViewLayout()
updateResetButtonAppearance()
}
}
// MARK: - Events
extension ImageEditorCropViewController {
@objc
private func didTapCancel() {
setControls(hidden: true, animated: true) { finished in
guard finished else { return }
self.dismiss(animated: false)
}
}
@objc
private func didTapDone() {
model.replace(transform: transform)
setControls(hidden: true, animated: true) { finished in
guard finished else { return }
self.dismiss(animated: false)
}
}
@objc
private func didTapRotateImage() {
Logger.verbose("")
// Invert width and height.
let outputSizePixels = CGSize(width: transform.outputSizePixels.height, height: transform.outputSizePixels.width)
let rotationAngle = -CGFloat.pi / 2
let unitTranslation = transform.unitTranslation
let rotationRadians = transform.rotationRadians + rotationAngle
let scaling = transform.scaling
rotationControl.canvasRotation += rotationAngle.radiansToDegrees
updateTransform(ImageEditorTransform(outputSizePixels: outputSizePixels,
unitTranslation: unitTranslation,
rotationRadians: rotationRadians,
scaling: scaling,
isFlipped: transform.isFlipped).normalize(srcImageSizePixels: model.srcImageSizePixels),
animated: true)
}
@objc
private func didTapFlipImage() {
Logger.verbose("")
updateTransform(ImageEditorTransform(outputSizePixels: transform.outputSizePixels,
unitTranslation: transform.unitTranslation,
rotationRadians: transform.rotationRadians,
scaling: transform.scaling,
isFlipped: !transform.isFlipped).normalize(srcImageSizePixels: model.srcImageSizePixels),
animated: true)
}
@objc
private func didTapReset() {
Logger.verbose("")
rotationControl.canvasRotation = 0
updateTransform(ImageEditorTransform.defaultTransform(srcImageSizePixels: model.srcImageSizePixels), animated: true)
}
@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 outputSizePixels = transform.outputSizePixels
let unitTranslation = transform.unitTranslation
let rotationRadians = sender.angle.degreesToRadians
let scaling = transform.scaling
updateTransform(ImageEditorTransform(outputSizePixels: outputSizePixels,
unitTranslation: unitTranslation,
rotationRadians: rotationRadians,
scaling: scaling,
isFlipped: transform.isFlipped).normalize(srcImageSizePixels: model.srcImageSizePixels))
}
@objc
private func rotationControlDidBeginEditing(_ sender: RotationControl) {
setGridHidden(false, animated: true)
}
@objc
private func rotationControlDidEndEditing(_ sender: RotationControl) {
setGridHidden(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)
}
}