Re-do all crop view controller with pure Auto Layout which allowed to animate layout changes and transitions between UI states. Image rotation using two fingers was removed to match native Photos app: tilt control UI at the bottom allows much more precise rotation and two finger gesture is now purely for zooming and panning.
362 lines
14 KiB
Swift
362 lines
14 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
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
|
|
|
|
var size: CGSize = CGSize(square: CropView.desiredCornerSize) {
|
|
didSet {
|
|
widthConstraint.constant = size.width
|
|
heightConstraint.constant = size.height
|
|
}
|
|
}
|
|
|
|
lazy private var widthConstraint: NSLayoutConstraint = self.widthAnchor.constraint(equalToConstant: size.width)
|
|
lazy private var heightConstraint: NSLayoutConstraint = self.heightAnchor.constraint(equalToConstant: size.width)
|
|
|
|
init(cropRegion: CropRegion) {
|
|
self.cropRegion = cropRegion
|
|
super.init(frame: .zero)
|
|
isUserInteractionEnabled = false
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
shapeLayer?.fillColor = UIColor.white.cgColor
|
|
addConstraints([ widthConstraint, heightConstraint ])
|
|
}
|
|
|
|
@available(*, unavailable, message: "Use init(cropRegion:) instead.")
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override class var layerClass: AnyClass {
|
|
return CAShapeLayer.self
|
|
}
|
|
|
|
private var shapeLayer: CAShapeLayer? {
|
|
return layer as? CAShapeLayer
|
|
}
|
|
|
|
override var bounds: CGRect {
|
|
didSet {
|
|
if bounds != oldValue {
|
|
updatePath()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updatePath() {
|
|
guard let shapeLayer = shapeLayer else {
|
|
return
|
|
}
|
|
|
|
let cornerThickness: CGFloat = 2
|
|
let shapeFrame = bounds.insetBy(dx: -cornerThickness, dy: -cornerThickness)
|
|
let bezierPath = UIBezierPath()
|
|
switch cropRegion {
|
|
case .topLeft:
|
|
bezierPath.addRegion(withPoints: [
|
|
shapeFrame.origin,
|
|
CGPoint(x: shapeFrame.maxX - cornerThickness, y: shapeFrame.minY),
|
|
CGPoint(x: shapeFrame.maxX - cornerThickness, y: shapeFrame.minY + cornerThickness),
|
|
CGPoint(x: shapeFrame.minX + cornerThickness, y: shapeFrame.minY + cornerThickness),
|
|
CGPoint(x: shapeFrame.minX + cornerThickness, y: shapeFrame.maxY - cornerThickness),
|
|
CGPoint(x: shapeFrame.minX, y: shapeFrame.maxY - cornerThickness)
|
|
])
|
|
case .topRight:
|
|
bezierPath.addRegion(withPoints: [
|
|
CGPoint(x: shapeFrame.maxX, y: shapeFrame.minY),
|
|
CGPoint(x: shapeFrame.maxX, y: shapeFrame.maxY - cornerThickness),
|
|
CGPoint(x: shapeFrame.maxX - cornerThickness, y: shapeFrame.maxY - cornerThickness),
|
|
CGPoint(x: shapeFrame.maxX - cornerThickness, y: shapeFrame.minY + cornerThickness),
|
|
CGPoint(x: shapeFrame.minX + cornerThickness, y: shapeFrame.minY + cornerThickness),
|
|
CGPoint(x: shapeFrame.minX + cornerThickness, y: shapeFrame.minY)
|
|
])
|
|
case .bottomLeft:
|
|
bezierPath.addRegion(withPoints: [
|
|
CGPoint(x: shapeFrame.minX, y: shapeFrame.maxY),
|
|
CGPoint(x: shapeFrame.minX, y: shapeFrame.minY + cornerThickness),
|
|
CGPoint(x: shapeFrame.minX + cornerThickness, y: shapeFrame.minY + cornerThickness),
|
|
CGPoint(x: shapeFrame.minX + cornerThickness, y: shapeFrame.maxY - cornerThickness),
|
|
CGPoint(x: shapeFrame.maxX - cornerThickness, y: shapeFrame.maxY - cornerThickness),
|
|
CGPoint(x: shapeFrame.maxX - cornerThickness, y: shapeFrame.maxY)
|
|
])
|
|
case .bottomRight:
|
|
bezierPath.addRegion(withPoints: [
|
|
CGPoint(x: shapeFrame.maxX, y: shapeFrame.maxY),
|
|
CGPoint(x: shapeFrame.minX + cornerThickness, y: shapeFrame.maxY),
|
|
CGPoint(x: shapeFrame.minX + cornerThickness, y: shapeFrame.maxY - cornerThickness),
|
|
CGPoint(x: shapeFrame.maxX - cornerThickness, y: shapeFrame.maxY - cornerThickness),
|
|
CGPoint(x: shapeFrame.maxX - cornerThickness, y: shapeFrame.minY + cornerThickness),
|
|
CGPoint(x: shapeFrame.maxX, y: shapeFrame.minY + cornerThickness)
|
|
])
|
|
default:
|
|
owsFailDebug("Invalid crop region: \(cropRegion)")
|
|
}
|
|
|
|
shapeLayer.path = bezierPath.cgPath
|
|
}
|
|
}
|
|
|
|
private class CropBackgroundView: UIView {
|
|
|
|
enum Style {
|
|
case blur
|
|
case darkening
|
|
case blackout
|
|
}
|
|
|
|
var style: Style {
|
|
didSet {
|
|
updateStyle()
|
|
}
|
|
}
|
|
|
|
private let blurView = UIVisualEffectView()
|
|
private let darkeningView: UIView = {
|
|
let view = UIView()
|
|
view.backgroundColor = .black
|
|
return view
|
|
}()
|
|
|
|
required init(style: Style) {
|
|
self.style = style
|
|
super.init(frame: .zero)
|
|
isUserInteractionEnabled = false
|
|
addSubview(blurView)
|
|
addSubview(darkeningView)
|
|
updateStyle()
|
|
}
|
|
|
|
@available(*, unavailable, message: "Use init(style:)")
|
|
required init(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
blurView.frame = bounds
|
|
darkeningView.frame = bounds
|
|
}
|
|
|
|
private func updateStyle() {
|
|
switch style {
|
|
case .blur:
|
|
darkeningView.alpha = 0
|
|
blurView.effect = UIBlurEffect(style: .dark)
|
|
|
|
case .darkening:
|
|
darkeningView.alpha = 0.5
|
|
blurView.effect = nil
|
|
|
|
case .blackout:
|
|
darkeningView.alpha = 1
|
|
}
|
|
}
|
|
|
|
var lastKnownMaskRect: CGRect?
|
|
|
|
fileprivate func setMaskRect(_ maskRect: CGRect, animationDuration: TimeInterval) {
|
|
if let lastKnownMaskRect = lastKnownMaskRect, lastKnownMaskRect == maskRect {
|
|
return
|
|
}
|
|
|
|
let maskLayer: CAShapeLayer
|
|
if let existingMaskLayer = layer.mask as? CAShapeLayer {
|
|
maskLayer = existingMaskLayer
|
|
} else {
|
|
maskLayer = CAShapeLayer()
|
|
maskLayer.fillRule = .evenOdd
|
|
layer.mask = maskLayer
|
|
}
|
|
maskLayer.frame = layer.bounds
|
|
|
|
let path = CGMutablePath()
|
|
path.addRect(bounds)
|
|
path.addRect(maskRect)
|
|
|
|
if animationDuration > 0 {
|
|
let animation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.path))
|
|
animation.duration = animationDuration
|
|
animation.fromValue = maskLayer.path
|
|
animation.toValue = path
|
|
maskLayer.add(animation, forKey: "path")
|
|
}
|
|
|
|
maskLayer.path = path
|
|
|
|
lastKnownMaskRect = maskRect
|
|
}
|
|
}
|
|
|
|
class CropView: UIView {
|
|
|
|
static let desiredCornerSize: CGFloat = 22 // adjusted for stroke width, visible size is 24
|
|
private(set) var cornerSize = CGSize(square: CropView.desiredCornerSize)
|
|
|
|
private lazy var backgroundView = CropBackgroundView(style: CropView.backgroundStyle(forState: state))
|
|
|
|
private let cropFrameView: UIView = {
|
|
let view = UIView()
|
|
view.addBorder(with: .white)
|
|
view.translatesAutoresizingMaskIntoConstraints = false
|
|
return view
|
|
}()
|
|
|
|
private let cropCornerViews: [CropCornerView] = [
|
|
CropCornerView(cropRegion: .topLeft),
|
|
CropCornerView(cropRegion: .topRight),
|
|
CropCornerView(cropRegion: .bottomLeft),
|
|
CropCornerView(cropRegion: .bottomRight)
|
|
]
|
|
|
|
private let verticalGridLines: [UIView] = [ UIView(), UIView() ]
|
|
private let horizontalGridLines: [UIView] = [ UIView(), UIView() ]
|
|
|
|
enum State {
|
|
case initial // no crop frame visible, background set to `blackout`
|
|
case normal // default look: crop frame visible, grid lines hidden, background set to `blur`
|
|
case resizing // user is resizing: crop frame and grid lines visible, background set to `darkening`
|
|
}
|
|
private var state: State = .initial
|
|
|
|
// Defines crop frame.
|
|
let cropFrameLayoutGuide = UILayoutGuide()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
isUserInteractionEnabled = false
|
|
|
|
addSubview(backgroundView)
|
|
|
|
// Crop Frame
|
|
cropFrameLayoutGuide.identifier = "CropFrame"
|
|
addLayoutGuide(cropFrameLayoutGuide)
|
|
addSubview(cropFrameView)
|
|
addConstraints([
|
|
cropFrameView.leadingAnchor.constraint(equalTo: cropFrameLayoutGuide.leadingAnchor),
|
|
cropFrameView.topAnchor.constraint(equalTo: cropFrameLayoutGuide.topAnchor),
|
|
cropFrameView.trailingAnchor.constraint(equalTo: cropFrameLayoutGuide.trailingAnchor),
|
|
cropFrameView.bottomAnchor.constraint(equalTo: cropFrameLayoutGuide.bottomAnchor)
|
|
])
|
|
|
|
// Crop Frame Corners
|
|
for cropCornerView in cropCornerViews {
|
|
cropFrameView.addSubview(cropCornerView)
|
|
|
|
switch cropCornerView.cropRegion {
|
|
case .topLeft, .bottomLeft:
|
|
cropCornerView.autoPinEdge(toSuperviewEdge: .left)
|
|
case .topRight, .bottomRight:
|
|
cropCornerView.autoPinEdge(toSuperviewEdge: .right)
|
|
default:
|
|
owsFailDebug("Invalid crop region: \(String(describing: cropCornerView.cropRegion))")
|
|
}
|
|
switch cropCornerView.cropRegion {
|
|
case .topLeft, .topRight:
|
|
cropCornerView.autoPinEdge(toSuperviewEdge: .top)
|
|
case .bottomLeft, .bottomRight:
|
|
cropCornerView.autoPinEdge(toSuperviewEdge: .bottom)
|
|
default:
|
|
owsFailDebug("Invalid crop region: \(String(describing: cropCornerView.cropRegion))")
|
|
}
|
|
}
|
|
|
|
// Spacer Layout Guide that allows to space grid lines evenly
|
|
let spacerLayoutGuide = UILayoutGuide()
|
|
cropFrameView.addLayoutGuide(spacerLayoutGuide)
|
|
NSLayoutConstraint(item: spacerLayoutGuide, attribute: .left, relatedBy: .equal,
|
|
toItem: cropFrameView, attribute: .left, multiplier: 1, constant: 0).isActive = true
|
|
NSLayoutConstraint(item: spacerLayoutGuide, attribute: .top, relatedBy: .equal,
|
|
toItem: cropFrameView, attribute: .top, multiplier: 1, constant: 0).isActive = true
|
|
NSLayoutConstraint(item: spacerLayoutGuide, attribute: .width, relatedBy: .equal,
|
|
toItem: cropFrameView, attribute: .width, multiplier: 1/CGFloat(verticalGridLines.count + 1), constant: 0).isActive = true
|
|
NSLayoutConstraint(item: spacerLayoutGuide, attribute: .height, relatedBy: .equal,
|
|
toItem: cropFrameView, attribute: .height, multiplier: 1/CGFloat(horizontalGridLines.count + 1), constant: 0).isActive = true
|
|
|
|
// Grid Lines
|
|
for (index, line) in verticalGridLines.enumerated() {
|
|
line.backgroundColor = .ows_white
|
|
cropFrameView.addSubview(line)
|
|
line.autoSetDimension(.width, toSize: 1)
|
|
line.autoPinHeightToSuperview()
|
|
NSLayoutConstraint(item: line, attribute: .centerX, relatedBy: .equal,
|
|
toItem: spacerLayoutGuide, attribute: .right,
|
|
multiplier: CGFloat(index + 1),
|
|
constant: 0).isActive = true
|
|
}
|
|
for (index, line) in horizontalGridLines.enumerated() {
|
|
line.backgroundColor = .ows_white
|
|
cropFrameView.addSubview(line)
|
|
line.autoSetDimension(.height, toSize: 1)
|
|
line.autoPinWidthToSuperview()
|
|
NSLayoutConstraint(item: line, attribute: .centerY, relatedBy: .equal,
|
|
toItem: spacerLayoutGuide, attribute: .bottom,
|
|
multiplier: CGFloat(index + 1),
|
|
constant: 0).isActive = true
|
|
}
|
|
setState(.initial, animated: false)
|
|
}
|
|
|
|
@available(*, unavailable, message: "Use init(frame:)")
|
|
required init(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
backgroundView.frame = bounds
|
|
// `inheritedAnimationDuration` will return a non-zero value when called from within an animation block.
|
|
// That allows me to attach CAAnimation with the correct duration (if necessary).
|
|
let animationDuration = UIView.inheritedAnimationDuration
|
|
let maskRect = backgroundView.convert(cropFrameView.frame, from: self)
|
|
backgroundView.setMaskRect(maskRect, animationDuration: animationDuration)
|
|
updateCornerSize()
|
|
}
|
|
|
|
func setState(_ state: State, animated: Bool, completion: ((Bool) -> Void)? = nil) {
|
|
let cropFrameAlpha: CGFloat = state == .initial ? 0 : 1
|
|
let gridLinesAlpha: CGFloat = state == .resizing ? 1 : 0
|
|
let backgroundStyle = CropView.backgroundStyle(forState: state)
|
|
let layoutBlock = {
|
|
self.cropFrameView.alpha = cropFrameAlpha
|
|
self.verticalGridLines.forEach { $0.alpha = gridLinesAlpha }
|
|
self.horizontalGridLines.forEach { $0.alpha = gridLinesAlpha }
|
|
self.backgroundView.style = backgroundStyle
|
|
}
|
|
if animated {
|
|
UIView.animate(withDuration: 0.15, animations: layoutBlock, completion: completion)
|
|
} else {
|
|
layoutBlock()
|
|
completion?(true)
|
|
}
|
|
}
|
|
|
|
private class func backgroundStyle(forState state: State) -> CropBackgroundView.Style {
|
|
switch state {
|
|
case .initial: return .blackout
|
|
case .normal: return .blur
|
|
case .resizing: return .darkening
|
|
}
|
|
}
|
|
|
|
private func updateCornerSize() {
|
|
guard cropFrameView.width > 0, cropFrameView.height > 0 else { return }
|
|
|
|
self.cornerSize = CGSize(width: min(cropFrameView.width * 0.5, CropView.desiredCornerSize),
|
|
height: min(cropFrameView.height * 0.5, CropView.desiredCornerSize))
|
|
cropCornerViews.forEach { $0.size = cornerSize }
|
|
}
|
|
}
|