axiom-swiftui-animation-ref by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-animation-ref关于 SwiftUI 动画系统的全面指南,从基础概念到高级技术。本技能涵盖 Animatable 协议、iOS 26 的 @Animatable 宏、动画类型以及 Transaction 系统。
核心原则 SwiftUI 中的动画是基于 VectorArithmetic 协议,随时间进行的数学插值。理解这一基础将释放 SwiftUI 声明式动画系统的全部威力。
动画是在起始状态和结束状态之间生成中间值的过程。
.opacity(0) → .opacity(1)
当此动画运行时,SwiftUI 计算中间值:
0.0 → 0.02 → 0.05 → 0.1 → 0.25 → 0.4 → 0.6 → 0.8 → 1.0
值如何分布
SwiftUI 要求动画数据遵循 VectorArithmetic —— 提供减法、缩放、加法和零值。这使得 SwiftUI 能够在任意两个值之间进行插值。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
内置遵循类型:CGFloat、Double、Float、Angle(1D)、CGPoint、CGSize(2D)、CGRect(4D)。
关键见解 向量算术抽象了维度。SwiftUI 使用单一的泛型实现来动画化所有这些类型。
Int 不遵循 VectorArithmetic —— 在 3 和 4 之间不存在分数中间值。SwiftUI 只会直接切换值。
解决方案:使用 Float/Double 并以 Int 形式显示:
@State private var count: Float = 0
// ...
Text("\(Int(count))")
.animation(.spring, value: count)
可动画属性在概念上有两个值:
示例
.scaleEffect(selected ? 1.5 : 1.0)
当 selected 变为 true 时:
1.51.0 → 1.1 → 1.2 → 1.3 → 1.4 → 1.5 插值Animatable 协议允许视图通过定义哪些数据应该被插值来动画化其属性。
protocol Animatable {
associatedtype AnimatableData: VectorArithmetic
var animatableData: AnimatableData { get set }
}
SwiftUI 为任何遵循此协议的视图构建一个可动画属性。
许多 SwiftUI 修饰符遵循 Animatable:
.scaleEffect() —— 动画化缩放变换.rotationEffect() —— 动画化旋转.offset() —— 动画化位置偏移.opacity() —— 动画化透明度.blur() —— 动画化模糊半径.shadow() —— 动画化阴影属性Circle、Rectangle、RoundedRectangleCapsule、Ellipse、PathShape 实现当动画化多个属性时,使用 AnimatablePair 来组合向量。例如,scaleEffect 通过 AnimatablePair<CGSize.AnimatableData, UnitPoint.AnimatableData> 将 CGSize(2D)和 UnitPoint(2D)组合成一个 4D 向量。通过 .first 和 .second 访问组件。@Animatable 宏(iOS 26+)完全消除了这种样板代码。
struct AnimatableNumberView: View, Animatable {
var number: Double
var animatableData: Double {
get { number }
set { number = newValue }
}
var body: some View {
Text("\(Int(number))")
.font(.largeTitle)
}
}
// 用法
AnimatableNumberView(number: value)
.animation(.spring, value: value)
工作原理
number 从 0 变为 100bodynumber 值:0 → 5 → 15 → 30 → 55 → 80 → 100自定义 Animatable 遵循是昂贵的 —— SwiftUI 在主线程上为每一帧调用 body。内置效果(.scaleEffect()、.opacity())在主线程外运行且不调用 body。仅当内置修饰符无法实现效果时才使用自定义遵循(例如,动画化一个每帧重新定位子视图的自定义 Layout)。
@Animatable 宏消除了手动遵循 Animatable 协议的样板代码。
在 iOS 26 之前,你必须:
AnimatableanimatableData 的 getter 和 setterAnimatablePairiOS 26+,你只需添加 @Animatable:
@MainActor
@Animatable
struct MyView: View {
var scale: CGFloat
var opacity: Double
var body: some View {
// ...
}
}
该宏自动:
Animatable 遵循animatableDataAnimatablePair 处理多维数据struct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
var drawingDirection: Bool // 不希望动画化这个
// 繁琐的手动 animatableData 声明
var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>,
AnimatablePair<Double, AnimatablePair<CGFloat, CGFloat>>> {
get {
AnimatablePair(
AnimatablePair(startPoint.x, startPoint.y),
AnimatablePair(elevation, AnimatablePair(endPoint.x, endPoint.y))
)
}
set {
startPoint = CGPoint(x: newValue.first.first, y: newValue.first.second)
elevation = newValue.second.first
endPoint = CGPoint(x: newValue.second.second.first, y: newValue.second.second.second)
}
}
func path(in rect: CGRect) -> Path {
// 绘图代码
}
}
@Animatable
struct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
@AnimatableIgnored
var drawingDirection: Bool // 从动画中排除
func path(in rect: CGRect) -> Path {
// 绘图代码
}
}
代码行数:20 → 12(减少 40%)
使用 @AnimatableIgnored 将属性从动画中排除。
@MainActor
@Animatable
struct ProgressView: View {
var progress: Double // 可动画
var totalItems: Int // 可动画(如果是 Float,如果是 Int 则不可动画)
@AnimatableIgnored
var title: String // 不可动画
@AnimatableIgnored
var startTime: Date // 不可动画
@AnimatableIgnored
var debugEnabled: Bool // 不可动画
var body: some View {
VStack {
Text(title)
ProgressBar(value: progress)
if debugEnabled {
Text("Started: \(startTime.formatted())")
}
}
}
}
@Animatable 适用于任何数字显示 —— 股票价格、心率、分数、计时器、进度条:
@MainActor
@Animatable
struct AnimatedValueView: View {
var value: Double
var changePercent: Double
@AnimatableIgnored
var label: String
var body: some View {
VStack(alignment: .trailing) {
Text("\(value, format: .number.precision(.fractionLength(2)))")
.font(.title)
Text("\(changePercent > 0 ? "+" : "")\(changePercent, format: .percent)")
.foregroundStyle(changePercent > 0 ? .green : .red)
}
}
}
// 用法
AnimatedValueView(value: currentPrice, changePercent: 0.025, label: "Price")
.animation(.spring(duration: 0.8), value: currentPrice)
时序曲线动画使用贝塞尔曲线来控制动画随时间变化的速度。
.animation(.linear) // 恒定速度
.animation(.easeIn) // 慢开始,快结束
.animation(.easeOut) // 快开始,慢结束
.animation(.easeInOut) // 慢开始和结束,中间快
let customCurve = UnitCurve(
startControlPoint: CGPoint(x: 0.2, y: 0),
endControlPoint: CGPoint(x: 0.8, y: 1)
)
.animation(.timingCurve(customCurve, duration: 0.5))
所有时序曲线动画都接受一个可选的持续时间:
.animation(.easeInOut(duration: 0.3))
.animation(.linear(duration: 1.0))
默认值:0.35 秒
弹性动画使用物理模拟来创建自然、有机的运动。
.animation(.smooth) // 无弹跳(自 iOS 17 起默认)
.animation(.snappy) // 少量弹跳
.animation(.bouncy) // 较大弹跳
.animation(.spring(duration: 0.6, bounce: 0.3))
参数
duration —— 感知的动画持续时间bounce —— 弹跳量(0 = 无弹跳,1 = 非常有弹性)比传统的弹性参数(质量、刚度、阻尼)直观得多。
修改基础动画以创建复杂效果。
.animation(.spring.delay(0.5))
在开始动画前等待 0.5 秒。
.animation(.easeInOut.repeatCount(3, autoreverses: true))
.animation(.linear.repeatForever(autoreverses: false))
重复动画多次或无限次。
.animation(.spring.speed(2.0)) // 2 倍速
.animation(.spring.speed(0.5)) // 0.5 倍速
乘以动画速度。
在 iOS 17 之前
withAnimation {
// 默认使用时序曲线
}
iOS 17+
withAnimation {
// 默认使用 .smooth 弹性动画
}
变更原因:弹性动画感觉更自然,并且在被打断时能保持速度。
建议:拥抱弹性动画。它们让你的 UI 感觉更响应和精致。
触发动画的最常见方式。
Button("放大") {
withAnimation(.spring) {
scale = 1.5
}
}
工作原理
withAnimation 打开一个事务withAnimation(.spring(duration: 0.6, bounce: 0.4)) {
isExpanded.toggle()
}
withAnimation(nil) {
// 更改立即发生,无动画
resetState()
}
将动画应用于视图内的特定值。
Circle()
.fill(isActive ? .blue : .gray)
.animation(.spring, value: isActive)
工作原理:仅当 isActive 改变时应用动画。其他状态改变不会触发此动画。
Circle()
.scaleEffect(scale)
.animation(.bouncy, value: scale)
.opacity(opacity)
.animation(.easeInOut, value: opacity)
不同属性使用不同的动画。
将动画范围限定在特定的可动画属性上。
struct AvatarView: View {
var selected: Bool
var body: some View {
Image("avatar")
.scaleEffect(selected ? 1.5 : 1.0)
.animation(.spring, value: selected)
// ⚠️ 如果图片在 selected 改变时也发生变化,
// 图片过渡也会被动画化(意外的)
}
}
struct AvatarView: View {
var selected: Bool
var body: some View {
Image("avatar")
.animation(.spring, value: selected) {
$0.scaleEffect(selected ? 1.5 : 1.0)
}
// ✅ 只有 scaleEffect 被动画化,图片过渡不会
}
}
工作原理
定义自定义的 TransactionKey 类型,通过事务系统传播上下文。使用 withTransaction 设置值,并使用 .transaction 修饰符读取它们。这使得能够根据状态改变是如何触发的(点击 vs 编程方式)来应用不同的动画。
实现你自己的动画算法。
protocol CustomAnimation {
// 计算当前值
func animate<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> V?
// 可选:此动画是否应与前一个合并?
func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool
// 可选:当前速度
func velocity<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: AnimationContext<V>
) -> V?
}
struct LinearAnimation: CustomAnimation {
let duration: TimeInterval
func animate<V: VectorArithmetic>(
value: V, // 增量向量:目标值 - 当前值
time: TimeInterval,
context: inout AnimationContext<V>
) -> V? {
if time >= duration { return nil }
return value.scaled(by: time / duration)
}
}
关键理解:value 是增量向量(目标值 - 当前值),而不是目标值。完成后返回 nil。SwiftUI 会自动将缩放后的增量添加到当前值。
当新动画在前一个动画完成前开始时会发生什么?
func shouldMerge(...) -> Bool {
return false // 默认实现
}
行为:两个动画一起运行,结果是相加的。
示例
func shouldMerge(...) -> Bool {
return true // 弹性动画重写此方法
}
行为:新动画合并前一个动画的状态,保留速度。
示例
为什么弹性动画感觉更自然:它们在被打断时保留了动量。
循环遍历一系列阶段,在每个阶段应用不同的修饰符。每个阶段过渡都是独立动画的。
PhaseAnimator([false, true]) { phase in
Image(systemName: "star.fill")
.scaleEffect(phase ? 1.5 : 1.0)
.opacity(phase ? 1.0 : 0.5)
.rotationEffect(.degrees(phase ? 360 : 0))
} animation: { phase in
phase ? .spring(duration: 0.8, bounce: 0.3) : .easeInOut(duration: 0.4)
}
工作原理:从第一阶段开始,动画到第二阶段,然后循环。animation 闭包返回用于过渡进入该阶段的动画。阶段可以是任何 Equatable 类型 —— 对于复杂的多步骤序列使用枚举:
enum PulsePhase: CaseIterable { case idle, expand, contract }
PhaseAnimator(PulsePhase.allCases) { phase in
Circle()
.scaleEffect(phase == .expand ? 1.3 : phase == .contract ? 0.9 : 1.0)
}
触发器:添加 trigger 参数,以便仅在某个值改变时运行动画(而不是持续循环)。
为基于时间线的精确动画提供每个属性的关键帧轨道。比 PhaseAnimator 控制更精细。
struct AnimationValues {
var scale: Double = 1.0
var rotation: Angle = .zero
var yOffset: Double = 0
}
KeyframeAnimator(initialValue: AnimationValues()) { values in
Image(systemName: "heart.fill")
.scaleEffect(values.scale)
.rotationEffect(values.rotation)
.offset(y: values.yOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.3)
SpringKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(15), duration: 0.15)
LinearKeyframe(.degrees(-15), duration: 0.3)
LinearKeyframe(.zero, duration: 0.15)
}
KeyframeTrack(\.yOffset) {
CubicKeyframe(-20, duration: 0.3)
CubicKeyframe(0, duration: 0.3)
}
}
关键帧类型:LinearKeyframe(恒定速度)、SpringKeyframe(弹性物理)、CubicKeyframe(贝塞尔曲线)、MoveKeyframe(瞬时跳转,无插值)。
与 PhaseAnimator 对比:对于简单的状态循环使用 PhaseAnimator。当不同属性需要独立的时间安排时使用 KeyframeAnimator。
定义视图在插入/移除视图层次结构时如何动画化。
if showDetail {
DetailView()
.transition(.slide) // 滑入/滑出
.transition(.scale.combined(with: .opacity)) // 组合过渡
.transition(.move(edge: .bottom)) // 从边缘移动
.transition(.asymmetric( // 不同的进入/退出
insertion: .scale.combined(with: .opacity),
removal: .opacity
))
}
需要动画上下文 —— 将状态改变包装在 withAnimation 中或使用 .animation() 修饰符。没有动画,视图会立即出现/消失。
平滑地动画化视图在层次结构中两个位置之间的帧。通常用于英雄过渡和共享元素动画。
@Namespace private var animation
// 源
if !isExpanded {
RoundedRectangle(cornerRadius: 10)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 100, height: 100)
}
// 目标
if isExpanded {
RoundedRectangle(cornerRadius: 20)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 300, height: 400)
}
关键规则:相同的 id + 相同的 Namespace = 匹配对。在任何给定时间,只有一个具有给定 ID 的视图应该是 isSource: true(默认)。将状态改变包装在 withAnimation 中以实现平滑插值。
动画化视图内文本和符号内容的变化(iOS 16+)。
Text(value, format: .number)
.contentTransition(.numericText(countsDown: value < previous))
Image(systemName: isFavorite ? "heart.fill" : "heart")
.contentTransition(.symbolEffect(.replace))
iOS 18 引入了缩放过渡,其中被点击的单元格变形为进入的视图。此过渡是持续交互式的 —— 用户可以在过渡开始期间或之后抓取并拖动视图。
关键优势 在应用程序中从大单元格过渡的部分,缩放过渡通过在整个过渡过程中保持相同的 UI 元素在屏幕上来增加视觉连续性。
采用缩放过渡的两个步骤:
NavigationLink {
BraceletEditor(bracelet)
.navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
} label: {
BraceletPreview(bracelet)
}
BraceletPreview(bracelet)
.matchedTransitionSource(id: bracelet.id, in: namespace)
struct BraceletListView: View {
@Namespace private var braceletList
let bracelets: [Bracelet]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
ForEach(bracelets) { bracelet in
NavigationLink {
BraceletEditor(bracelet: bracelet)
.navigationTransition(
.zoom(sourceID: bracelet.id, in: braceletList)
)
} label: {
BraceletPreview(bracelet: bracelet)
}
.matchedTransitionSource(id: bracelet.id, in: braceletList)
}
}
}
}
}
}
在推送的视图控制器上设置 preferredTransition = .zoom { context in ... }。闭包返回源视图,并且在放大和缩小时都会被调用 —— 捕获一个稳定的标识符(模型对象),而不是直接捕获视图。
缩放过渡也适用于 fullScreenCover 和 sheet:
.fullScreenCover(item: $selectedBracelet) { bracelet in
BraceletEditor(bracelet: bracelet)
.navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
}
.matchedTransitionSource(id: bracelet.id, in: namespace) { source in
source.cornerRadius(8.0).shadow(radius: 4)
}
推送过渡不能被取消 —— 当被打断时,它们会转换为弹出过渡。视图控制器总是会到达 Appeared 状态。不要防止重叠的过渡;让系统处理它们。
iOS 18 允许使用 SwiftUI Animation 类型来动画化 UIKit 和 AppKit 视图。这提供了对全套 SwiftUI 动画的访问,包括自定义动画。
// 旧方式
UIView.animate(withDuration: 0.5, delay: 0,
usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5) {
bead.center = endOfBracelet
}
// 新方式:使用 SwiftUI Animation 类型
UIView.animate(.spring(duration: 0.5)) {
bead.center = endOfBracelet
}
所有 SwiftUI 动画都有效:.linear、.easeIn/Out、.spring、.smooth、.snappy、.bouncy、.repeatForever() 以及自定义动画。
架构说明:与旧的 UIKit API 不同,不会生成 CAAnimation —— 呈现值是直接动画化的。
当在 SwiftUI 中包装 UIKit 视图时,动画不会自动桥接:
struct BeadBoxWrapper: UIViewRepresentable {
@Binding var isOpen: Bool
func updateUIView(_ box: BeadBox, context: Context) {
// ❌ 绑定上的动画不影响 UIKit
box.lid.center.y = isOpen ? -100 : 100
}
}
// 用法
BeadBoxWrapper(isOpen: $isOpen)
.animation(.spring, value: isOpen) // 对 UIKit 视图无影响
使用 context.animate() 来桥接 SwiftUI 动画:
struct BeadBoxWrapper: UIViewRepresentable {
@Binding var isOpen: Bool
func makeUIView(context: Context) -> BeadBox {
BeadBox()
}
func updateUIView(_ box: BeadBox, context: Context) {
// ✅ 将动画从 Transaction 桥接到 UIKit
context.animate {
box.lid.center.y = isOpen ? -100 : 100
}
}
}
Transaction 中context.animate() 读取 Transaction 的动画context.animate {
// 这里的更改
} completion: {
// 动画完成时调用
// 如果未动画化,则立即内联调用
}
无论是否动画化都有效 —— 始终使用此模式是安全的。
在 SwiftUI 视图和 UIViews 之间运行的单个动画完美同步。这使得无缝的混合层次结构成为可能。
SwiftUI 动画通过动画合并自动保留速度 —— 无需手动计算速度:
// 使用 SwiftUI 动画的 UIKit
func handlePan(_ gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .changed:
UIView.animate(.interactiveSpring) {
bead.center = gesture.location(in: view)
}
case .ended:
UIView.animate(.spring) { // 自动继承速度
bead.center = endOfBracelet
}
default: break
}
}
// 纯 SwiftUI 等效
DragGesture()
.onChanged { value in
withAnimation(.interactiveSpring) { position = value.location }
}
.onEnded { _ in
withAnimation(.spring) { position = targetPosition }
}
每个 .interactiveSpring 都会重定向前一个动画,而最终的 .spring 会继承累积的速度以实现平滑减速。
按顺序检查:
Int 不能动画化;使用 Double/Float.animation(.spring, value: x) 或 withAnimation.animation(.spring, value: progress) 而不是 .animation(.spring, value: title)@Animatable(iOS 26+)或手动的 animatableData自定义 Animatable 遵循在主线程上为每一帧调用 body。尽可能使用内置效果(.opacity()、.scaleEffect())—— 它们在主线程外运行。对于复杂情况,使用 Instruments 进行分析。
弹性动画默认合并,保留速度。如果你不希望合并行为,使用时序曲线动画(.easeInOut)。参见上面的动画合并行为部分。
WWDC:2023-10156, 2023-10157, 2023-10158, 2024-10145, 2025-256
文档:/swiftui/animatable, /swiftui/animation, /swiftui/vectorarithmetic, /swiftui/transaction, /swiftui/view/navigationtransition(:), /swiftui/view/matchedtransitionsource(id:in:configuration:), /uikit/uiview/animate( :changes:completion:)
技能:axiom-swiftui-26-ref, axiom-swiftui-nav-ref, axiom-swiftui-performance, axiom-swiftui-debugging, axiom-sf-symbols-ref
每周安装数
129
仓库
GitHub 星标数
610
首次出现
2026 年 1 月 21 日
安全审计
安装于
opencode112
codex106
gemini-cli103
cursor99
github-copilot98
claude-code98
Comprehensive guide to SwiftUI's animation system, from foundational concepts to advanced techniques. This skill covers the Animatable protocol, the iOS 26 @Animatable macro, animation types, and the Transaction system.
Core principle Animation in SwiftUI is mathematical interpolation over time, powered by the VectorArithmetic protocol. Understanding this foundation unlocks the full power of SwiftUI's declarative animation system.
Animation is the process of generating intermediate values between a start and end state.
.opacity(0) → .opacity(1)
While this animation runs, SwiftUI computes intermediate values:
0.0 → 0.02 → 0.05 → 0.1 → 0.25 → 0.4 → 0.6 → 0.8 → 1.0
How values are distributed
SwiftUI requires animated data to conform to VectorArithmetic — providing subtraction, scaling, addition, and a zero value. This enables SwiftUI to interpolate between any two values.
Built-in conforming types : CGFloat, Double, Float, Angle (1D), CGPoint, CGSize (2D), CGRect (4D).
Key insight Vector arithmetic abstracts over dimensionality. SwiftUI animates all these types with a single generic implementation.
Int doesn't conform to VectorArithmetic — no fractional intermediates exist between 3 and 4. SwiftUI simply snaps the value.
Solution : Use Float/Double and display as Int:
@State private var count: Float = 0
// ...
Text("\(Int(count))")
.animation(.spring, value: count)
Animatable attributes conceptually have two values:
Example
.scaleEffect(selected ? 1.5 : 1.0)
When selected becomes true:
1.51.0 → 1.1 → 1.2 → 1.3 → 1.4 → 1.5 over timeThe Animatable protocol allows views to animate their properties by defining which data should be interpolated.
protocol Animatable {
associatedtype AnimatableData: VectorArithmetic
var animatableData: AnimatableData { get set }
}
SwiftUI builds an animatable attribute for any view conforming to this protocol.
Many SwiftUI modifiers conform to Animatable:
.scaleEffect() — Animates scale transform.rotationEffect() — Animates rotation.offset() — Animates position offset.opacity() — Animates transparency.blur() — Animates blur radius.shadow() — Animates shadow propertiesCircle, Rectangle, RoundedRectangleCapsule, Ellipse, PathShape implementationsWhen animating multiple properties, use AnimatablePair to combine vectors. For example, scaleEffect combines CGSize (2D) and UnitPoint (2D) into a 4D vector via AnimatablePair<CGSize.AnimatableData, UnitPoint.AnimatableData>. Access components via .first and .second. The @Animatable macro (iOS 26+) eliminates this boilerplate entirely.
struct AnimatableNumberView: View, Animatable {
var number: Double
var animatableData: Double {
get { number }
set { number = newValue }
}
var body: some View {
Text("\(Int(number))")
.font(.largeTitle)
}
}
// Usage
AnimatableNumberView(number: value)
.animation(.spring, value: value)
How it works
number changes from 0 to 100body for every frame of the animationnumber value: 0 → 5 → 15 → 30 → 55 → 80 → 100Custom Animatable conformance is expensive — SwiftUI calls body for every frame on the main thread. Built-in effects (.scaleEffect(), .opacity()) run off-main-thread and don't call body. Use custom conformance only when built-in modifiers can't achieve the effect (e.g., animating a custom Layout that repositions subviews per-frame).
The @Animatable macro eliminates the boilerplate of manually conforming to the Animatable protocol.
Before iOS 26 , you had to:
AnimatableanimatableData getter and setterAnimatablePair for multiple propertiesiOS 26+ , you just add @Animatable:
@MainActor
@Animatable
struct MyView: View {
var scale: CGFloat
var opacity: Double
var body: some View {
// ...
}
}
The macro automatically:
Animatable conformanceanimatableData from VectorArithmetic-conforming propertiesAnimatablePairstruct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
var drawingDirection: Bool // Don't want to animate this
// Tedious manual animatableData declaration
var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>,
AnimatablePair<Double, AnimatablePair<CGFloat, CGFloat>>> {
get {
AnimatablePair(
AnimatablePair(startPoint.x, startPoint.y),
AnimatablePair(elevation, AnimatablePair(endPoint.x, endPoint.y))
)
}
set {
startPoint = CGPoint(x: newValue.first.first, y: newValue.first.second)
elevation = newValue.second.first
endPoint = CGPoint(x: newValue.second.second.first, y: newValue.second.second.second)
}
}
func path(in rect: CGRect) -> Path {
// Drawing code
}
}
@Animatable
struct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
@AnimatableIgnored
var drawingDirection: Bool // Excluded from animation
func path(in rect: CGRect) -> Path {
// Drawing code
}
}
Lines of code : 20 → 12 (40% reduction)
Use @AnimatableIgnored to exclude properties from animation.
@MainActor
@Animatable
struct ProgressView: View {
var progress: Double // Animated
var totalItems: Int // Animated (if Float, not if Int)
@AnimatableIgnored
var title: String // Not animated
@AnimatableIgnored
var startTime: Date // Not animated
@AnimatableIgnored
var debugEnabled: Bool // Not animated
var body: some View {
VStack {
Text(title)
ProgressBar(value: progress)
if debugEnabled {
Text("Started: \(startTime.formatted())")
}
}
}
}
@Animatable works for any numeric display — stock prices, heart rate, scores, timers, progress bars:
@MainActor
@Animatable
struct AnimatedValueView: View {
var value: Double
var changePercent: Double
@AnimatableIgnored
var label: String
var body: some View {
VStack(alignment: .trailing) {
Text("\(value, format: .number.precision(.fractionLength(2)))")
.font(.title)
Text("\(changePercent > 0 ? "+" : "")\(changePercent, format: .percent)")
.foregroundStyle(changePercent > 0 ? .green : .red)
}
}
}
// Usage
AnimatedValueView(value: currentPrice, changePercent: 0.025, label: "Price")
.animation(.spring(duration: 0.8), value: currentPrice)
Timing curve animations use bezier curves to control the speed of animation over time.
.animation(.linear) // Constant speed
.animation(.easeIn) // Starts slow, ends fast
.animation(.easeOut) // Starts fast, ends slow
.animation(.easeInOut) // Slow start and end, fast middle
let customCurve = UnitCurve(
startControlPoint: CGPoint(x: 0.2, y: 0),
endControlPoint: CGPoint(x: 0.8, y: 1)
)
.animation(.timingCurve(customCurve, duration: 0.5))
All timing curve animations accept an optional duration:
.animation(.easeInOut(duration: 0.3))
.animation(.linear(duration: 1.0))
Default : 0.35 seconds
Spring animations use physics simulation to create natural, organic motion.
.animation(.smooth) // No bounce (default since iOS 17)
.animation(.snappy) // Small amount of bounce
.animation(.bouncy) // Larger amount of bounce
.animation(.spring(duration: 0.6, bounce: 0.3))
Parameters
duration — Perceived animation durationbounce — Amount of bounce (0 = no bounce, 1 = very bouncy)Much more intuitive than traditional spring parameters (mass, stiffness, damping).
Modify base animations to create complex effects.
.animation(.spring.delay(0.5))
Waits 0.5 seconds before starting the animation.
.animation(.easeInOut.repeatCount(3, autoreverses: true))
.animation(.linear.repeatForever(autoreverses: false))
Repeats the animation multiple times or infinitely.
.animation(.spring.speed(2.0)) // 2x faster
.animation(.spring.speed(0.5)) // 2x slower
Multiplies the animation speed.
Before iOS 17
withAnimation {
// Used timing curve by default
}
iOS 17+
withAnimation {
// Uses .smooth spring by default
}
Why the change : Spring animations feel more natural and preserve velocity when interrupted.
Recommendation : Embrace springs. They make your UI feel more responsive and polished.
The most common way to trigger an animation.
Button("Scale Up") {
withAnimation(.spring) {
scale = 1.5
}
}
How it works
withAnimation opens a transactionwithAnimation(.spring(duration: 0.6, bounce: 0.4)) {
isExpanded.toggle()
}
withAnimation(nil) {
// Changes happen immediately, no animation
resetState()
}
Apply animations to specific values within a view.
Circle()
.fill(isActive ? .blue : .gray)
.animation(.spring, value: isActive)
How it works : Animation only applies when isActive changes. Other state changes won't trigger this animation.
Circle()
.scaleEffect(scale)
.animation(.bouncy, value: scale)
.opacity(opacity)
.animation(.easeInOut, value: opacity)
Different animations for different properties.
Narrowly scope animations to specific animatable attributes.
struct AvatarView: View {
var selected: Bool
var body: some View {
Image("avatar")
.scaleEffect(selected ? 1.5 : 1.0)
.animation(.spring, value: selected)
// ⚠️ If image also changes when selected changes,
// image transition gets animated too (accidental)
}
}
struct AvatarView: View {
var selected: Bool
var body: some View {
Image("avatar")
.animation(.spring, value: selected) {
$0.scaleEffect(selected ? 1.5 : 1.0)
}
// ✅ Only scaleEffect animates, image transition doesn't
}
}
How it works
Define custom TransactionKey types to propagate context through the transaction system. Use withTransaction to set values and .transaction modifier to read them. This enables applying different animations based on how a state change was triggered (tap vs programmatic).
Implement your own animation algorithms.
protocol CustomAnimation {
// Calculate current value
func animate<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> V?
// Optional: Should this animation merge with previous?
func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool
// Optional: Current velocity
func velocity<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: AnimationContext<V>
) -> V?
}
struct LinearAnimation: CustomAnimation {
let duration: TimeInterval
func animate<V: VectorArithmetic>(
value: V, // Delta vector: target - current
time: TimeInterval,
context: inout AnimationContext<V>
) -> V? {
if time >= duration { return nil }
return value.scaled(by: time / duration)
}
}
Critical understanding : value is the delta vector (target - current), not the target. Return nil when done. SwiftUI adds the scaled delta to the current value automatically.
What happens when a new animation starts before the previous one finishes?
func shouldMerge(...) -> Bool {
return false // Default implementation
}
Behavior : Both animations run together, results are combined additively.
Example
func shouldMerge(...) -> Bool {
return true // Springs override this
}
Behavior : New animation incorporates state of previous animation, preserving velocity.
Example
Why springs feel more natural : They preserve momentum when interrupted.
Cycles through a sequence of phases, applying different modifiers at each phase. Each phase transition is independently animated.
PhaseAnimator([false, true]) { phase in
Image(systemName: "star.fill")
.scaleEffect(phase ? 1.5 : 1.0)
.opacity(phase ? 1.0 : 0.5)
.rotationEffect(.degrees(phase ? 360 : 0))
} animation: { phase in
phase ? .spring(duration: 0.8, bounce: 0.3) : .easeInOut(duration: 0.4)
}
How it works : Begins at first phase, animates to second, then loops. The animation closure returns the animation used to transition INTO that phase. Phases can be any Equatable type — use an enum for complex multi-step sequences:
enum PulsePhase: CaseIterable { case idle, expand, contract }
PhaseAnimator(PulsePhase.allCases) { phase in
Circle()
.scaleEffect(phase == .expand ? 1.3 : phase == .contract ? 0.9 : 1.0)
}
Trigger : Add a trigger parameter to run the animation only when a value changes (instead of looping continuously).
Provides per-property keyframe tracks for precise, timeline-based animations. More control than PhaseAnimator.
struct AnimationValues {
var scale: Double = 1.0
var rotation: Angle = .zero
var yOffset: Double = 0
}
KeyframeAnimator(initialValue: AnimationValues()) { values in
Image(systemName: "heart.fill")
.scaleEffect(values.scale)
.rotationEffect(values.rotation)
.offset(y: values.yOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.3)
SpringKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(15), duration: 0.15)
LinearKeyframe(.degrees(-15), duration: 0.3)
LinearKeyframe(.zero, duration: 0.15)
}
KeyframeTrack(\.yOffset) {
CubicKeyframe(-20, duration: 0.3)
CubicKeyframe(0, duration: 0.3)
}
}
Keyframe types : LinearKeyframe (constant velocity), SpringKeyframe (spring physics), CubicKeyframe (bezier curves), MoveKeyframe (instant jump, no interpolation).
vs PhaseAnimator : Use PhaseAnimator for simple state cycling. Use KeyframeAnimator when different properties need independent timing.
Defines how a view animates when inserted/removed from the view hierarchy.
if showDetail {
DetailView()
.transition(.slide) // Slide in/out
.transition(.scale.combined(with: .opacity)) // Combine transitions
.transition(.move(edge: .bottom)) // Move from edge
.transition(.asymmetric( // Different in/out
insertion: .scale.combined(with: .opacity),
removal: .opacity
))
}
Requires animation context — wrap the state change in withAnimation or use .animation() modifier. Without animation, the view appears/disappears instantly.
Smoothly animate a view's frame between two positions in the hierarchy. Commonly used for hero transitions and shared element animations.
@Namespace private var animation
// Source
if !isExpanded {
RoundedRectangle(cornerRadius: 10)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 100, height: 100)
}
// Destination
if isExpanded {
RoundedRectangle(cornerRadius: 20)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 300, height: 400)
}
Key rules : Same id + same Namespace = matched pair. Only one view with a given ID should be isSource: true (default) at a time. Wrap state change in withAnimation for smooth interpolation.
Animates changes to text and symbol content within a view (iOS 16+).
Text(value, format: .number)
.contentTransition(.numericText(countsDown: value < previous))
Image(systemName: isFavorite ? "heart.fill" : "heart")
.contentTransition(.symbolEffect(.replace))
iOS 18 introduces the zoom transition, where a tapped cell morphs into the incoming view. This transition is continuously interactive—users can grab and drag the view during or after the transition begins.
Key benefit In parts of your app where you transition from a large cell, zoom transitions increase visual continuity by keeping the same UI elements on screen across the transition.
Two steps to adopt zoom transitions:
NavigationLink {
BraceletEditor(bracelet)
.navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
} label: {
BraceletPreview(bracelet)
}
BraceletPreview(bracelet)
.matchedTransitionSource(id: bracelet.id, in: namespace)
struct BraceletListView: View {
@Namespace private var braceletList
let bracelets: [Bracelet]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
ForEach(bracelets) { bracelet in
NavigationLink {
BraceletEditor(bracelet: bracelet)
.navigationTransition(
.zoom(sourceID: bracelet.id, in: braceletList)
)
} label: {
BraceletPreview(bracelet: bracelet)
}
.matchedTransitionSource(id: bracelet.id, in: braceletList)
}
}
}
}
}
}
Set preferredTransition = .zoom { context in ... } on the pushed view controller. The closure returns the source view and is called on both zoom in and zoom out — capture a stable identifier (model object), not a view directly.
Zoom transitions also work with fullScreenCover and sheet:
.fullScreenCover(item: $selectedBracelet) { bracelet in
BraceletEditor(bracelet: bracelet)
.navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
}
.matchedTransitionSource(id: bracelet.id, in: namespace) { source in
source.cornerRadius(8.0).shadow(radius: 4)
}
Push transitions cannot be cancelled — when interrupted, they convert to pop transitions. The view controller always reaches the Appeared state. Don't guard against overlapping transitions; let the system handle them.
iOS 18 enables using SwiftUI Animation types to animate UIKit and AppKit views. This provides access to the full suite of SwiftUI animations, including custom animations.
// Old way
UIView.animate(withDuration: 0.5, delay: 0,
usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5) {
bead.center = endOfBracelet
}
// New way: Use SwiftUI Animation type
UIView.animate(.spring(duration: 0.5)) {
bead.center = endOfBracelet
}
All SwiftUI animations work: .linear, .easeIn/Out, .spring, .smooth, .snappy, .bouncy, .repeatForever(), and custom animations.
Architecture note : Unlike old UIKit APIs, no CAAnimation is generated — presentation values are animated directly.
When wrapping UIKit views in SwiftUI, animations don't automatically bridge:
struct BeadBoxWrapper: UIViewRepresentable {
@Binding var isOpen: Bool
func updateUIView(_ box: BeadBox, context: Context) {
// ❌ Animation on binding doesn't affect UIKit
box.lid.center.y = isOpen ? -100 : 100
}
}
// Usage
BeadBoxWrapper(isOpen: $isOpen)
.animation(.spring, value: isOpen) // No effect on UIKit view
Use context.animate() to bridge SwiftUI animations:
struct BeadBoxWrapper: UIViewRepresentable {
@Binding var isOpen: Bool
func makeUIView(context: Context) -> BeadBox {
BeadBox()
}
func updateUIView(_ box: BeadBox, context: Context) {
// ✅ Bridges animation from Transaction to UIKit
context.animate {
box.lid.center.y = isOpen ? -100 : 100
}
}
}
Transactioncontext.animate() reads the Transaction's animationcontext.animate {
// Changes here
} completion: {
// Called when animation completes
// If not animated, called immediately inline
}
Works whether animated or not — safe to always use this pattern.
A single animation running across SwiftUI Views and UIViews runs perfectly in sync. This enables seamless mixed hierarchies.
SwiftUI animations automatically preserve velocity through animation merging — no manual velocity calculation needed:
// UIKit with SwiftUI animations
func handlePan(_ gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .changed:
UIView.animate(.interactiveSpring) {
bead.center = gesture.location(in: view)
}
case .ended:
UIView.animate(.spring) { // Inherits velocity automatically
bead.center = endOfBracelet
}
default: break
}
}
// Pure SwiftUI equivalent
DragGesture()
.onChanged { value in
withAnimation(.interactiveSpring) { position = value.location }
}
.onEnded { _ in
withAnimation(.spring) { position = targetPosition }
}
Each .interactiveSpring retargets the previous animation, and the final .spring inherits the accumulated velocity for smooth deceleration.
Check in order:
Int can't animate; use Double/Float.animation(.spring, value: x) or withAnimation.animation(.spring, value: progress) not .animation(.spring, value: title)@Animatable (iOS 26+) or manual animatableDataCustom Animatable conformance calls body every frame on main thread. Use built-in effects (.opacity(), .scaleEffect()) when possible — they run off-main-thread. Profile with Instruments for complex cases.
Spring animations merge by default, preserving velocity. Use timing curve animations (.easeInOut) if you don't want merging behavior. See Animation Merging Behavior section above.
WWDC : 2023-10156, 2023-10157, 2023-10158, 2024-10145, 2025-256
Docs : /swiftui/animatable, /swiftui/animation, /swiftui/vectorarithmetic, /swiftui/transaction, /swiftui/view/navigationtransition(:), /swiftui/view/matchedtransitionsource(id:in:configuration:), /uikit/uiview/animate( :changes:completion:)
Skills : axiom-swiftui-26-ref, axiom-swiftui-nav-ref, axiom-swiftui-performance, axiom-swiftui-debugging, axiom-sf-symbols-ref
Weekly Installs
129
Repository
GitHub Stars
610
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode112
codex106
gemini-cli103
cursor99
github-copilot98
claude-code98
Clerk Swift iOS 原生集成指南:SwiftUI/UIKit 认证实现与快速开始
820 周安装