376 lines
14 KiB
Swift
376 lines
14 KiB
Swift
//
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
|
|
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
|
|
}()
|
|
|
|
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 }
|
|
}
|
|
}
|
|
|
|
private extension UIBezierPath {
|
|
func addRegion(withPoints points: [CGPoint]) {
|
|
guard let first = points.first else {
|
|
owsFailDebug("No points.")
|
|
return
|
|
}
|
|
move(to: first)
|
|
for point in points.dropFirst() {
|
|
addLine(to: point)
|
|
}
|
|
addLine(to: first)
|
|
}
|
|
}
|