mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
824 lines
34 KiB
Swift
824 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)
|
|
}
|
|
}
|