8.1 SwiftUI 动画的工作原理
SwiftUI 的动画系统是声明式的:你只需要告诉框架"状态从 A 变化到 B",SwiftUI 自动计算并绘制中间的过渡帧。你不需要像 UIKit 那样手动设置关键帧、调用 beginAnimations 等命令式 API。
SwiftUI 会比较状态变化前后的视图"快照",对于可动画的属性(位置、大小、颜色、透明度等),在两个状态之间自动插值产生动画效果。你只需改变状态,动画由框架负责。
可动画属性 vs 不可动画属性
| 可动画(Animatable) | 不可动画 |
|---|---|
opacity、scaleEffect | 视图的结构变化(添加/删除子视图) |
offset、rotationEffect | 字体大小(整数跳变) |
frame(宽高) | 视图类型本身 |
foregroundStyle(颜色) | 字符串内容 |
cornerRadius | 图片资源切换 |
8.2 withAnimation {} — 隐式动画
withAnimation { } 是最简单的动画方式——只需将状态改变包裹在 withAnimation 块中,所有依赖该状态的可动画属性都会自动产生过渡效果。
withAnimation 触发,影响所有因该状态变化而更新的视图属性。"隐式"指动画是自动传播的,不需要在每个视图上单独声明。
struct ToggleView: View {
@State private var isExpanded = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
.frame(width: isExpanded ? 300 : 100,
height: isExpanded ? 200 : 100)
.opacity(isExpanded ? 1.0 : 0.5)
Button("切换") {
// withAnimation 包裹状态改变,所有相关属性自动动画
withAnimation(.spring(duration: 0.5)) {
isExpanded.toggle()
}
}
}
}
}
// withAnimation 可以指定动画曲线
withAnimation(.easeInOut(duration: 0.3)) { isVisible = true }
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { scale = 1.2 }
withAnimation(.linear(duration: 2.0).repeatForever()) { rotation += 360 }
8.3 .animation() — 显式动画
.animation(_:value:) 修饰符绑定到特定值,只有当该值变化时才触发动画,更加精准可控。这是 SwiftUI 推荐的现代动画方式。
优先使用 .animation(_:value:) 显式动画,它只对特定值变化响应,不会意外触发其他属性的动画。withAnimation 则是"广播式"的,所有监听相关状态的视图都会响应。
struct HeartButton: View {
@State private var isLiked = false
@State private var count = 0
var body: some View {
VStack {
Image(systemName: isLiked ? "heart.fill" : "heart")
.font(.system(size: 44))
.foregroundStyle(isLiked ? .red : .gray)
.scaleEffect(isLiked ? 1.3 : 1.0)
// 显式动画:只在 isLiked 变化时执行弹簧动画
.animation(.spring(response: 0.3, dampingFraction: 0.5),
value: isLiked)
Text("\(count)")
.font(.title2)
// count 和 isLiked 有各自独立的动画
.animation(.easeOut(duration: 0.2), value: count)
}
.onTapGesture {
isLiked.toggle()
count += isLiked ? 1 : -1
}
}
}
8.4 动画曲线详解
动画曲线(Timing Curve)描述了属性值随时间变化的节奏——快慢、弹跳、线性等。选择合适的曲线能让动画感觉自然流畅。
| 动画类型 | 特点 | 适用场景 |
|---|---|---|
.easeIn | 慢→快(加速进入) | 元素退出屏幕 |
.easeOut | 快→慢(减速结束) | 元素进入屏幕,最自然 |
.easeInOut | 慢→快→慢(两头慢) | 通用过渡,默认推荐 |
.linear | 匀速 | 无限循环旋转、进度条 |
.spring() | 弹簧物理效果,有过冲和回弹 | 交互反馈、卡片展开 |
.bouncy | 预设弹跳效果(iOS 17+) | 按钮点击反馈 |
.snappy | 快速弹簧(iOS 17+) | 快速响应的UI操作 |
// spring() 参数详解
Animation.spring(
response: 0.4, // 弹簧"硬度",越小越快(推荐 0.3-0.6)
dampingFraction: 0.7, // 阻尼,1.0=无弹跳,0.0=永远弹跳(推荐 0.6-0.9)
blendDuration: 0 // 与上一个动画的混合时长
)
// iOS 17+ 新语法(更简洁)
Animation.spring(duration: 0.5, bounce: 0.3)
// bounce: 0=无弹跳,1=最大弹跳
// 动画修饰符
animation.delay(0.2) // 延迟 0.2 秒后开始
animation.speed(2.0) // 以 2 倍速播放
animation.repeatCount(3) // 重复 3 次
animation.repeatForever(autoreverse: true) // 无限循环+自动反转
// 实例:加载旋转动画
@State private var isAnimating = false
Image(systemName: "arrow.trianglehead.2.clockwise")
.rotationEffect(.degrees(isAnimating ? 360 : 0))
.animation(
.linear(duration: 1.0).repeatForever(autoreverse: false),
value: isAnimating
)
.onAppear { isAnimating = true }
8.5 Transition — 视图的出入场动画
当视图从视图层级中插入或移除时,.transition() 定义其出现/消失的动画效果。
if语句)使用,仅改变属性不会触发 transition。
struct TransitionDemo: View {
@State private var showCard = false
var body: some View {
VStack {
Button("显示/隐藏") {
withAnimation(.spring(duration: 0.4)) {
showCard.toggle()
}
}
if showCard {
RoundedRectangle(cornerRadius: 16)
.fill(.blue)
.frame(width: 200, height: 120)
// 出现:从底部滑入;消失:缩小消失
.transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .scale.combined(with: .opacity)
))
}
}
}
}
// 常用 Transition
.transition(.opacity) // 淡入淡出
.transition(.scale) // 缩放
.transition(.move(edge: .leading)) // 从左侧滑入/出
.transition(.slide) // 从左/右滑动(系统默认)
.transition(.scale(scale: 0.5, anchor: .top)) // 从顶部缩放展开
.transition(.push(from: .bottom)) // iOS 16+ 推入效果
// 组合多个 Transition
.transition(.scale.combined(with: .opacity)) // 同时缩放+淡出
8.6 自定义动画:ViewModifier 与 AnimatableModifier
当内置动画无法满足需求时,可以通过实现 Animatable 协议自定义可动画的属性。
// 自定义 ViewModifier:波浪文字抖动效果
struct WaveModifier: ViewModifier, Animatable {
var phase: Double // 这个属性会被动画插值
// animatableData 告诉 SwiftUI 哪个属性参与动画插值
var animatableData: Double {
get { phase }
set { phase = newValue }
}
func body(content: Content) -> some View {
content
.offset(y: sin(phase) * 5) // 垂直抖动幅度5pt
.opacity(1 - abs(sin(phase)) * 0.3)
}
}
extension View {
func wave(phase: Double) -> some View {
modifier(WaveModifier(phase: phase))
}
}
// 使用自定义动画
struct WaveText: View {
@State private var phase = 0.0
var body: some View {
Text("Hello SwiftUI!")
.font(.title)
.wave(phase: phase)
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever()) {
phase = .pi * 2
}
}
}
}
8.7 手势识别
SwiftUI 提供了一系列手势识别器,通过 .gesture() 修饰符附加到任何视图上。
.onTapGesture { } 是它的快捷方式。
// TapGesture:单击 + 双击
Image(systemName: "star")
.onTapGesture(count: 2) { // 双击
print("双击了!")
}
.onTapGesture { // 单击(放在双击之后)
print("单击了")
}
// LongPressGesture
@State private var isPressed = false
Circle()
.gesture(
LongPressGesture(minimumDuration: 1.0)
.onChanged { _ in isPressed = true } // 开始按下
.onEnded { _ in // 达到时长
isPressed = false
print("长按完成")
}
)
// DragGesture
@State private var offset: CGSize = .zero
Circle()
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation // 实时更新偏移
}
.onEnded { value in
// value.predictedEndTranslation: 基于速度预测的最终位置
withAnimation(.spring) { offset = .zero } // 弹回原位
}
)
// MagnificationGesture(捏合缩放)
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
Image("photo")
.scaleEffect(scale)
.gesture(
MagnificationGesture()
.onChanged { value in
scale = lastScale * value // 在上次比例基础上乘以当前手势比例
}
.onEnded { _ in
lastScale = scale // 保存最终比例
}
)
8.8 @GestureState — 手势临时状态
@GestureState 是专门为手势设计的状态属性,手势结束时自动重置为初始值,无需手动在 onEnded 里重置。
struct PressableButton: View {
// @GestureState: 手势进行时为 true,手势结束后自动回到 false
@GestureState private var isPressed = false
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(isPressed ? Color.blue.opacity(0.7) : Color.blue)
.frame(width: 200, height: 60)
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(.spring(response: 0.2), value: isPressed)
.gesture(
LongPressGesture(minimumDuration: 0.01)
// updating: 手势进行中持续更新 @GestureState
.updating($isPressed) { value, state, _ in
state = value // state 是 @GestureState 的引用
}
)
}
}
// DragGesture + @GestureState 组合
struct DraggableCircle: View {
@GestureState private var dragOffset: CGSize = .zero // 手势结束自动归零
@State private var position: CGSize = .zero // 持久化最终位置
var body: some View {
Circle()
.frame(width: 60)
.offset(x: position.width + dragOffset.width,
y: position.height + dragOffset.height)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation // 实时拖动偏移
}
.onEnded { value in
// 手势结束:dragOffset 自动归零,累加到 position
position.width += value.translation.width
position.height += value.translation.height
}
)
}
}
8.9 手势组合
SwiftUI 支持将多个手势组合使用,实现复杂的交互逻辑。
// 同时支持拖动 + 捏合缩放(如图片查看器)
struct PhotoViewer: View {
@GestureState private var dragState: CGSize = .zero
@GestureState private var magnifyBy: CGFloat = 1.0
let dragGesture = DragGesture()
.updating($dragState) { val, state, _ in state = val.translation }
let pinchGesture = MagnificationGesture()
.updating($magnifyBy) { val, state, _ in state = val }
var body: some View {
Image("landscape")
.resizable()
.scaledToFit()
.offset(dragState)
.scaleEffect(magnifyBy)
.gesture(dragGesture.simultaneously(with: pinchGesture))
}
}
// 先长按,再拖动(模拟 iOS 主屏抖动模式)
struct LongPressThenDrag: View {
@GestureState private var isLongPressed = false
@GestureState private var dragOffset: CGSize = .zero
var body: some View {
let longPress = LongPressGesture(minimumDuration: 0.5)
.updating($isLongPressed) { val, state, _ in state = val }
let drag = DragGesture()
.updating($dragOffset) { val, state, _ in state = val.translation }
// 必须先长按成功,才能开始拖动
let combined = longPress.sequenced(before: drag)
RoundedRectangle(cornerRadius: 12)
.fill(isLongPressed ? .orange : .blue)
.frame(width: 80, height: 80)
.offset(isLongPressed ? dragOffset : .zero)
.scaleEffect(isLongPressed ? 1.1 : 1.0)
.animation(.spring, value: isLongPressed)
.gesture(combined)
}
}
8.10 实战示例:可拖动卡片 + 点击翻转
将本章知识点整合,实现一个可拖动、点击后翻转展示背面信息的卡片组件。
// 翻转卡片视图
struct FlipCard: View {
let frontColor: Color
let backContent: String
@State private var isFlipped = false
@State private var degrees = 0.0
var body: some View {
ZStack {
// 正面
RoundedRectangle(cornerRadius: 16)
.fill(frontColor)
.overlay {
VStack {
Image(systemName: "questionmark.circle")
.font(.system(size: 40))
Text("点击翻转").font(.caption)
}
.foregroundStyle(.white)
}
.opacity(isFlipped ? 0 : 1)
// 背面(需要水平镜像,否则文字会反向)
RoundedRectangle(cornerRadius: 16)
.fill(.indigo)
.overlay {
Text(backContent)
.font(.body)
.multilineTextAlignment(.center)
.foregroundStyle(.white)
.padding()
}
.opacity(isFlipped ? 1 : 0)
.rotation3DEffect(.degrees(180), axis: (0, 1, 0))
}
.frame(width: 200, height: 280)
// 卡片整体的3D翻转
.rotation3DEffect(
.degrees(degrees),
axis: (0, 1, 0), // 绕Y轴旋转
perspective: 0.5 // 透视深度,产生3D感
)
.onTapGesture {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
degrees += 180
isFlipped.toggle()
}
}
}
}
// 可拖动、可吸附边缘的卡片
struct DraggableCard: View {
@State private var offset: CGSize = .zero
@State private var isDragging = false
var body: some View {
GeometryReader { proxy in
FlipCard(frontColor: .blue, backContent: "这是卡片背面的内容!")
.shadow(radius: isDragging ? 20 : 8)
.scaleEffect(isDragging ? 1.05 : 1.0)
.offset(offset)
.animation(.spring(response: 0.3), value: isDragging)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
isDragging = false
let screenWidth = proxy.size.width
// 判断是否滑过一半,决定是否移出屏幕
if abs(value.predictedEndTranslation.width) > screenWidth / 2 {
withAnimation(.easeOut(duration: 0.3)) {
offset.width = value.predictedEndTranslation.width > 0
? screenWidth : -screenWidth // 飞出
}
} else {
withAnimation(.spring) {
offset = .zero // 弹回中心
}
}
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
}
SwiftUI 动画系统的精髓:状态驱动,声明式描述。掌握 withAnimation 和 .animation(value:) 两种触发方式,熟悉各种动画曲线(尤其是 .spring()),合理使用 .transition 处理视图进出场,用 @GestureState 简化手势临时状态管理。下一章我们将学习如何持久化数据。