TM-SGNL-iOS/SignalUI/UIKitExtensions/UIKit+Animations.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

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