A Declarative Animation Framework for iOS: Design and Implementation
The article introduces a lightweight, protocol‑oriented iOS animation framework that uses type erasure and declarative animators to replace nested callback code, providing simple factory methods, serial and parallel execution support, and clearer, more maintainable animation sequences.
In everyday iOS development, view animations are often written with nested callbacks, for example a fade‑in followed by a scale operation:
UIView.animate(withDuration: 3, animations: {
view.alpha = 1
}, completion: { _ in
UIView.animate(withDuration: 1) {
view.frame.size = CGSize(width: 200, height: 200)
}
})When many sequential animations are required, the code quickly turns into a “callback hell” that is hard to maintain.
Several third‑party animation libraries exist, such as Spring (lightweight Swift‑based spring animations), Hero (highly customizable view transitions), and TweenKit (simple tweening). All of them still require relatively verbose code for complex sequences.
The article proposes a lightweight, declarative solution that supports both serial and parallel animation execution using a custom protocol‑oriented design.
Animator protocol defines the basic lifecycle of an animation executor:
public protocol Animator {
associatedtype T
mutating func start(with view: UIView)
func pause()
func resume()
func stop()
}Specific animation parameters are described by a hierarchy of protocols. The base protocol supplies a duration property, while CAAnimationProtocol , ProPertyAnimationProtocol , CABaseAnimationProtocol , and CAKeyFrameAnimationProtocol add properties such as repeatCount , keyPath , keyTimes , and closures that generate animation values.
public protocol AnimationBaseProtocol {
var duration: TimeInterval { get }
}
protocol CAAnimationProtocol: AnimationBaseProtocol {
var repeatCount: Float { get }
var isRemovedOnCompletion: Bool { get }
var keyPath: String? { get }
var animationkey: String? { get }
}
protocol ProPertyAnimationProtocol: AnimationBaseProtocol {
var curve: UIView.AnimationCurve { get }
var fireAfterDelay: TimeInterval { get }
var closure: (UIView) -> Void { get }
}
protocol CABaseAnimationProtocol: CAAnimationProtocol {
var fromValue: Any { get }
var toValue: Any { get }
}
protocol CAKeyFrameAnimationProtocol: CAAnimationProtocol {
var keyTimes: [NSNumber]? { get }
var timingFunction: CAMediaTimingFunction? { get }
var valuesClosure: ((UIView) -> [Any]?) { get }
}Because the framework must store heterogeneous animators, type erasure is used. The article demonstrates this with a generic Stack class and an AnyStack wrapper that hides the concrete generic type.
class Stack
{
var items = [T]()
func push(item: T) { items.append(item) }
func pop() -> T? {
return items.isEmpty ? nil : items.removeLast()
}
}
class AnyStack {
private let pushImpl: (_ item: Any) -> Void
private let popImpl: () -> Any?
init
(_ stack: Stack
) {
pushImpl = { item in if let item = item as? T { stack.push(item: item) } }
popImpl = { stack.pop() }
}
func push(item: Any) { pushImpl(item) }
func pop() -> Any? { return popImpl() }
}
let stackArray: [AnyStack] = [AnyStack(stackOfString), AnyStack(stackOfInt)]With type erasure in place, the Animator extension provides convenient factory methods for common animations such as fadeIn and scale :
extension Animator {
public static func fadeIn(duration: TimeInterval = 0.25,
curve: UIView.AnimationCurve = .linear,
fireAfterDelay: TimeInterval = 0.0,
completion: (() -> Void)? = nil) -> AnyAnimator
{
let propertyAnimation = PropertyAnimation()
propertyAnimation.duration = duration
propertyAnimation.curve = curve
propertyAnimation.fireAfterDelay = fireAfterDelay
propertyAnimation.closure = { $0.alpha = 1 }
return Self.creatAnimator(with: propertyAnimation, completion: completion)
}
public static func scale(values: [NSNumber],
keyTimes: [NSNumber],
repeatCount: Float = 1.0,
duration: TimeInterval = 0.3,
completion: (() -> Void)? = nil) -> AnyAnimator
{
let animation = CAFrameKeyAnimation()
animation.keyTimes = keyTimes
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.valuesClosure = { _ in values }
animation.repeatCount = repeatCount
animation.isRemovedOnCompletion = true
animation.fillMode = .removed
animation.keyPath = "transform.scale"
animation.animationkey = "com.moyi.animation.scale.times"
animation.duration = duration
return AnyAnimator(CAKeyFrameAnimator(animation: animation, completion: completion))
}
public static func creatAnimator(with propertyAnimation: PropertyAnimation,
completion: (() -> Void)? = nil) -> AnyAnimator
{
return AnyAnimator(ViewPropertyAnimator(animation: propertyAnimation, completion: completion))
}
}The article also explains core‑animation concepts: CAAnimation subclasses ( CABasicAnimation , CAKeyframeAnimation , CAAnimationGroup ) and the importance of the keyPath property for KVC‑based property animation.
To manage sequences and parallel groups, an AnimationToken is introduced, and UIView is extended with methods that accept an array of AnyAnimator objects. The extensions handle the recursive start‑and‑completion logic for serial execution and a counter‑based approach for parallel execution.
extension EntityWrapper where This: UIView {
internal func performAnimations
(_ animators: [AnyAnimator
],
completionHandlers: [() -> Void]) {
guard !animators.isEmpty else {
completionHandlers.forEach { $0() }
return
}
var leftAnimations = animators
var anyAnimator = leftAnimations.removeFirst()
anyAnimator.start(with: this)
anyAnimator.append {
self.performAnimations(leftAnimations, completionHandlers: completionHandlers)
}
}
internal func performAnimationsParallelism
(_ animators: [AnyAnimator
],
completionHandlers: [() -> Void]) {
guard !animators.isEmpty else {
completionHandlers.forEach { $0() }
return
}
let animationCount = animators.count
var completionCount = 0
let animationCompletionHandler = {
completionCount += 1
if completionCount == animationCount {
completionHandlers.forEach { $0() }
}
}
for animator in animators {
animator.start(with: this)
animator.append { animationCompletionHandler() }
}
}
@discardableResult
private func animate
(_ animators: [AnyAnimator
]) -> AnimationToken
{
return AnimationToken(view: this, animators: animators, mode: .inSequence)
}
@discardableResult
public func animate
(_ animators: AnyAnimator
...) -> AnimationToken
{
return animate(animators)
}
@discardableResult
private func animate
(parallelism animators: [AnyAnimator
]) -> AnimationToken
{
return AnimationToken(view: this, animators: animators, mode: .parallelism)
}
@discardableResult
public func animate
(parallelism animators: AnyAnimator
...) -> AnimationToken
{
return animate(parallelism: animators)
}
}Finally, the author concludes that the presented framework offers a lightweight way to avoid deeply nested animation callbacks, making animation code clearer and easier to evolve.
References include articles on declarative animation in Swift, Apple’s Core Animation Programming Guide, and several community resources.
NetEase Cloud Music Tech Team
Official account of NetEase Cloud Music Tech Team
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.