做 SwiftUI 开发这几年,我越来越觉得动画不是界面上的点缀,而是 App 和用户之间最直接的对话方式。一个按钮按下去有没有反馈、一张卡片能不能跟手滑动、一个页面切换是否顺滑,这些细节决定了用户是觉得"这 App 真顺手"还是"哪里怪怪的"。SwiftUI 把动画变成了声明式的状态描述,我们不需要像 UIKit 时代那样手动管理动画的起止和插值,但这同样意味着,如果不理解底层机制,动画动得不对的时候,排起错来也更费劲。
这篇文章是我在多个实际项目里折腾 SwiftUI 动画和交互设计的经验总结。我会从最基础的 withAnimation 和 transaction 讲起,逐步深入到手势驱动的弹性动画、自定义转场、Animatable 与 GeometryEffect 的使用,最后用一个完整的卡片堆叠交互把知识点串起来,并附上一份常见问题速查表。无论你是刚接触 SwiftUI 的新手,还是已经写过不少界面但总觉得动画"差点意思"的开发者,这篇文章应该都能帮你把动画这件事捋顺。
1. 理解 SwiftUI 动画的底层逻辑:先别急着写动画
1.1 动画的本质是状态插值,不是视图移动
很多 UI 框架的动画思路是"我告诉你起点和终点,你在中间自动补上若干帧"。SwiftUI 不太一样,它走的是声明式路线:你用代码描述"界面应当呈现什么状态",然后通过修改状态来触发视图更新,中间帧由系统自动补齐。
举个例子,下面这段代码:
swift复制struct ExpandView: View {
@State private var isExpanded = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 12)
.fill(isExpanded ? Color.orange : Color.blue)
.frame(width: isExpanded ? 200 : 100,
height: isExpanded ? 200 : 100)
.onTapGesture {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
isExpanded.toggle()
}
}
}
}
}
你看到的动画效果是"一个方块由蓝色变成橙色,尺寸从 100 放大到 200"。但仔细想,真正被动画化的其实是 isExpanded 这个状态。SwiftUI 拿到状态变化前后的两份视图描述,对比之后发现颜色和尺寸发生了改变,于是自动在两者之间做插值。
这里有个关键认知:SwiftUI 动画的对象不是视图对象本身,而是"状态变化引起的差异"。这个认知决定了很多排查思路。比如你发现某个动画没生效,第一反应不该是"我的动画代码写错了吗",而是"我的状态真的变了吗?这个状态变化有没有被动画上下文包住?"
1.2 withAnimation 与 transaction:动画的两种触发路径
SwiftUI 里触发动画有两条路径:显式动画和隐式动画。
显式动画就是我们上面用的 withAnimation:在闭包里修改状态,闭包外受影响的所有视图都会尽量用动画过渡。
swift复制withAnimation(.easeInOut(duration: 0.3)) {
showDetail.toggle()
}
隐式动画则是给视图附加 .animation(_:value:) 修饰符,指定"当某个值变化时,这个视图的后续状态变化都用这套动画参数":
swift复制Text("Hello")
.scaleEffect(isScaled ? 1.5 : 1.0)
.animation(.spring(), value: isScaled)
两者各有适用范围。我的经验是:控制范围小、只想让某个视图动,优先用隐式动画;一次状态变化牵扯多个视图、需要统一节奏时,用 withAnimation。
还有一个容易忽略的东西叫 transaction(事务)。SwiftUI 每次状态变化都会生成一个事务,里面记录着动画参数、是否禁用动画等信息。显式动画的本质,就是给这次状态变化的事务写入一套动画参数。
transaction 也可以手动操作,适合做精细控制。比如"第一次进入页面不要动画,之后要动画":
swift复制withAnimation {
var transaction = Transaction(animation: .spring(response: 0.6, dampingFraction: 0.8))
if isFirstLaunch {
transaction.disablesAnimations = true
}
withTransaction(transaction) {
isExpanded.toggle()
}
}
这种写法在执行"初始布局"或"状态恢复"时很实用,能避免页面一加载就播放一套突兀的动画。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓动曲线与弹簧参数:动画手感从哪里来
2.1 系统缓动曲线的真实差异
缓动曲线(Timing Curve)决定了动画在时间轴上的"速度分布",直接影响手感。SwiftUI 内置了几种常用曲线:
.linear:匀速,适合进度条、机械运动感的效果。.easeIn:慢启动、快速结束,适合"飞入"的前半段准备动作。.easeOut:快速启动、慢速结束,大多数界面反馈都用它。.easeInOut:两头慢中间快,适合页面级别的过渡。.spring:弹簧动画,适合拖拽、缩放、弹性回弹。
我在实战里经常遇到一个问题:弹窗出现用 easeOut 总觉得"有点黏",数据加载完成后的提示浮层不够清脆。后来我把浮层的出现动画从 easeOut 换成了 spring,手感立刻不一样了。原因在于弹簧动画的位移轨迹不是一条标准的贝塞尔曲线,而是带有"过冲"(overshoot)的物理模拟,给用户一种"有生命、有重量"的反馈。
对比一下实际代码:
swift复制// 有点"橡皮筋"感
.animation(.easeOut(duration: 0.3), value: isPresented)
// 更清脆、有过冲
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: isPresented)
同一个弹窗,两种曲线的观感差异非常明显。做产品时如果设计师给了"要有弹性"的标注,直接套 easeOut 是交不了差的。
2.2 自定义 TimingCurve 与关键帧动画
系统曲线不够用时,可以用 .timingCurve 自定义贝塞尔曲线,参数和 CSS 里的 cubic-bezier 类似:
swift复制.animation(.timingCurve(0.2, 0.8, 0.2, 1, duration: 0.4), value: progress)
这组参数对应的效果是"先快后慢再稳",比系统 easeInOut 更有质感。你可以把它理解为:第一控制点 (0.2, 0.8) 让曲线前半段陡峭,第二控制点 (0.2, 1) 让末尾平滑收住。
iOS 17 之后,SwiftUI 引入了 KeyframeAnimator,支持定义关键帧动画,适合做"心跳"、"呼吸灯"这类多段节奏效果:
swift复制struct PulseValues {
var scale: CGFloat
}
struct PulseView: View {
var body: some View {
Circle()
.fill(Color.red)
.frame(width: 80, height: 80)
.keyframeAnimator(initialValue: PulseValues(scale: 1.0)) { view, value in
view.scaleEffect(value.scale)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.3, duration: 0.3)
CubicKeyframe(0.9, duration: 0.2)
CubicKeyframe(1.1, duration: 0.2)
CubicKeyframe(1.0, duration: 0.2)
}
}
}
}
关键帧帮助我们从"从一个状态到另一个状态"升级为"串起一系列中间姿势"。在做复杂交互动效时,比如一个按钮从点击到确认再到复位,用 KeyframeAnimator 可以把时序写得非常清晰,不用再靠多个 withAnimation 嵌套或 DispatchQueue 延迟拼凑。
2.3 弹簧动画三参数与初速度
继续讲 spring 的参数。最常见的初始化器是 .spring(response:dampingFraction:blendDuration:)。
response:弹簧的"刚性",值越小弹得越快,通常界面元素取 0.3~0.6。dampingFraction:阻尼系数,0~1 之间,越大越"没有余震"。0.7~0.9 是"清脆"的手感区间,接近 1 几乎无过冲,取 0 会永远振荡。blendDuration:多个动画切换时的混合时长,一般保持默认即可。
还有一个容易被忽略的参数:initialVelocity(初速度)。它对手势驱动的动画尤其重要。当你拖拽一张卡片,手指松开的一瞬间,卡片已经有了运动速度,如果动画从初速度 0 开始,就会出现"猛地一顿再弹回去"的断裂感。正确的做法是把手指当前的滑动速度传给动画:
swift复制withAnimation(.interpolatingSpring(stiffness: 200, damping: 30, initialVelocity: dragVelocity)) {
isDragging = false
}
这个细节我排过很多次 bug 才意识到。interpolatingSpring 的 stiffness 和 damping 是物理单位,直观理解就是"刚度和阻尼":刚度越大回弹越快,阻尼越大停止越快。
提示:iOS 17 之后,
spring还提供了一套基于"质量、刚度、阻尼、目标速度"的初始化器,更接近 UIKit 的UISpringTimingParameters,从 UIKit 迁移过来的同学上手会更顺。
3. 从插值到创造:Animatable、GeometryEffect 与 matchedGeometryEffect
3.1 让自定义视图参与动画:实现 Animatable
默认情况下,SwiftUI 只能对实现了 VectorArithmetic 协议的类型做插值。CGFloat、CGPoint、Double、Color 等类型都满足要求,所以可以直接用于动画。但如果你自定义了一个 Shape,想让它的某些参数动起来,就需要手动实现 Animatable。
比如一个环形进度条:
swift复制struct ProgressRing: Shape {
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
let start = Angle(degrees: -90)
let end = Angle(degrees: -90 + 360 * progress)
return Path { p in
p.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
radius: min(rect.width, rect.height) / 2 - 4,
startAngle: start,
endAngle: end,
clockwise: false)
}
}
}
这时你只要这样用:
swift复制ProgressRing(progress: progress)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.animation(.spring(), value: progress)
进度环的圆弧就会自动跟着 progress 的数值平滑增长。如果不实现 animatableData,进度条会"跳变"而不是"增长"。
这里的关键是:animatableData 必须是一个可插值的类型,而且 getter/setter 要直接映射到你希望动画的那个属性上。如果某个形状有多个可动画属性,可以自定义一个 struct 并实现 VectorArithmetic,这种技巧在做"多边形平滑变形""路径渐变"时非常有用。
3.2 用 GeometryEffect 实现跟随手势的复杂形变
GeometryEffect 是 SwiftUI 中比较"高级"的动画工具。它能让视图在动画过程中按照你定义的变换逐步移动,同时还能在动画过程中持续改变视图表现。
一个典型例子是实现"卡片绕底部中心翻转":
swift复制struct RotateEffect: GeometryEffect {
var angle: Double
var animatableData: Double {
get { angle }
set { angle = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
var transform = CGAffineTransform.identity
transform = transform.translatedBy(x: size.width / 2, y: size.height)
transform = transform.rotated(by: CGFloat(angle))
transform = transform.translatedBy(x: -size.width / 2, y: -size.height)
return ProjectionTransform(transform)
}
}
Text("Flip Card")
.modifier(RotateEffect(angle: isFlipped ? .pi : 0))
.animation(.spring(), value: isFlipped)
这里关键点是:GeometryEffect 的 animatableData 会被动画系统逐帧插值,每一帧都重新调用 effectValue,所以视图会像被"逐帧操纵"一样平滑变化。
使用 GeometryEffect 的时机是"视图本身的位置和尺寸不变,但需要在动画过程中施加一个连续的几何变换"。我在做雷达扫描、仪表盘指针、照片翻转切换时都用过它,效果比直接改 offset 和 rotationEffect 的组合更可控。
3.3 matchedGeometryEffect:跨视图的动画魔术
matchedGeometryEffect 是最像"魔法"的功能:它让两个视图在动画过程中共享同一个几何身份,系统会算出位置、大小、圆角等信息,无缝过渡。
经典场景是列表缩略图点开变成大图:
swift复制@Namespace private var heroNamespace
if isExpanded {
DetailView()
.matchedGeometryEffect(id: "cover", in: heroNamespace)
} else {
ThumbnailView()
.matchedGeometryEffect(id: "cover", in: heroNamespace)
}
切换时用 withAnimation 包住状态变化即可。
使用时有几个必须注意的坑:
- 同一个
namespace内id必须唯一。如果两个视图用了同样的id,SwiftUI 会直接抛 precondition failure,严重时可能 crash。 matchedGeometryEffect尽量放在层级较浅的位置,避免和其他隐式动画叠加导致行为诡异。- 要配合
if或switch使用,让 SwiftUI 把两个视图视为同一个"逻辑视图"在不同条件下的呈现。
我踩过的一个坑是:列表缩略图在 cell 里,详情图在 NavigationStack 的 destination 里,namespace 没有定义在共同的祖先层级,结果动画"时灵时不灵"。后来把 namespace 提升到导航容器顶层,并确保两个视图在同一个容器树内,效果才稳定下来。
4. 手势驱动的交互动效:让视图"粘"在手指上
4.1 用 @GestureState 做无动画的实时跟随
手势驱动的动画和普通点击动画有个核心区别:手势进行中我们通常不希望有插值动画,而是希望视图位置严格跟随手指,否则会有明显延迟感;松手之后才需要动画回弹或飞向某个目标。
@GestureState 正是为这个场景设计的。它的特点是:手势进行中值持续更新,手势结束时自动重置为初始值。
swift复制@GestureState private var dragOffset: CGSize = .zero
var body: some View {
CardView()
.offset(dragOffset)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
// 判断是否应该飞出屏幕
}
)
}
注意:@GestureState 的 updating 里直接赋值,视图会立即更新,中间没有补间动画。松手后 state 自动回到 .zero,视图会回到原位——如果你希望"回原位"有动画,需要额外对 dragOffset 设置 .animation(_:value:),或者用 onChange 包一层 withAnimation。
实际项目中,我倾向于把"拖拽跟随"和"最终位置动画"拆开:拖拽中不做动画,松手后根据速度、位置判断落到哪里,再用 spring 动画飞过去。这样逻辑清晰,体感也最好。
4.2 interactiveSpring:动态目标的顺滑弹簧
iOS 17 引入了 InteractiveSpring 和 SpringAnimation,目的是解决"手势驱动的动画需要动态更新目标值"这件事。之前做"方向盘转动""卡片拖拽回弹"这类效果时,目标值往往是固定算好的;但 InteractiveSpring 支持在动画过程中持续修改目标值,系统会平滑地跟随,体感接近原生的滚动减速度。
用法示例:
swift复制@State private var position: CGFloat = 0
var springAnimation: Animation {
.interactiveSpring(response: 0.4, dampingFraction: 0.86, blendDuration: 0.25)
}
var body: some View {
Circle()
.offset(x: position)
.gesture(
DragGesture()
.onChanged { value in
withAnimation(springAnimation) {
position = value.translation.width
}
}
.onEnded { value in
withAnimation(springAnimation) {
position = value.predictedEndTranslation.width
}
}
)
}
这里关键点在 onChanged 里也用 withAnimation 包住更新。初看很奇怪,手指移动时为什么还要动画?但 InteractiveSpring 的"动画"不是延迟插值,而是"给当前运动状态设置一个新目标",系统会保持速度连续,避免跳动。我第一次用也觉得很反直觉,但实测下来这是最顺滑的写法。
DragGesture 的 predictedEndTranslation 是系统基于当前速度和位置估算的终点,用它可以实现"甩出去"的惯性效果。
4.3 手势动画的性能红线
手势动画最怕卡顿,任何一帧的延迟都会直接变成手指下的"黏滞感"。针对 SwiftUI 手势动画,我的优化顺序是:
- 能用
offset、scaleEffect、rotationEffect这类几何变换,就不要改frame或布局约束。几何变换不触发 layout,开销低很多。 - 列表动画优先考虑
.drawingGroup(),把视图合成到离屏纹理。注意drawingGroup不是万能的,内容频繁变化的视图反而可能增加开销。 - 避免在整个
body上做动画。把动画状态细化到子视图,用Equatable或局部@State隔离变化粒度。 - 在手势
onChanged里减少@State写入次数。如果只需要更新某个子视图,用局部状态而不是把状态放到顶层。 - 列表尽量用
LazyVStack而不是VStack,保证只有可视区域内的视图参与计算。
提示:在用
.drawingGroup()时要注意,它会把视图渲染到离屏纹理,如果视图内容频繁更新(比如文本、图片异步加载),有可能产生"白屏"或"残影"问题。建议只在动画对象是静态内容时使用。
5. 转场动画与沉浸式交互:从"切换页面"到"讲述故事"
5.1 transition 与 asymmetric:给页面切换加叙事感
转场(transition)处理的是"视图出现和消失时怎么演",它和动画是两件事。动画处理状态变化的属性插值,转场处理视图生命周期的插入与移除。
最简单的用法:
swift复制ZStack {
if showDetail {
DetailView()
.transition(.move(edge: .bottom))
}
}
注意:transition 必须配合动画才能生效。如果 showDetail 的切换没有被 withAnimation 包住,transition 只会瞬间插入或移除,没有任何过渡。
实际开发里更常用的是不对称转场,出现和消失用不同行为:
swift复制.transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .scale(scale: 0.9).combined(with: .opacity)
))
常见的一个坑是:在 NavigationStack 里 push/pop 时,transition 会被导航系统自己的转场接管,你给 destination 加的 transition 不一定生效。想要完全自定义导航转场,一种方案是放弃 NavigationLink,自己在 ZStack + if 里做"模拟导航"。
5.2 用 preferenceKey 实现"全屏卡片弹出"
要从"列表卡片点开后弹一个全屏详情,且详情初始位置正好是卡片位置"这种效果,仅靠 matchedGeometryEffect 有时不够灵活,因为详情页的结构和列表项差别很大。这时可以用 PreferenceKey 把卡片在窗口坐标系中的位置传递出去。
思路是:在列表项里通过 preference 上报自己的 frame,在根视图用 onPreferenceChange 接收,把详情视图放到 overlay 中,根据记录的 frame 设置初始 offset 和 scale。
核心片段:
swift复制struct CardFramePreferenceKey: PreferenceKey {
static var defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { $1 })
}
}
// 列表项中
Color.clear
.frame(height: 0)
.preference(key: CardFramePreferenceKey.self,
value: [card.id: cardFrameInWindow()])
然后在根视图接收:
swift复制.onPreferenceChange(CardFramePreferenceKey.self) { frames in
self.cardFrames = frames
}
拿到初始 frame 后,把详情视图放进 overlay,用 spring 动画改变 offset 与 scale,就能实现"卡片展开为全屏详情"的沉浸式效果。我在多个社交类 App 的"照片墙"场景用过这个方案,比系统 push 转场更有沉浸感。
5.3 动画工作流与素材准备:asset 与图片加载对动画的影响
做动画时,素材的准备也很关键。很多人忽略 asset 的加载方式会影响动画流畅度。
- 在 asset catalog 中,PDF/SVG 向量图建议勾选
Preserve Vector Data,这样Image在缩放动画时不会模糊,也不会因为动态生成位图而卡顿。 - 避免在大图上直接做
scaleEffect动画。一个 2000px 大图每帧都缩放,GPU 压力不小。更好的做法是预生成多档缩略图,动画时用合适尺寸的图。 - iOS 15 之后可以用
AsyncImage做异步加载,配合transition可以做出"图片加载完成后的淡入"效果:
swift复制AsyncImage(url: imageURL) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
default:
ProgressView()
}
}
.transition(.opacity)
.animation(.easeInOut(duration: 0.3), value: imageLoaded)
资源准备这件事,我建议在动画设计阶段就纳入工作流。设计师在 Figma 里输出动画时,最好同时标明哪些元素需要矢量资源、哪些需要位图多档位,避免开发到一半才发现素材撑不起动画性能。
6. 综合实战:做一个跟手的卡片堆叠交互
到这里,基础工具都过了一遍。现在我们把它们组合起来,做一个类似"卡片堆叠滑动"的交互,很多社交类 App 都在用。
6.1 需求拆解与场景设计
需求很简单:
- 顶部展示一张卡片,剩余卡片在下面依次堆叠。
- 左右拖拽时,卡片会旋转、偏移,露出下层卡片。
- 松手后,如果位移超过阈值就飞出去;没超过就回弹。
- 飞出去后自动补充下一张。
在动手写代码前,先想清楚"主角"是谁。这个交互的主角是顶层卡片,下层卡片只需静态层叠。如果所有卡片都绑定同一个拖拽状态,会出现所有卡片一起跟着手指动的诡异效果。所以核心思路是:顶层卡片绑手势和偏移,下层卡片按 index 做固定偏移缩放。
6.2 核心实现与代码解析
swift复制struct SwipeCardView: View {
@State private var cards: [CardData] = CardData.sampleData
@State private var dragOffset: CGSize = .zero
@State private var dragAngle: Double = 0
var body: some View {
ZStack {
ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in
CardView(card: card)
.scaleEffect(index == 0 ? 1.0 : 0.95 - CGFloat(index) * 0.02)
.offset(x: index == 0 ? dragOffset.width : 0,
y: index == 0 ? dragOffset.height : CGFloat(index) * 8)
.rotationEffect(index == 0 ? .degrees(dragAngle) : .degrees(0))
.gesture(index == 0 ? dragGesture : nil)
}
}
}
var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
dragOffset = value.translation
dragAngle = Double(value.translation.width / 20)
}
.onEnded { value in
let threshold: CGFloat = 120
if abs(value.translation.width) > threshold ||
abs(value.predictedEndTranslation.width) > threshold * 2 {
// 飞出屏幕
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
dragOffset = CGSize(
width: value.translation.width * 3,
height: value.translation.height * 3
)
dragAngle = Double(value.translation.width / 10)
} completion: {
cards.removeFirst()
dragOffset = .zero
dragAngle = 0
}
} else {
// 回弹
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
dragOffset = .zero
dragAngle = 0
}
}
}
}
}
几个细节值得说明。
第一,旋转角度和位移的关系。我用了 dragOffset.width / 20,也就是每横向移动 20pt 旋转 1 度。这个比例手感比较舒服,你可以根据卡片宽度动态计算,更通用的公式是 拖拽距离 / 卡片宽度 * 最大角度。
第二,飞出动画的 completion 回调。iOS 17 之后多数动画 API 支持 completion 闭包,能替代 DispatchQueue.main.asyncAfter 延迟逻辑,代码更干净。在 completion 里移除卡片并重置状态,避免状态残留。
第三,ForEach 使用 Array(cards.enumerated()) 遍历,同时拿到 index 和 data。id 必须稳定唯一,否则移除卡片时 SwiftUI 的 diff 可能算出错误结果。
6.3 高级打磨:串行过渡与手感调节
上面这个版本已经能跑,但体验上还有几个可以打磨的点。
第一个是"补卡动画"。当飞出一张卡片后,下面所有卡片应该依次往前补位,加一点轻微的位移和缩放动画,整体会更生动。可以利用 scaleEffect 的 animation,给每个卡片按 index 绑定:
swift复制.animation(.spring(response: 0.4, dampingFraction: 0.9), value: cards.count)
当 cards.count 变化时,下层卡片的 scale 和 offset 会自动过渡。
第二个是"多段动画"。如果你希望卡片飞出后有"下一张卡片补位"的过场,可以用 KeyframeAnimator 做两段:先飞出,再补位。不过在卡片流场景里,completion 回调已经足够,不必为了用新 API 而用新 API。
第三个是手感调节。我发现卡片堆叠交互的手感主要取决于三个参数:
- 旋转比例(
width / 20)决定"翻动感"。 - 飞出阈值(
120)决定"灵敏 vs 稳重"。 - 回弹弹簧的
dampingFraction(0.8~0.9)决定"余震程度"。
这三个参数应该做成可调常量,方便真机调试时快速试手感,不要硬编码在逻辑里。
7. 常见问题速查与排查技巧
7.1 典型问题速查表
我把这几年在 SwiftUI 动画上遇到的典型问题整理成一张速查表,方便按图索骥。
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 动画完全不生效,视图瞬间跳到终态 | 状态变化没有被 withAnimation 包裹,或 .animation(_:value:) 的 value 绑定错误 |
确认动画上下文:显式用 withAnimation,隐式用 .animation 并绑对 value |
| 动画显示不全,出现一半就停了 | 视图在动画期间被移出层级,或父容器 |
