session-ios/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewControll...

825 lines
34 KiB
Swift

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import UIKit
import SessionUIKit
public protocol ImageEditorCropViewControllerDelegate: class {
func cropDidComplete(transform: ImageEditorTransform)
func cropDidCancel()
}
// MARK: -
// A view for editing text item in image editor.
class ImageEditorCropViewController: OWSViewController {
private weak var delegate: ImageEditorCropViewControllerDelegate?
private let model: ImageEditorModel
private let srcImage: UIImage
private let previewImage: UIImage
private var transform: ImageEditorTransform
public let clipView = OWSLayerView()
public let croppedContentView = OWSLayerView()
public let uncroppedContentView = UIView()
private var croppedImageLayer = CALayer()
private var uncroppedImageLayer = CALayer()
private enum CropRegion {
// The sides of the crop region.
case left, right, top, bottom
// The corners of the crop region.
case topLeft, topRight, bottomLeft, bottomRight
}
private class CropCornerView: OWSLayerView {
let cropRegion: CropRegion
init(cropRegion: CropRegion) {
self.cropRegion = cropRegion
super.init()
}
@available(*, unavailable, message: "use other init() instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
}
private let cropView = UIView()
private let cropCornerViews: [CropCornerView] = [
CropCornerView(cropRegion: .topLeft),
CropCornerView(cropRegion: .topRight),
CropCornerView(cropRegion: .bottomLeft),
CropCornerView(cropRegion: .bottomRight)
]
init(delegate: ImageEditorCropViewControllerDelegate,
model: ImageEditorModel,
srcImage: UIImage,
previewImage: UIImage) {
self.delegate = delegate
self.model = model
self.srcImage = srcImage
self.previewImage = previewImage
transform = model.currentTransform()
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable, message: "use other init() instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
// MARK: - View Lifecycle
private var isCropLocked = false
private var cropLockButton: OWSButton?
override func loadView() {
self.view = UIView()
self.view.backgroundColor = Colors.navigationBarBackground
self.view.layoutMargins = .zero
// MARK: - Buttons
let rotate90Button = OWSButton(imageName: "image_editor_rotate",
tintColor: Colors.text) { [weak self] in
self?.rotate90ButtonPressed()
}
let flipButton = OWSButton(imageName: "image_editor_flip",
tintColor: Colors.text) { [weak self] in
self?.flipButtonPressed()
}
let cropLockButton = OWSButton(imageName: "image_editor_crop_unlock",
tintColor: Colors.text) { [weak self] in
self?.cropLockButtonPressed()
}
self.cropLockButton = cropLockButton
// MARK: - Canvas & Wrapper
let wrapperView = UIView.container()
wrapperView.backgroundColor = .clear
wrapperView.isOpaque = false
// TODO: We could mask the clipped region with a semi-transparent overlay like WA.
clipView.clipsToBounds = true
clipView.backgroundColor = .clear
clipView.isOpaque = false
clipView.layoutCallback = { [weak self] (_) in
guard let strongSelf = self else {
return
}
strongSelf.updateCropViewLayout()
}
wrapperView.addSubview(clipView)
croppedImageLayer.contents = previewImage.cgImage
croppedImageLayer.contentsScale = previewImage.scale
croppedContentView.backgroundColor = .clear
croppedContentView.isOpaque = false
croppedContentView.layer.addSublayer(croppedImageLayer)
croppedContentView.layoutCallback = { [weak self] (_) in
guard let strongSelf = self else {
return
}
strongSelf.updateContent()
}
clipView.addSubview(croppedContentView)
croppedContentView.autoPinEdgesToSuperviewEdges()
uncroppedImageLayer.contents = previewImage.cgImage
uncroppedImageLayer.contentsScale = previewImage.scale
// The "uncropped" view/layer are used to display the
// content that has been cropped out. Its content
// should be semi-transparent to distinguish it from
// the content within the crop bounds.
uncroppedImageLayer.opacity = 0.5
uncroppedContentView.backgroundColor = .clear
uncroppedContentView.isOpaque = false
uncroppedContentView.layer.addSublayer(uncroppedImageLayer)
wrapperView.addSubview(uncroppedContentView)
uncroppedContentView.autoPin(toEdgesOf: croppedContentView)
// MARK: - Footer
let footer = UIStackView(arrangedSubviews: [
rotate90Button,
flipButton,
UIView.hStretchingSpacer(),
cropLockButton
])
footer.axis = .horizontal
footer.spacing = 16
footer.backgroundColor = .clear
footer.isOpaque = false
let imageMargin: CGFloat = 20
let stackView = UIStackView(arrangedSubviews: [
wrapperView,
footer
])
stackView.axis = .vertical
stackView.alignment = .fill
stackView.spacing = imageMargin
stackView.layoutMargins = UIEdgeInsets(top: 8, left: imageMargin, bottom: 8, right: imageMargin)
stackView.isLayoutMarginsRelativeArrangement = true
self.view.addSubview(stackView)
stackView.autoPinEdgesToSuperviewEdges()
// MARK: - Crop View
// Add crop view last so that it appears in front of the content.
cropView.setContentHuggingLow()
cropView.setCompressionResistanceLow()
view.addSubview(cropView)
for cropCornerView in cropCornerViews {
cropView.addSubview(cropCornerView)
switch cropCornerView.cropRegion {
case .topLeft, .bottomLeft:
cropCornerView.autoPinEdge(toSuperviewEdge: .left)
case .topRight, .bottomRight:
cropCornerView.autoPinEdge(toSuperviewEdge: .right)
default:
owsFailDebug("Invalid crop region: \(cropRegion)")
}
switch cropCornerView.cropRegion {
case .topLeft, .topRight:
cropCornerView.autoPinEdge(toSuperviewEdge: .top)
case .bottomLeft, .bottomRight:
cropCornerView.autoPinEdge(toSuperviewEdge: .bottom)
default:
owsFailDebug("Invalid crop region: \(cropRegion)")
}
}
setCropViewAppearance()
updateClipViewLayout()
configureGestures()
updateNavigationBar()
}
public func updateNavigationBar() {
let resetButton = navigationBarButton(imageName: "image_editor_undo",
selector: #selector(didTapReset(sender:)))
let doneButton = navigationBarButton(imageName: "image_editor_checkmark_full",
selector: #selector(didTapDone(sender:)))
var navigationBarItems = [UIView]()
if transform.isNonDefault {
navigationBarItems = [resetButton, doneButton]
} else {
navigationBarItems = [doneButton]
}
updateNavigationBar(navigationBarItems: navigationBarItems)
}
private func updateCropLockButton() {
guard let cropLockButton = cropLockButton else {
owsFailDebug("Missing cropLockButton")
return
}
cropLockButton.setImage(imageName: (isCropLocked
? "image_editor_crop_lock"
: "image_editor_crop_unlock"))
}
@objc
public override var prefersStatusBarHidden: Bool {
return true
}
@objc
override public var canBecomeFirstResponder: Bool {
return true
}
private static let desiredCornerSize: CGFloat = 24
private static let minCropSize: CGFloat = desiredCornerSize * 2
private var cornerSize = CGSize.zero
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 var cropViewConstraints = [NSLayoutConstraint]()
private func setCropViewAppearance() {
// TODO: Tune the size.
let cornerSize = CGSize(width: min(clipView.width() * 0.5, ImageEditorCropViewController.desiredCornerSize),
height: min(clipView.height() * 0.5, ImageEditorCropViewController.desiredCornerSize))
self.cornerSize = cornerSize
for cropCornerView in cropCornerViews {
let cornerThickness: CGFloat = 2
let shapeLayer = CAShapeLayer()
cropCornerView.layer.addSublayer(shapeLayer)
shapeLayer.fillColor = UIColor.white.cgColor
shapeLayer.strokeColor = nil
cropCornerView.layoutCallback = { (view) in
let shapeFrame = view.bounds.insetBy(dx: -cornerThickness, dy: -cornerThickness)
shapeLayer.frame = shapeFrame
let bezierPath = UIBezierPath()
switch cropCornerView.cropRegion {
case .topLeft:
bezierPath.addRegion(withPoints: [
CGPoint.zero,
CGPoint(x: shapeFrame.width - cornerThickness, y: 0),
CGPoint(x: shapeFrame.width - cornerThickness, y: cornerThickness),
CGPoint(x: cornerThickness, y: cornerThickness),
CGPoint(x: cornerThickness, y: shapeFrame.height - cornerThickness),
CGPoint(x: 0, y: shapeFrame.height - cornerThickness)
])
case .topRight:
bezierPath.addRegion(withPoints: [
CGPoint(x: shapeFrame.width, y: 0),
CGPoint(x: shapeFrame.width, y: shapeFrame.height - cornerThickness),
CGPoint(x: shapeFrame.width - cornerThickness, y: shapeFrame.height - cornerThickness),
CGPoint(x: shapeFrame.width - cornerThickness, y: cornerThickness),
CGPoint(x: cornerThickness, y: cornerThickness),
CGPoint(x: cornerThickness, y: 0)
])
case .bottomLeft:
bezierPath.addRegion(withPoints: [
CGPoint(x: 0, y: shapeFrame.height),
CGPoint(x: 0, y: cornerThickness),
CGPoint(x: cornerThickness, y: cornerThickness),
CGPoint(x: cornerThickness, y: shapeFrame.height - cornerThickness),
CGPoint(x: shapeFrame.width - cornerThickness, y: shapeFrame.height - cornerThickness),
CGPoint(x: shapeFrame.width - cornerThickness, y: shapeFrame.height)
])
case .bottomRight:
bezierPath.addRegion(withPoints: [
CGPoint(x: shapeFrame.width, y: shapeFrame.height),
CGPoint(x: cornerThickness, y: shapeFrame.height),
CGPoint(x: cornerThickness, y: shapeFrame.height - cornerThickness),
CGPoint(x: shapeFrame.width - cornerThickness, y: shapeFrame.height - cornerThickness),
CGPoint(x: shapeFrame.width - cornerThickness, y: cornerThickness),
CGPoint(x: shapeFrame.width, y: cornerThickness)
])
default:
owsFailDebug("Invalid crop region: \(cropCornerView.cropRegion)")
}
shapeLayer.path = bezierPath.cgPath
}
}
cropView.addBorder(with: .white)
}
private func updateCropViewLayout() {
NSLayoutConstraint.deactivate(cropViewConstraints)
cropViewConstraints.removeAll()
// TODO: Tune the size.
let cornerSize = CGSize(width: min(clipView.width() * 0.5, ImageEditorCropViewController.desiredCornerSize),
height: min(clipView.height() * 0.5, ImageEditorCropViewController.desiredCornerSize))
self.cornerSize = cornerSize
for cropCornerView in cropCornerViews {
cropViewConstraints.append(contentsOf: cropCornerView.autoSetDimensions(to: cornerSize))
}
if !isCropGestureActive {
cropView.frame = view.convert(clipView.bounds, from: clipView)
}
}
internal 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) {
self.transform = transform
// Don't animate changes.
CATransaction.begin()
CATransaction.setDisableActions(true)
applyTransform()
updateClipViewLayout()
updateImageLayer()
updateNavigationBar()
CATransaction.commit()
}
private func applyTransform() {
let viewSize = croppedContentView.bounds.size
croppedContentView.layer.setAffineTransform(transform.affineTransform(viewSize: viewSize))
uncroppedContentView.layer.setAffineTransform(transform.affineTransform(viewSize: viewSize))
}
private func updateImageLayer() {
let viewSize = croppedContentView.bounds.size
ImageEditorCanvasView.updateImageLayer(imageLayer: croppedImageLayer, viewSize: viewSize, imageSize: model.srcImageSizePixels, transform: transform)
ImageEditorCanvasView.updateImageLayer(imageLayer: uncroppedImageLayer, 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)
}
// MARK: - Gestures
private class func unitTranslation(oldLocationView: CGPoint,
newLocationView: CGPoint,
viewBounds: CGRect,
oldTransform: ImageEditorTransform) -> CGPoint {
// The beauty of using an SRT (scale-rotate-translation) tranform 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
public 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)
let newRotationRadians = gestureStartTransform.rotationRadians + gestureRecognizer.pinchStateLast.angleRadians - gestureRecognizer.pinchStateStart.angleRadians
// 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
}
}
// MARK: - Pan Gesture
private var gestureStartTransform: ImageEditorTransform?
private var panCropRegion: CropRegion?
private var isCropGestureActive: Bool {
return panCropRegion != nil
}
@objc
public func handlePanGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) {
AssertIsOnMainThread()
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
}
}
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 - cornerSize.width * 2
let maxDeltaY = cropRectangleNow.size.height - 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
}
// If crop is locked, update the crop rectangle
// to retain the original aspect ratio.
if (isCropLocked) {
let scaleX = cropRectangleNow.width / cropRectangleStart.width
let scaleY = cropRectangleNow.height / cropRectangleStart.height
var cropRectangleLocked = cropRectangleStart
// Find a new crop rectangle size with the correct aspect
// ratio which is always larger than the "naive" crop rectangle.
// We always expand and never shrink the crop rectangle to
// fix its aspect ratio, to ensure the "max deltas" enforced
// above still are honored.
if scaleX > scaleY {
cropRectangleLocked.size.width = cropRectangleNow.width
cropRectangleLocked.size.height = cropRectangleNow.width * cropRectangleStart.height / cropRectangleStart.width
} else {
cropRectangleLocked.size.height = cropRectangleNow.height
cropRectangleLocked.size.width = cropRectangleNow.height * cropRectangleStart.width / cropRectangleStart.height
}
// Pin the crop rectangle to the sides that aren't being manipulated.
switch panCropRegion {
case .left, .topLeft, .bottomLeft:
cropRectangleLocked.origin.x = cropRectangleStart.maxX - cropRectangleLocked.width
default:
// Bias towards aligning left.
cropRectangleLocked.origin.x = cropRectangleStart.minX
}
switch panCropRegion {
case .top, .topLeft, .topRight:
cropRectangleLocked.origin.y = cropRectangleStart.maxY - cropRectangleLocked.height
default:
// Bias towards aligning top.
cropRectangleLocked.origin.y = cropRectangleStart.minY
}
cropRectangleNow = cropRectangleLocked
}
cropView.frame = 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 tranform
// 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 = ImageEditorCropViewController.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: - Events
@objc func didTapDone(sender: UIButton) {
completeAndDismiss()
}
private func completeAndDismiss() {
self.delegate?.cropDidComplete(transform: transform)
self.dismiss(animated: false) {
// Do nothing.
}
}
@objc public func rotate90ButtonPressed() {
rotateButtonPressed(angleRadians: CGFloat.pi * 0.5, rotateCanvas: true)
}
private func rotateButtonPressed(angleRadians: CGFloat, rotateCanvas: Bool) {
let outputSizePixels = (rotateCanvas
// Invert width and height.
? CGSize(width: transform.outputSizePixels.height,
height: transform.outputSizePixels.width)
: transform.outputSizePixels)
let unitTranslation = transform.unitTranslation
let rotationRadians = transform.rotationRadians + angleRadians
let scaling = transform.scaling
updateTransform(ImageEditorTransform(outputSizePixels: outputSizePixels,
unitTranslation: unitTranslation,
rotationRadians: rotationRadians,
scaling: scaling,
isFlipped: transform.isFlipped).normalize(srcImageSizePixels: model.srcImageSizePixels))
}
@objc public func flipButtonPressed() {
updateTransform(ImageEditorTransform(outputSizePixels: transform.outputSizePixels,
unitTranslation: transform.unitTranslation,
rotationRadians: transform.rotationRadians,
scaling: transform.scaling,
isFlipped: !transform.isFlipped).normalize(srcImageSizePixels: model.srcImageSizePixels))
}
@objc func didTapReset(sender: UIButton) {
Logger.verbose("")
updateTransform(ImageEditorTransform.defaultTransform(srcImageSizePixels: model.srcImageSizePixels))
}
@objc public func cropLockButtonPressed() {
isCropLocked = !isCropLocked
updateCropLockButton()
}
}
// MARK: -
extension ImageEditorCropViewController: UIGestureRecognizerDelegate {
@objc public 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)
}
}