Signal-iOS/SignalUI/Views/ImageEditor/ImageEditorCropViewController.swift
igor-signal e586969b57 Fix issue with rotation control in the media editor's crop tool.
Rotation control would show value outside of -45..45 range after leaving
crop tool and opening it again.
2022-07-14 12:12:04 -07:00

996 lines
40 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 contentAlignmentLayoutGuide = UILayoutGuide()
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)
// 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.autoPinWidthToSuperview()
footerView.autoPinEdge(toSuperviewEdge: .bottom)
// MARK: - Content Layout Guide
// The purpose of this layout logic 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.
// This is why `contentAlignmentLayoutGuide` is constructed to copy `AttachmentPrepContentView.contentLayoutGuide`,
// which defines image size and position in "review" screen.
//
// Top of the `contentAlignmentLayoutGuide` is constrained using logic
// from `AttachmentApprovalViewController.updateContentLayoutMargins(for:)`.
//
// Bottom of the `contentAlignmentLayoutGuide` is constrained to the top of the `bottomBar`,
// not `footerView` (which includes rotation control). This works because bottom content layout margin
// in `AttachmentPrepContentView` is calculated as the height of ImageEditorBottomBar (same as `bottomBar` in this VC).
view.addLayoutGuide(contentAlignmentLayoutGuide)
if UIDevice.current.hasIPhoneXNotch {
view.addConstraint(contentAlignmentLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor))
} else {
view.addConstraint(contentAlignmentLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor))
}
view.addConstraint(contentAlignmentLayoutGuide.bottomAnchor.constraint(equalTo: bottomBar.topAnchor))
view.addConstraints([
contentAlignmentLayoutGuide.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
contentAlignmentLayoutGuide.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor) ])
wrapperView.autoPinEdge(.bottom, to: .top, of: footerView, withOffset: 0, relation: .lessThanOrEqual)
view.addConstraints([
wrapperView.leadingAnchor.constraint(equalTo: contentAlignmentLayoutGuide.leadingAnchor),
wrapperView.trailingAnchor.constraint(equalTo: contentAlignmentLayoutGuide.trailingAnchor),
wrapperView.centerYAnchor.constraint(equalTo: contentAlignmentLayoutGuide.centerYAnchor) ])
// 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
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("")
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)
}
}