Chapter 08

动画与手势

声明式动画、弹簧动效、手势识别——让你的 App 拥有灵动自然的交互体验

8.1 SwiftUI 动画的工作原理

SwiftUI 的动画系统是声明式的:你只需要告诉框架"状态从 A 变化到 B",SwiftUI 自动计算并绘制中间的过渡帧。你不需要像 UIKit 那样手动设置关键帧、调用 beginAnimations 等命令式 API。

ℹ️
声明式动画的核心思路

SwiftUI 会比较状态变化前后的视图"快照",对于可动画的属性(位置、大小、颜色、透明度等),在两个状态之间自动插值产生动画效果。你只需改变状态,动画由框架负责。

可动画属性 vs 不可动画属性

可动画(Animatable)不可动画
opacityscaleEffect视图的结构变化(添加/删除子视图)
offsetrotationEffect字体大小(整数跳变)
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 推荐的现代动画方式。

💡
显式 vs 隐式动画的选择

优先使用 .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() 定义其出现/消失的动画效果。

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() 修饰符附加到任何视图上。

TapGesture(点击) 单击或多击。.onTapGesture { } 是它的快捷方式。
LongPressGesture(长按) 按住超过指定时长触发。可配置最小时长(默认0.5秒)和最大移动距离。
DragGesture(拖动) 手指拖动,提供起始位置、当前位置、速度等信息。
MagnificationGesture(捏合缩放) 双指捏合/展开,返回放大倍数(1.0 为原始大小)。
RotationGesture(旋转) 双指旋转,返回旋转角度。
// 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 支持将多个手势组合使用,实现复杂的交互逻辑。

.simultaneously(with:) 两个手势同时响应,互不干扰。例如同时支持拖动和捏合缩放。
.sequenced(before:) 第一个手势成功后,才开始识别第二个手势。例如"先长按,再拖动"。
.exclusively(before:) 排他模式:第一个手势优先,若第一个手势失败才尝试第二个。
// 同时支持拖动 + 捏合缩放(如图片查看器)
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 简化手势临时状态管理。下一章我们将学习如何持久化数据。