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.
971 lines
38 KiB
Swift
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)
|
|
}
|
|
}
|