mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
659 lines
26 KiB
Swift
659 lines
26 KiB
Swift
//
|
|
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
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 contentView = OWSLayerView()
|
|
|
|
public let clipView = OWSLayerView()
|
|
|
|
private var imageLayer = 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: UIView {
|
|
let cropRegion: CropRegion
|
|
|
|
init(cropRegion: CropRegion) {
|
|
self.cropRegion = cropRegion
|
|
super.init(frame: .zero)
|
|
}
|
|
|
|
@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
|
|
|
|
override func loadView() {
|
|
self.view = UIView()
|
|
|
|
if (UIAccessibilityIsReduceTransparencyEnabled()) {
|
|
self.view.backgroundColor = UIColor(white: 0.5, alpha: 0.5)
|
|
} else {
|
|
let alpha = OWSNavigationBar.backgroundBlurMutingFactor
|
|
self.view.backgroundColor = UIColor(white: 0.5, alpha: alpha)
|
|
|
|
let blurEffectView = UIVisualEffectView(effect: Theme.barBlurEffect)
|
|
blurEffectView.layer.zPosition = -1
|
|
self.view.addSubview(blurEffectView)
|
|
blurEffectView.autoPinEdgesToSuperviewEdges()
|
|
}
|
|
|
|
let stackView = UIStackView()
|
|
stackView.axis = .vertical
|
|
stackView.alignment = .fill
|
|
stackView.spacing = 16
|
|
stackView.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20)
|
|
stackView.isLayoutMarginsRelativeArrangement = true
|
|
self.view.addSubview(stackView)
|
|
stackView.ows_autoPinToSuperviewEdges()
|
|
|
|
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop,
|
|
target: self,
|
|
action: #selector(didTapBackButton))
|
|
|
|
let wrapperView = UIView.container()
|
|
wrapperView.backgroundColor = .clear
|
|
wrapperView.isOpaque = false
|
|
stackView.addArrangedSubview(wrapperView)
|
|
|
|
// 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)
|
|
|
|
imageLayer.contents = previewImage.cgImage
|
|
imageLayer.contentsScale = previewImage.scale
|
|
contentView.backgroundColor = .clear
|
|
contentView.isOpaque = false
|
|
contentView.layer.addSublayer(imageLayer)
|
|
contentView.layoutCallback = { [weak self] (_) in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.updateContent()
|
|
}
|
|
clipView.addSubview(contentView)
|
|
contentView.ows_autoPinToSuperviewEdges()
|
|
|
|
let rotate90Button = OWSButton()
|
|
rotate90Button.setTitle(NSLocalizedString("IMAGE_EDITOR_ROTATE_90_BUTTON", comment: "Label for button that rotates image 90 degrees."),
|
|
for: .normal)
|
|
rotate90Button.block = { [weak self] in
|
|
self?.rotate90ButtonPressed()
|
|
}
|
|
|
|
let rotate45Button = OWSButton()
|
|
rotate45Button.setTitle(NSLocalizedString("IMAGE_EDITOR_ROTATE_45_BUTTON", comment: "Label for button that rotates image 45 degrees."),
|
|
for: .normal)
|
|
rotate45Button.block = { [weak self] in
|
|
self?.rotate45ButtonPressed()
|
|
}
|
|
|
|
let resetButton = OWSButton()
|
|
resetButton.setTitle(NSLocalizedString("IMAGE_EDITOR_RESET_BUTTON", comment: "Label for button that resets crop & rotation state."),
|
|
for: .normal)
|
|
resetButton.block = { [weak self] in
|
|
self?.resetButtonPressed()
|
|
}
|
|
|
|
let zoom2xButton = OWSButton()
|
|
zoom2xButton.setTitle("Zoom 2x",
|
|
for: .normal)
|
|
zoom2xButton.block = { [weak self] in
|
|
self?.zoom2xButtonPressed()
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|
|
|
|
let footer = UIStackView(arrangedSubviews: [rotate90Button, rotate45Button, resetButton, zoom2xButton])
|
|
footer.axis = .horizontal
|
|
footer.spacing = 16
|
|
footer.backgroundColor = .clear
|
|
footer.isOpaque = false
|
|
stackView.addArrangedSubview(footer)
|
|
|
|
updateClipViewLayout()
|
|
|
|
configureGestures()
|
|
}
|
|
|
|
private static let desiredCornerSize: CGFloat = 30
|
|
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 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))
|
|
|
|
cropCornerView.addRedBorder()
|
|
cropView.addRedBorder()
|
|
}
|
|
|
|
if !isCropGestureActive {
|
|
cropView.frame = view.convert(clipView.bounds, from: clipView)
|
|
}
|
|
}
|
|
|
|
internal func updateContent() {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.verbose("")
|
|
|
|
let viewSize = contentView.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()
|
|
|
|
CATransaction.commit()
|
|
}
|
|
|
|
private func applyTransform() {
|
|
Logger.verbose("")
|
|
|
|
let viewSize = contentView.bounds.size
|
|
contentView.layer.setAffineTransform(transform.affineTransform(viewSize: viewSize))
|
|
}
|
|
|
|
private func updateImageLayer() {
|
|
let viewSize = contentView.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
|
|
pinchGestureRecognizer.delegate = self
|
|
view.addGestureRecognizer(pinchGestureRecognizer)
|
|
|
|
let panGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
|
|
panGestureRecognizer.maximumNumberOfTouches = 1
|
|
panGestureRecognizer.referenceView = self.clipView
|
|
panGestureRecognizer.delegate = self
|
|
view.addGestureRecognizer(panGestureRecognizer)
|
|
}
|
|
|
|
override public var canBecomeFirstResponder: Bool {
|
|
return true
|
|
}
|
|
|
|
// 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).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.locationStart 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
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
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).normalize(srcImageSizePixels: model.srcImageSizePixels))
|
|
}
|
|
|
|
private func handleNormalPanGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let gestureStartTransform = gestureStartTransform else {
|
|
owsFailDebug("Missing pinchTransform.")
|
|
return
|
|
}
|
|
guard let oldLocationView = gestureRecognizer.locationStart 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).normalize(srcImageSizePixels: model.srcImageSizePixels))
|
|
}
|
|
|
|
private func cropRegion(forGestureRecognizer gestureRecognizer: ImageEditorPanGestureRecognizer) -> CropRegion? {
|
|
guard let location = gestureRecognizer.locationStart 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 public func didTapBackButton() {
|
|
completeAndDismiss()
|
|
}
|
|
|
|
private func completeAndDismiss() {
|
|
self.delegate?.cropDidComplete(transform: transform)
|
|
|
|
self.dismiss(animated: true) {
|
|
// Do nothing.
|
|
}
|
|
}
|
|
|
|
@objc public func rotate90ButtonPressed() {
|
|
rotateButtonPressed(angleRadians: CGFloat.pi * 0.5, rotateCanvas: true)
|
|
}
|
|
|
|
@objc public func rotate45ButtonPressed() {
|
|
rotateButtonPressed(angleRadians: CGFloat.pi * 0.25, rotateCanvas: false)
|
|
}
|
|
|
|
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).normalize(srcImageSizePixels: model.srcImageSizePixels))
|
|
}
|
|
|
|
@objc public func zoom2xButtonPressed() {
|
|
let outputSizePixels = transform.outputSizePixels
|
|
let unitTranslation = transform.unitTranslation
|
|
let rotationRadians = transform.rotationRadians
|
|
let scaling = transform.scaling * 2.0
|
|
updateTransform(ImageEditorTransform(outputSizePixels: outputSizePixels,
|
|
unitTranslation: unitTranslation,
|
|
rotationRadians: rotationRadians,
|
|
scaling: scaling).normalize(srcImageSizePixels: model.srcImageSizePixels))
|
|
}
|
|
|
|
@objc public func resetButtonPressed() {
|
|
updateTransform(ImageEditorTransform.defaultTransform(srcImageSizePixels: model.srcImageSizePixels))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|