import UIKit import UIKitRuntimeUtils @objc private class CALayerAnimationDelegate: NSObject, CAAnimationDelegate { private let keyPath: String? var completion: ((Bool) -> Void)? init(animation: CAAnimation, completion: ((Bool) -> Void)?) { if let animation = animation as? CABasicAnimation { self.keyPath = animation.keyPath } else { self.keyPath = nil } self.completion = completion super.init() } @objc func animationDidStop(_ anim: CAAnimation, finished flag: Bool) { if let anim = anim as? CABasicAnimation { if anim.keyPath != self.keyPath { return } } if let completion = self.completion { completion(flag) self.completion = nil } } } private let completionKey = "CAAnimationUtils_completion" public let kCAMediaTimingFunctionSpring = "CAAnimationUtilsSpringCurve" public let kCAMediaTimingFunctionCustomSpringPrefix = "CAAnimationUtilsSpringCustomCurve" public extension CAAnimation { var completion: ((Bool) -> Void)? { get { if let delegate = self.delegate as? CALayerAnimationDelegate { return delegate.completion } else { return nil } } set(value) { if let delegate = self.delegate as? CALayerAnimationDelegate { delegate.completion = value } else { self.delegate = CALayerAnimationDelegate(animation: self, completion: value) } } } } private func adjustFrameRate(animation: CAAnimation) { if #available(iOS 15.0, *) { let maxFps = Float(UIScreen.main.maximumFramesPerSecond) if maxFps > 61.0 { var preferredFps: Float = maxFps if let animation = animation as? CABasicAnimation { if animation.keyPath == "opacity" { preferredFps = 60.0 return } } animation.preferredFrameRateRange = CAFrameRateRange(minimum: 30.0, maximum: preferredFps, preferred: maxFps) } } } public extension CALayer { func makeAnimation(from: AnyObject?, to: AnyObject, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) -> CAAnimation { if timingFunction.hasPrefix(kCAMediaTimingFunctionCustomSpringPrefix) { let components = timingFunction.components(separatedBy: "_") let damping = Float(components[1]) ?? 100.0 let initialVelocity = Float(components[2]) ?? 0.0 let animation = CASpringAnimation(keyPath: keyPath) animation.fromValue = from animation.toValue = to animation.isRemovedOnCompletion = removeOnCompletion animation.fillMode = .forwards if let completion = completion { animation.delegate = CALayerAnimationDelegate(animation: animation, completion: completion) } animation.damping = CGFloat(damping) animation.initialVelocity = CGFloat(initialVelocity) animation.mass = 5.0 animation.stiffness = 900.0 animation.duration = animation.settlingDuration animation.timingFunction = CAMediaTimingFunction.init(name: .linear) let k = Float(UIView.animationDurationFactor()) var speed: Float = 1.0 if k != 0 && k != 1 { speed = Float(1.0) / k } animation.speed = speed * Float(animation.duration / duration) animation.isAdditive = additive if !delay.isZero { animation.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) + delay * UIView.animationDurationFactor() animation.fillMode = .both } adjustFrameRate(animation: animation) return animation } else if timingFunction == kCAMediaTimingFunctionSpring { if duration == 0.5 { let animation = makeSpringAnimation(keyPath) animation.fromValue = from animation.toValue = to animation.isRemovedOnCompletion = removeOnCompletion animation.fillMode = .forwards if let completion = completion { animation.delegate = CALayerAnimationDelegate(animation: animation, completion: completion) } let k = Float(UIView.animationDurationFactor()) var speed: Float = 1.0 if k != 0 && k != 1 { speed = Float(1.0) / k } animation.speed = speed * Float(animation.duration / duration) animation.isAdditive = additive if !delay.isZero { animation.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) + delay * UIView.animationDurationFactor() animation.fillMode = .both } adjustFrameRate(animation: animation) return animation } else { let k = Float(UIView.animationDurationFactor()) var speed: Float = 1.0 if k != 0 && k != 1 { speed = Float(1.0) / k } let animation = CABasicAnimation(keyPath: keyPath) animation.fromValue = from animation.toValue = to animation.duration = duration animation.timingFunction = CAMediaTimingFunction(controlPoints: 0.380, 0.700, 0.125, 1.000) animation.isRemovedOnCompletion = removeOnCompletion animation.fillMode = .forwards animation.speed = speed animation.isAdditive = additive if let completion = completion { animation.delegate = CALayerAnimationDelegate(animation: animation, completion: completion) } if !delay.isZero { animation.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) + delay * UIView.animationDurationFactor() animation.fillMode = .both } adjustFrameRate(animation: animation) return animation } } else { let k = Float(UIView.animationDurationFactor()) var speed: Float = 1.0 if k != 0 && k != 1 { speed = Float(1.0) / k } let animation = CABasicAnimation(keyPath: keyPath) animation.fromValue = from animation.toValue = to animation.duration = duration if let mediaTimingFunction = mediaTimingFunction { animation.timingFunction = mediaTimingFunction } else { switch timingFunction { case CAMediaTimingFunctionName.linear.rawValue, CAMediaTimingFunctionName.easeIn.rawValue, CAMediaTimingFunctionName.easeOut.rawValue, CAMediaTimingFunctionName.easeInEaseOut.rawValue, CAMediaTimingFunctionName.default.rawValue: animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName(rawValue: timingFunction)) default: animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) } } animation.isRemovedOnCompletion = removeOnCompletion animation.fillMode = .forwards animation.speed = speed animation.isAdditive = additive if let completion = completion { animation.delegate = CALayerAnimationDelegate(animation: animation, completion: completion) } if !delay.isZero { animation.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) + delay * UIView.animationDurationFactor() animation.fillMode = .both } adjustFrameRate(animation: animation) return animation } } func animate(from: AnyObject?, to: AnyObject, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil, key: String? = nil) { let animation = self.makeAnimation(from: from, to: to, keyPath: keyPath, timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) self.add(animation, forKey: key ?? (additive ? nil : keyPath)) } func animateGroup(_ animations: [CAAnimation], key: String, completion: ((Bool) -> Void)? = nil) { let animationGroup = CAAnimationGroup() var timeOffset = 0.0 for animation in animations { animation.beginTime = self.convertTime(animation.beginTime, from: nil) + timeOffset timeOffset += animation.duration / Double(animation.speed) } animationGroup.animations = animations animationGroup.duration = timeOffset if let completion = completion { animationGroup.delegate = CALayerAnimationDelegate(animation: animationGroup, completion: completion) } adjustFrameRate(animation: animationGroup) self.add(animationGroup, forKey: key) } func animateKeyframes(values: [AnyObject], keyTimes: [NSNumber]? = nil, duration: Double, keyPath: String, timingFunction: String = CAMediaTimingFunctionName.linear.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { let k = Float(UIView.animationDurationFactor()) var speed: Float = 1.0 if k != 0 && k != 1 { speed = Float(1.0) / k } let animation = CAKeyframeAnimation(keyPath: keyPath) animation.values = values var effectiveKeyTimes: [NSNumber] = [] if let keyTimes { effectiveKeyTimes = keyTimes } else { for i in 0 ..< values.count { if i == 0 { effectiveKeyTimes.append(0.0) } else if i == values.count - 1 { effectiveKeyTimes.append(1.0) } else { effectiveKeyTimes.append((Double(i) / Double(values.count - 1)) as NSNumber) } } } animation.keyTimes = effectiveKeyTimes animation.speed = speed animation.duration = duration animation.isAdditive = additive animation.calculationMode = .linear if let mediaTimingFunction = mediaTimingFunction { animation.timingFunction = mediaTimingFunction } else { animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName(rawValue: timingFunction)) } animation.isRemovedOnCompletion = removeOnCompletion if let completion = completion { animation.delegate = CALayerAnimationDelegate(animation: animation, completion: completion) } adjustFrameRate(animation: animation) self.add(animation, forKey: keyPath) } func springAnimation(from: AnyObject, to: AnyObject, keyPath: String, duration: Double, delay: Double = 0.0, initialVelocity: CGFloat = 0.0, damping: CGFloat = 88.0, removeOnCompletion: Bool = true, additive: Bool = false) -> CABasicAnimation { let animation: CABasicAnimation if #available(iOS 9.0, *) { animation = makeSpringBounceAnimation(keyPath, initialVelocity, damping) } else { animation = makeSpringAnimation(keyPath) } animation.fromValue = from animation.toValue = to animation.isRemovedOnCompletion = removeOnCompletion animation.fillMode = .forwards let k = Float(UIView.animationDurationFactor()) var speed: Float = 1.0 if k != 0 && k != 1 { speed = Float(1.0) / k } if !delay.isZero { animation.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) + delay * UIView.animationDurationFactor() animation.fillMode = .both } animation.speed = speed * Float(animation.duration / duration) animation.isAdditive = additive adjustFrameRate(animation: animation) return animation } func animateSpring(from: AnyObject, to: AnyObject, keyPath: String, duration: Double, delay: Double = 0.0, initialVelocity: CGFloat = 0.0, damping: CGFloat = 88.0, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { let animation: CABasicAnimation if #available(iOS 9.0, *) { animation = makeSpringBounceAnimation(keyPath, initialVelocity, damping) } else { animation = makeSpringAnimation(keyPath) } animation.fromValue = from animation.toValue = to animation.isRemovedOnCompletion = removeOnCompletion animation.fillMode = .forwards if let completion = completion { animation.delegate = CALayerAnimationDelegate(animation: animation, completion: completion) } let k = Float(UIView.animationDurationFactor()) var speed: Float = 1.0 if k != 0 && k != 1 { speed = Float(1.0) / k } if !delay.isZero { animation.beginTime = self.convertTime(CACurrentMediaTime(), from: nil) + delay * UIView.animationDurationFactor() animation.fillMode = .both } animation.speed = speed * Float(animation.duration / duration) animation.isAdditive = additive adjustFrameRate(animation: animation) self.add(animation, forKey: additive ? nil : keyPath) } func animateAdditive(from: NSValue, to: NSValue, keyPath: String, key: String, timingFunction: String, mediaTimingFunction: CAMediaTimingFunction? = nil, duration: Double, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { let k = Float(UIView.animationDurationFactor()) var speed: Float = 1.0 if k != 0 && k != 1 { speed = Float(1.0) / k } let animation = CABasicAnimation(keyPath: keyPath) animation.fromValue = from animation.toValue = to animation.duration = duration if let mediaTimingFunction = mediaTimingFunction { animation.timingFunction = mediaTimingFunction } else { animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName(rawValue: timingFunction)) } animation.isRemovedOnCompletion = removeOnCompletion animation.fillMode = .forwards animation.speed = speed animation.isAdditive = true if let completion = completion { animation.delegate = CALayerAnimationDelegate(animation: animation, completion: completion) } adjustFrameRate(animation: animation) self.add(animation, forKey: key) } func animateAlpha(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> ())? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "opacity", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, completion: completion) } func animateScale(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "transform.scale", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } func animateSublayerScale(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "sublayerTransform.scale", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } func animateScaleX(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "transform.scale.x", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, completion: completion) } func animateScaleY(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "transform.scale.y", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, completion: completion) } func animateRotation(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "transform.rotation.z", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, completion: completion) } func animatePosition(from: CGPoint, to: CGPoint, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, force: Bool = false, completion: ((Bool) -> Void)? = nil) { if from == to && !force { if let completion = completion { completion(true) } return } self.animate(from: NSValue(cgPoint: from), to: NSValue(cgPoint: to), keyPath: "position", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } func animateAnchorPoint(from: CGPoint, to: CGPoint, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, force: Bool = false, completion: ((Bool) -> Void)? = nil) { if from == to && !force { if let completion = completion { completion(true) } return } self.animate(from: NSValue(cgPoint: from), to: NSValue(cgPoint: to), keyPath: "anchorPoint", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } func animateBounds(from: CGRect, to: CGRect, duration: Double, delay: Double = 0.0, timingFunction: String, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, force: Bool = false, completion: ((Bool) -> Void)? = nil) { if from == to && !force { if let completion = completion { completion(true) } return } self.animate(from: NSValue(cgRect: from), to: NSValue(cgRect: to), keyPath: "bounds", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } func animateWidth(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, force: Bool = false, completion: ((Bool) -> Void)? = nil) { if from == to && !force { if let completion = completion { completion(true) } return } self.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "bounds.size.width", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } func animateHeight(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, force: Bool = false, completion: ((Bool) -> Void)? = nil) { if from == to && !force { if let completion = completion { completion(true) } return } self.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "bounds.size.height", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } func animateBoundsOriginXAdditive(from: CGFloat, to: CGFloat, duration: Double, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { self.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "bounds.origin.x", timingFunction: timingFunction, duration: duration, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: true, completion: completion) } func animateBoundsOriginYAdditive(from: CGFloat, to: CGFloat, duration: Double, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { self.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "bounds.origin.y", timingFunction: timingFunction, duration: duration, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: true, completion: completion) } func animateBoundsOriginXAdditive(from: CGFloat, to: CGFloat, duration: Double, mediaTimingFunction: CAMediaTimingFunction) { self.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "bounds.origin.x", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: duration, mediaTimingFunction: mediaTimingFunction, additive: true) } func animateBoundsOriginYAdditive(from: CGFloat, to: CGFloat, duration: Double, mediaTimingFunction: CAMediaTimingFunction) { self.animate(from: from as NSNumber, to: to as NSNumber, keyPath: "bounds.origin.y", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: duration, mediaTimingFunction: mediaTimingFunction, additive: true) } func animateShapeLineWidth(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "lineWidth", timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) } func animatePositionKeyframes(values: [CGPoint], duration: Double, removeOnCompletion: Bool = true, completion: ((Bool) -> Void)? = nil) { self.animateKeyframes(values: values.map { NSValue(cgPoint: $0) }, duration: duration, keyPath: "position") } func animateFrame(from: CGRect, to: CGRect, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, force: Bool = false, completion: ((Bool) -> Void)? = nil) { if from == to && !force { if let completion = completion { completion(true) } return } var interrupted = false var completedPosition = false var completedBounds = false let partialCompletion: () -> Void = { if interrupted || (completedPosition && completedBounds) { if let completion = completion { completion(!interrupted) } } } var fromPosition = CGPoint(x: from.midX, y: from.midY) var toPosition = CGPoint(x: to.midX, y: to.midY) var fromBounds = CGRect(origin: self.bounds.origin, size: from.size) var toBounds = CGRect(origin: self.bounds.origin, size: to.size) if additive { fromPosition.x = -(toPosition.x - fromPosition.x) fromPosition.y = -(toPosition.y - fromPosition.y) toPosition = CGPoint() fromBounds.size.width = -(toBounds.width - fromBounds.width) fromBounds.size.height = -(toBounds.height - fromBounds.height) toBounds = CGRect() } self.animatePosition(from: fromPosition, to: toPosition, duration: duration, delay: delay, timingFunction: timingFunction, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, force: force, completion: { value in if !value { interrupted = true } completedPosition = true partialCompletion() }) self.animateBounds(from: fromBounds, to: toBounds, duration: duration, delay: delay, timingFunction: timingFunction, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, force: force, completion: { value in if !value { interrupted = true } completedBounds = true partialCompletion() }) } func cancelAnimationsRecursive(key: String) { self.removeAnimation(forKey: key) if let sublayers = self.sublayers { for layer in sublayers { layer.cancelAnimationsRecursive(key: key) } } } }