TM-SGNL-iOS/SignalUI/ImageEditor/ImageEditorCropView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

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)
}
}