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