385 lines
13 KiB
Swift
385 lines
13 KiB
Swift
//
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
|
|
public extension UIViewPropertyAnimator {
|
|
|
|
convenience init (
|
|
duration: TimeInterval,
|
|
springDamping: CGFloat,
|
|
springResponse: CGFloat,
|
|
initialVelocity velocity: CGVector = .zero
|
|
) {
|
|
let stiffness = pow(2 * .pi / springResponse, 2)
|
|
let damping = 4 * .pi * springDamping / springResponse
|
|
let timingParameters = UISpringTimingParameters(
|
|
mass: 1,
|
|
stiffness: stiffness,
|
|
damping: damping,
|
|
initialVelocity: velocity
|
|
)
|
|
self.init(duration: duration, timingParameters: timingParameters)
|
|
isUserInteractionEnabled = true
|
|
}
|
|
}
|
|
|
|
public extension UIView {
|
|
|
|
func animateDecelerationToVerticalEdge(
|
|
withDuration duration: TimeInterval,
|
|
velocity: CGPoint,
|
|
velocityThreshold: CGFloat = 500,
|
|
boundingRect: CGRect,
|
|
completion: ((Bool) -> Void)? = nil
|
|
) {
|
|
var velocity = velocity
|
|
if abs(velocity.x) < velocityThreshold { velocity.x = 0 }
|
|
if abs(velocity.y) < velocityThreshold { velocity.y = 0 }
|
|
|
|
let currentPosition = frame.origin
|
|
|
|
let referencePoint: CGPoint
|
|
if velocity != .zero {
|
|
// Calculate the time until we intersect with each edge with
|
|
// a constant velocity.
|
|
|
|
// time = (end position - start position) / velocity
|
|
|
|
let timeUntilVerticalEdge: CGFloat
|
|
if velocity.x > 0 {
|
|
timeUntilVerticalEdge = ((boundingRect.maxX - width) - currentPosition.x) / velocity.x
|
|
} else if velocity.x < 0 {
|
|
timeUntilVerticalEdge = (boundingRect.minX - currentPosition.x) / velocity.x
|
|
} else {
|
|
timeUntilVerticalEdge = .greatestFiniteMagnitude
|
|
}
|
|
|
|
let timeUntilHorizontalEdge: CGFloat
|
|
if velocity.y > 0 {
|
|
timeUntilHorizontalEdge = ((boundingRect.maxY - height) - currentPosition.y) / velocity.y
|
|
} else if velocity.y < 0 {
|
|
timeUntilHorizontalEdge = (boundingRect.minY - currentPosition.y) / velocity.y
|
|
} else {
|
|
timeUntilHorizontalEdge = .greatestFiniteMagnitude
|
|
}
|
|
|
|
// See which edge we intersect with first and calculate the position
|
|
// on the other axis when we reach that intersection point.
|
|
|
|
// end position = (time * velocity) + start position
|
|
|
|
let intersectPoint: CGPoint
|
|
if timeUntilHorizontalEdge > timeUntilVerticalEdge {
|
|
intersectPoint = CGPoint(
|
|
x: velocity.x > 0 ? (boundingRect.maxX - width) : boundingRect.minX,
|
|
y: (timeUntilVerticalEdge * velocity.y) + currentPosition.y
|
|
)
|
|
} else {
|
|
intersectPoint = CGPoint(
|
|
x: (timeUntilHorizontalEdge * velocity.x) + currentPosition.x,
|
|
y: velocity.y > 0 ? (boundingRect.maxY - height) : boundingRect.minY
|
|
)
|
|
}
|
|
|
|
referencePoint = intersectPoint
|
|
} else {
|
|
referencePoint = currentPosition
|
|
}
|
|
|
|
let destinationFrame = CGRect(origin: referencePoint, size: frame.size).pinnedToVerticalEdge(of: boundingRect)
|
|
let distance = destinationFrame.origin.distance(currentPosition)
|
|
|
|
UIView.animate(
|
|
withDuration: duration,
|
|
delay: 0,
|
|
usingSpringWithDamping: 1,
|
|
initialSpringVelocity: abs(velocity.length / distance),
|
|
options: .curveEaseOut,
|
|
animations: { self.frame = destinationFrame },
|
|
completion: completion
|
|
)
|
|
}
|
|
|
|
func setIsHidden(_ isHidden: Bool, animated: Bool, completion: ((Bool) -> Void)? = nil) {
|
|
setIsHidden(isHidden, withAnimationDuration: animated ? 0.2 : 0, completion: completion)
|
|
}
|
|
|
|
func setIsHidden(_ isHidden: Bool, withAnimationDuration duration: TimeInterval, completion: ((Bool) -> Void)? = nil) {
|
|
guard duration > 0, isHidden != self.isHidden else {
|
|
self.isHidden = isHidden
|
|
completion?(true)
|
|
return
|
|
}
|
|
|
|
let initialAlpha = alpha
|
|
if !isHidden && initialAlpha > 0 {
|
|
UIView.performWithoutAnimation {
|
|
self.alpha = 0
|
|
self.isHidden = false
|
|
}
|
|
}
|
|
|
|
UIView.animate(withDuration: duration,
|
|
animations: {
|
|
self.alpha = isHidden ? 0 : initialAlpha
|
|
},
|
|
completion: { finished in
|
|
guard finished else {
|
|
completion?(false)
|
|
return
|
|
}
|
|
self.isHidden = isHidden
|
|
self.alpha = initialAlpha
|
|
completion?(true)
|
|
})
|
|
}
|
|
}
|
|
|
|
public extension UIView.AnimationCurve {
|
|
|
|
var asAnimationOptions: UIView.AnimationOptions {
|
|
switch self {
|
|
case .easeInOut:
|
|
return .curveEaseInOut
|
|
case .easeIn:
|
|
return .curveEaseIn
|
|
case .easeOut:
|
|
return .curveEaseOut
|
|
case .linear:
|
|
return .curveLinear
|
|
@unknown default:
|
|
return .curveEaseInOut
|
|
}
|
|
}
|
|
}
|
|
|
|
public extension Optional where Wrapped == UIView.AnimationCurve {
|
|
|
|
var asAnimationOptions: UIView.AnimationOptions {
|
|
return (self ?? .easeInOut).asAnimationOptions
|
|
}
|
|
}
|
|
|
|
// MARK: - Corners
|
|
|
|
public extension UIView {
|
|
|
|
static func uiRectCorner(forOWSDirectionalRectCorner corner: OWSDirectionalRectCorner) -> UIRectCorner {
|
|
if corner == .allCorners {
|
|
return .allCorners
|
|
}
|
|
|
|
var result: UIRectCorner = []
|
|
let isRTL = CurrentAppContext().isRTL
|
|
|
|
if corner.contains(.topLeading) {
|
|
result.insert(isRTL ? .topRight : .topLeft)
|
|
}
|
|
if corner.contains(.topTrailing) {
|
|
result.insert(isRTL ? .topLeft : .topRight)
|
|
}
|
|
if corner.contains(.bottomTrailing) {
|
|
result.insert(isRTL ? .bottomLeft : .bottomRight)
|
|
}
|
|
if corner.contains(.bottomLeading) {
|
|
result.insert(isRTL ? .bottomRight : .bottomLeft)
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
public extension UIBezierPath {
|
|
/// Create a roundedRect path with two different corner radii.
|
|
///
|
|
/// - Parameters:
|
|
/// - rect: The outer bounds of the roundedRect.
|
|
/// - sharpCorners: The corners that should use `sharpCornerRadius`. The
|
|
/// other corners will use `wideCornerRadius`.
|
|
/// - sharpCornerRadius: The corner radius of `sharpCorners`.
|
|
/// - wideCornerRadius: The corner radius of non-`sharpCorners`.
|
|
///
|
|
static func roundedRect(
|
|
_ rect: CGRect,
|
|
sharpCorners: UIRectCorner,
|
|
sharpCornerRadius: CGFloat,
|
|
wideCornerRadius: CGFloat
|
|
) -> UIBezierPath {
|
|
|
|
return roundedRect(
|
|
rect,
|
|
sharpCorners: sharpCorners,
|
|
sharpCornerRadius: sharpCornerRadius,
|
|
wideCorners: .allCorners.subtracting(sharpCorners),
|
|
wideCornerRadius: wideCornerRadius
|
|
)
|
|
}
|
|
|
|
/// Create a roundedRect path with two different corner radii.
|
|
///
|
|
/// The behavior is undefined if `sharpCorners` and `wideCorners` overlap.
|
|
///
|
|
/// - Parameters:
|
|
/// - rect: The outer bounds of the roundedRect.
|
|
/// - sharpCorners: The corners that should use `sharpCornerRadius`.
|
|
/// - sharpCornerRadius: The corner radius of `sharpCorners`.
|
|
/// - wideCorners: The corners that should use `wideCornerRadius`.
|
|
/// - wideCornerRadius: The corner radius of `wideCorners`.
|
|
///
|
|
static func roundedRect(
|
|
_ rect: CGRect,
|
|
sharpCorners: UIRectCorner,
|
|
sharpCornerRadius: CGFloat,
|
|
wideCorners: UIRectCorner,
|
|
wideCornerRadius: CGFloat
|
|
) -> UIBezierPath {
|
|
|
|
assert(sharpCorners.isDisjoint(with: wideCorners))
|
|
|
|
func cornerRounding(forCorner corner: UIRectCorner) -> CGFloat {
|
|
if sharpCorners.contains(corner) {
|
|
return sharpCornerRadius
|
|
}
|
|
if wideCorners.contains(corner) {
|
|
return wideCornerRadius
|
|
}
|
|
return 0
|
|
}
|
|
|
|
return UIBezierPath.roundedRect(
|
|
rect,
|
|
topLeftRounding: cornerRounding(forCorner: .topLeft),
|
|
topRightRounding: cornerRounding(forCorner: .topRight),
|
|
bottomRightRounding: cornerRounding(forCorner: .bottomRight),
|
|
bottomLeftRounding: cornerRounding(forCorner: .bottomLeft)
|
|
)
|
|
}
|
|
|
|
static func roundedRect(
|
|
_ rect: CGRect,
|
|
topLeftRounding: CGFloat,
|
|
topRightRounding: CGFloat,
|
|
bottomRightRounding: CGFloat,
|
|
bottomLeftRounding: CGFloat
|
|
) -> UIBezierPath {
|
|
|
|
let topAngle = CGFloat.halfPi * 3
|
|
let rightAngle = CGFloat.halfPi * 0
|
|
let bottomAngle = CGFloat.halfPi * 1
|
|
let leftAngle = CGFloat.halfPi * 2
|
|
|
|
let bubbleLeft = rect.minX
|
|
let bubbleTop = rect.minY
|
|
let bubbleRight = rect.maxX
|
|
let bubbleBottom = rect.maxY
|
|
|
|
let bezierPath = UIBezierPath()
|
|
|
|
// starting just to the right of the top left corner and working clockwise
|
|
bezierPath.move(to: CGPoint(x: bubbleLeft + topLeftRounding, y: bubbleTop))
|
|
|
|
// top right corner
|
|
bezierPath.addArc(
|
|
withCenter: CGPoint(x: bubbleRight - topRightRounding,
|
|
y: bubbleTop + topRightRounding),
|
|
radius: topRightRounding,
|
|
startAngle: topAngle,
|
|
endAngle: rightAngle,
|
|
clockwise: true
|
|
)
|
|
|
|
// bottom right corner
|
|
bezierPath.addArc(
|
|
withCenter: CGPoint(x: bubbleRight - bottomRightRounding,
|
|
y: bubbleBottom - bottomRightRounding),
|
|
radius: bottomRightRounding,
|
|
startAngle: rightAngle,
|
|
endAngle: bottomAngle,
|
|
clockwise: true
|
|
)
|
|
|
|
// bottom left corner
|
|
bezierPath.addArc(
|
|
withCenter: CGPoint(x: bubbleLeft + bottomLeftRounding,
|
|
y: bubbleBottom - bottomLeftRounding),
|
|
radius: bottomLeftRounding,
|
|
startAngle: bottomAngle,
|
|
endAngle: leftAngle,
|
|
clockwise: true
|
|
)
|
|
|
|
// top left corner
|
|
bezierPath.addArc(
|
|
withCenter: CGPoint(x: bubbleLeft + topLeftRounding,
|
|
y: bubbleTop + topLeftRounding),
|
|
radius: topLeftRounding,
|
|
startAngle: leftAngle,
|
|
endAngle: topAngle,
|
|
clockwise: true
|
|
)
|
|
|
|
return bezierPath
|
|
}
|
|
}
|
|
|
|
// MARK: CoreAnimation
|
|
|
|
private class CALayerDelegateNoAnimations: NSObject, CALayerDelegate {
|
|
/* If defined, called by the default implementation of the
|
|
* -actionForKey: method. Should return an object implementing the
|
|
* CAAction protocol. May return 'nil' if the delegate doesn't specify
|
|
* a behavior for the current event. Returning the null object (i.e.
|
|
* '[NSNull null]') explicitly forces no further search. (I.e. the
|
|
* +defaultActionForKey: method will not be called.) */
|
|
func action(for layer: CALayer, forKey event: String) -> CAAction? {
|
|
NSNull()
|
|
}
|
|
}
|
|
|
|
extension CALayer {
|
|
|
|
private static let delegateNoAnimations = CALayerDelegateNoAnimations()
|
|
|
|
public func disableAnimationsWithDelegate() {
|
|
owsAssertDebug(self.delegate == nil)
|
|
|
|
self.delegate = Self.delegateNoAnimations
|
|
}
|
|
}
|
|
|
|
public extension CGAffineTransform {
|
|
static func translate(_ point: CGPoint) -> CGAffineTransform {
|
|
CGAffineTransform(translationX: point.x, y: point.y)
|
|
}
|
|
|
|
static func scale(_ scaling: CGFloat) -> CGAffineTransform {
|
|
CGAffineTransform(scaleX: scaling, y: scaling)
|
|
}
|
|
|
|
static func rotate(_ angleRadians: CGFloat) -> CGAffineTransform {
|
|
CGAffineTransform(rotationAngle: angleRadians)
|
|
}
|
|
|
|
func translate(_ point: CGPoint) -> CGAffineTransform {
|
|
translatedBy(x: point.x, y: point.y)
|
|
}
|
|
|
|
func scale(_ scaling: CGFloat) -> CGAffineTransform {
|
|
scaledBy(x: scaling, y: scaling)
|
|
}
|
|
|
|
func rotate(_ angleRadians: CGFloat) -> CGAffineTransform {
|
|
rotated(by: angleRadians)
|
|
}
|
|
}
|
|
|
|
public extension CACornerMask {
|
|
static let top: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
static let bottom: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
|
static let left: CACornerMask = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
|
static let right: CACornerMask = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
|
|
|
static let all: CACornerMask = top.union(bottom)
|
|
}
|