【SwiftUI娓娓道来】第3课:让界面活起来——交互与动画指南

开篇:静态的界面是画,动态的界面是生命

前两课,我们已经走过了 SwiftUI 的入门阶段。第1课认识了积木:Text、Image、Button、VStack、List、NavigationStack。第2课学会了布局:frame、padding、Spacer、LazyVGrid、Layout 协议。现在,你已经能做出一个像模像样的静态页面了。

但是,如果你把这样的 App 拿给别人看,对方点一下按钮,没反应;滑一下卡片,没动静;切换一个开关,界面生硬地跳变。你会觉得少了点什么。

少了什么?少了交互 和动画。

交互让用户能操作界面,动画让操作有反馈。没有交互,App 就是一张画;有了交互和动画,App 才有了生命。用户点一下按钮,按钮轻轻缩小再弹回来;用户滑一下卡片,卡片跟着手指移动;用户切换一个开关,颜色平滑过渡。这些细节看起来小,但它们决定了 App 是"能用"还是"好用"。

这一课,我们就专门聊两件事:交互 和动画 。而且我们要聊得很深、很细。因为这两块内容,是 SwiftUI 最能体现"声明式思维"的地方。你写 UIKit 动画时,要手动创建 CABasicAnimation,设置 fromValue、toValue、duration、timingFunction,然后加到 layer 上。SwiftUI 里,你只要改变一个状态,然后告诉系统"这里要动画",剩下的它全包了。

但"简单"不等于"浅"。SwiftUI 的动画系统背后有很多值得理解的概念:动画曲线、弹簧物理、转场、相位、关键帧、matchedGeometryEffect、触觉反馈。理解得越深,你越能做出细腻、自然、有质感的交互。

我们会从最简单的按钮讲起,一路讲到手势、动画曲线、转场、弹簧、相位、关键帧、触觉反馈、交互式动画、共享元素过渡。每一节都有可以直接跑的代码,你可以边看边在 Xcode 里改。改一个参数,看一眼预览,你就懂了。

这一课会很长,但不会难。因为 SwiftUI 的交互和动画,本质上是在用代码描述"变化",而不是在手动计算每一帧。你只要理解了它的思路,剩下的就是多写多试。

好,我们开始。


一、Button:最基本的交互,也是最深的一课

1.1 从最简单的按钮开始

swift 复制代码
Button("点我") {
    print("按钮被点击了")
}

这就是 SwiftUI 里最简单的按钮。第一个参数是标题,第二个参数是点击后执行的闭包。

你可以把它放进任何容器里:

swift 复制代码
VStack {
    Text("欢迎来到 SwiftUI")
        .font(.title)

    Button("开始探索") {
        print("开始探索")
    }
    .buttonStyle(.borderedProminent)
}
.padding()

.buttonStyle(.borderedProminent) 会给按钮一个明显的背景色,让它看起来像一个主要操作按钮。

1.2 自定义标签的按钮

很多时候,你不想用纯文字当按钮标签,而是想用图标、图片、或者复杂的视图。这时可以用带 label 的写法:

swift 复制代码
Button {
    print("添加")
} label: {
    Label("添加", systemImage: "plus")
}
.buttonStyle(.bordered)

或者:

swift 复制代码
Button {
    print("喜欢")
} label: {
    Image(systemName: "heart.fill")
        .font(.title)
        .foregroundStyle(.red)
}

这种写法的好处是:label 里可以放任何 View,你想怎么设计就怎么设计。

你甚至可以在按钮里放一整个卡片:

swift 复制代码
Button {
    print("卡片被点击")
} label: {
    VStack(alignment: .leading, spacing: 8) {
        Image(systemName: "photo")
            .font(.largeTitle)
            .foregroundStyle(.blue)

        Text("标题")
            .font(.headline)

        Text("这是一段描述文字,说明了卡片的内容。")
            .font(.subheadline)
            .foregroundStyle(.secondary)
    }
    .padding()
    .frame(maxWidth: .infinity, alignment: .leading)
    .background(.blue.opacity(0.1))
    .clipShape(RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)

注意最后用了 .buttonStyle(.plain),这样按钮不会给卡片加上默认的蓝色文字或者高亮效果。卡片本身就是按钮的全部外观。

1.3 按钮的样式

SwiftUI 内置了几种按钮样式:

swift 复制代码
Button("默认") {}
Button("边框") {}
    .buttonStyle(.bordered)
Button("突出") {}
    .buttonStyle(.borderedProminent)
Button("无边框") {}
    .buttonStyle(.plain)

你还可以用 .tint() 改变按钮颜色:

swift 复制代码
Button("删除") {
    // 删除操作
}
.buttonStyle(.borderedProminent)
.tint(.red)

你甚至可以自定义按钮样式:

swift 复制代码
struct ScaleButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
            .opacity(configuration.isPressed ? 0.8 : 1.0)
            .animation(.easeOut(duration: 0.15), value: configuration.isPressed)
    }
}

Button("按我") {
    print("点击")
}
.buttonStyle(ScaleButtonStyle())

ButtonStyle 的 makeBody 方法会收到一个 configuration,里面有 label 和 isPressed。你可以根据 isPressed 改变外观,做出按下缩小的效果。这是自定义按钮最强大的方式。

1.4 按钮与状态

按钮最常见的用途是改变状态:

swift 复制代码
struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack(spacing: 20) {
            Text("当前计数:\(count)")
                .font(.title2)
                .monospacedDigit()

            HStack(spacing: 16) {
                Button {
                    count -= 1
                } label: {
                    Image(systemName: "minus.circle.fill")
                        .font(.title)
                }
                .buttonStyle(.bordered)

                Button {
                    count += 1
                } label: {
                    Image(systemName: "plus.circle.fill")
                        .font(.title)
                }
                .buttonStyle(.borderedProminent)

                Button("重置") {
                    count = 0
                }
                .buttonStyle(.bordered)
                .tint(.red)
            }
        }
        .padding()
    }
}

.monospacedDigit() 让数字等宽,这样计数变化时文字不会左右抖动。这是做计数器、计时器的小技巧。

1.5 按钮的禁用状态

swift 复制代码
Button("提交") {
    // 提交
}
.disabled(username.isEmpty)

.disabled(true) 会让按钮变灰,并且不响应点击。这在表单里非常常用。

你也可以用 .opacity() 手动控制:

swift 复制代码
Button("提交") {
    // 提交
}
.opacity(username.isEmpty ? 0.5 : 1.0)
.disabled(username.isEmpty)

1.6 按钮的角色

swift 复制代码
Button("删除", role: .destructive) {
    // 删除
}

Button("取消", role: .cancel) {
    // 取消
}

role 会影响按钮的样式和语义。.destructive 通常是红色,.cancel 用于取消操作。这在 alert 和 confirmationDialog 里特别重要。

1.7 按钮的点击区域

默认情况下,按钮的点击区域就是它内容的大小。如果你想让按钮更大、更容易点,可以加 .padding() 和 .frame():

swift 复制代码
Button {
    print("点击")
} label: {
    Text("宽按钮")
        .frame(maxWidth: .infinity)
        .padding()
}
.buttonStyle(.borderedProminent)

frame(maxWidth: .infinity) 让按钮占满宽度,.padding() 让内容有内边距,点击区域也跟着变大。这在移动端很重要,因为手指没有鼠标那么精确。苹果的人机界面指南建议,可点击区域至少 44x44 点。

1.8 按钮与异步任务

按钮经常用来触发异步操作,比如网络请求:

swift 复制代码
struct LoadButtonView: View {
    @State private var isLoading = false
    @State private var result: String?

    var body: some View {
        VStack(spacing: 20) {
            Button {
                Task {
                    await loadData()
                }
            } label: {
                if isLoading {
                    ProgressView()
                        .progressViewStyle(.circular)
                } else {
                    Text("加载数据")
                }
            }
            .buttonStyle(.borderedProminent)
            .disabled(isLoading)

            if let result {
                Text(result)
                    .foregroundStyle(.secondary)
            }
        }
        .padding()
    }

    func loadData() async {
        isLoading = true
        defer { isLoading = false }

        try? await Task.sleep(nanoseconds: 1_500_000_000)
        result = "数据加载完成"
    }
}

按钮在加载时显示 ProgressView,并禁用自己,防止重复点击。这是移动端非常常见的模式。

1.9 按钮的图标动画

swift 复制代码
struct AnimatedIconButtonView: View {
    @State private var isRotated = false

    var body: some View {
        Button {
            withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                isRotated.toggle()
            }
        } label: {
            Image(systemName: "arrow.clockwise")
                .font(.title)
                .rotationEffect(.degrees(isRotated ? 360 : 0))
        }
        .buttonStyle(.bordered)
    }
}

点击按钮,图标旋转 360 度。这种小动画在刷新、切换、展开等操作里非常讨喜。


二、手势:让用户直接用手指说话

按钮是"点"的交互,但移动端还有更多手势:拖拽、长按、双击、旋转、缩放。SwiftUI 提供了一组手势识别器,你可以把它们附加到任何 View 上。

2.1 onTapGesture:点击手势

最简单的点击手势:

swift 复制代码
Text("点我")
    .padding()
    .background(.blue.opacity(0.2))
    .clipShape(RoundedRectangle(cornerRadius: 8))
    .onTapGesture {
        print("文字被点击了")
    }

双击:

swift 复制代码
Text("双击我")
    .onTapGesture(count: 2) {
        print("双击了")
    }

你甚至可以组合单击和双击,但要注意:双击会先触发一次单击。所以通常你会用 exclusively(before:) 来让它们互斥:

swift 复制代码
Text("单击或双击")
    .gesture(
        TapGesture(count: 2)
            .onEnded { print("双击") }
            .exclusively(before:
                TapGesture(count: 1)
                    .onEnded { print("单击") }
            )
    )

2.2 onLongPressGesture:长按手势

swift 复制代码
Text("长按我")
    .onLongPressGesture {
        print("长按了")
    }

指定最短按压时间:

swift 复制代码
Text("按住 1 秒")
    .onLongPressGesture(minimumDuration: 1.0) {
        print("按住了一秒")
    }

长按过程中还可以监听按压变化:

swift 复制代码
struct LongPressProgressView: View {
    @State private var isPressing = false
    @State private var progress: CGFloat = 0

    var body: some View {
        ZStack {
            Circle()
                .stroke(.gray.opacity(0.3), lineWidth: 6)

            Circle()
                .trim(from: 0, to: progress)
                .stroke(.blue, style: StrokeStyle(lineWidth: 6, lineCap: .round))
                .rotationEffect(.degrees(-90))

            Image(systemName: "hand.tap.fill")
                .font(.title)
                .foregroundStyle(isPressing ? .blue : .gray)
        }
        .frame(width: 100, height: 100)
        .onLongPressGesture(minimumDuration: 2.0) {
            print("长按完成")
            progress = 0
        } onPressingChanged: { pressing in
            isPressing = pressing
            if pressing {
                withAnimation(.linear(duration: 2.0)) {
                    progress = 1.0
                }
            } else {
                withAnimation(.easeOut(duration: 0.2)) {
                    progress = 0
                }
            }
        }
    }
}

onPressingChanged 会在手指按下和松开时触发。你可以用它做长按进度动画。

2.3 onTapGesture 与 Button 的区别

你可能会问:既然有了 Button,为什么还要 onTapGesture?

区别在于:

  • Button 有默认的点击反馈(高亮、变暗),有无障碍支持,有按钮语义;
  • onTapGesture 更轻,适合给任意 View 加点击行为,但不会自动提供按钮语义。

经验法则:

  • 如果是一个"操作",用 Button;
  • 如果是一个"区域",比如卡片、图片、文字,用 onTapGesture 更自然。

2.4 DragGesture:拖拽手势

拖拽是移动端最有表现力的手势之一。我们做一个可以拖动的小方块:

swift 复制代码
struct DraggableView: View {
    @State private var offset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.blue.gradient)
            .frame(width: 100, height: 100)
            .offset(offset)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                    }
                    .onEnded { value in
                        withAnimation(.spring()) {
                            offset = .zero
                        }
                    }
            )
    }
}

DragGesture 有三个重要回调:

  • onChanged:手指移动时持续触发;
  • onEnded:手指松开时触发;
  • value.translation:从起点到当前位置的位移。

这个例子中,方块跟着手指移动,松手后弹回原位。withAnimation(.spring()) 让回弹有弹簧效果。

DragGesture 的 value 里还有很多有用的信息:

  • value.startLocation:手势开始时的位置;
  • value.location:当前位置;
  • value.translation:从起点到当前位置的位移;
  • value.predictedEndTranslation:预测的最终位移,可以用来做惯性滑动。

2.5 用预测位移做惯性效果

swift 复制代码
struct InertiaDragView: View {
    @State private var offset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.purple.gradient)
            .frame(width: 100, height: 100)
            .offset(offset)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                    }
                    .onEnded { value in
                        let predicted = value.predictedEndTranslation
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
                            offset = CGSize(
                                width: predicted.width * 0.5,
                                height: predicted.height * 0.5
                            )
                        }
                    }
            )
    }
}

松手后,方块会按照预测方向继续滑一段距离,然后停下。这就是惯性效果。

2.6 限制拖拽范围

swift 复制代码
struct BoundedDragView: View {
    @State private var offset = CGSize.zero

    var body: some View {
        GeometryReader { geo in
            RoundedRectangle(cornerRadius: 16)
                .fill(.green.gradient)
                .frame(width: 80, height: 80)
                .offset(offset)
                .gesture(
                    DragGesture()
                        .onChanged { value in
                            let maxX = geo.size.width - 80
                            let maxY = geo.size.height - 80
                            offset = CGSize(
                                width: min(max(0, value.translation.width), maxX),
                                height: min(max(0, value.translation.height), maxY)
                            )
                        }
                )
        }
        .frame(height: 300)
        .background(.gray.opacity(0.1))
    }
}

这里用 min 和 max 把拖拽范围限制在容器内。GeometryReader 提供了容器尺寸。

2.7 用拖拽做滑动删除效果

swift 复制代码
struct SwipeToDeleteView: View {
    @State private var offset: CGFloat = 0
    @State private var isDeleted = false

    var body: some View {
        if isDeleted {
            Text("已删除")
                .foregroundStyle(.secondary)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
        } else {
            ZStack(alignment: .trailing) {
                Text("删除")
                    .foregroundStyle(.white)
                    .frame(maxWidth: .infinity, alignment: .trailing)
                    .padding()
                    .background(.red)

                HStack {
                    Text("向左滑动删除")
                    Spacer()
                }
                .padding()
                .background(.white)
                .offset(x: offset)
                .gesture(
                    DragGesture()
                        .onChanged { value in
                            if value.translation.width < 0 {
                                offset = value.translation.width
                            }
                        }
                        .onEnded { value in
                            if value.translation.width < -100 {
                                withAnimation(.easeOut) {
                                    isDeleted = true
                                }
                            } else {
                                withAnimation(.spring()) {
                                    offset = 0
                                }
                            }
                        }
                )
            }
            .clipped()
        }
    }
}

这个例子展示了拖拽手势的经典用法:跟手滑动、阈值判断、动画过渡。真实的 List 已经内置了 .onDelete,但理解这个原理对做自定义交互非常有帮助。

2.8 MagnificationGesture:缩放手势

swift 复制代码
struct ZoomImageView: View {
    @State private var scale: CGFloat = 1.0
    @State private var lastScale: CGFloat = 1.0

    var body: some View {
        Image(systemName: "photo.fill")
            .font(.system(size: 80))
            .foregroundStyle(.blue)
            .scaleEffect(scale)
            .gesture(
                MagnificationGesture()
                    .onChanged { value in
                        scale = lastScale * value
                    }
                    .onEnded { _ in
                        lastScale = scale
                        if scale < 1.0 {
                            withAnimation(.spring()) {
                                scale = 1.0
                                lastScale = 1.0
                            }
                        } else if scale > 3.0 {
                            withAnimation(.spring()) {
                                scale = 3.0
                                lastScale = 3.0
                            }
                        }
                    }
            )
    }
}

MagnificationGesture 的 value 是相对于手势开始的缩放倍数。我们用 lastScale 记录上一次的缩放值,这样连续缩放不会跳变。

2.9 RotationGesture:旋转手势

swift 复制代码
struct RotateView: View {
    @State private var angle: Angle = .zero
    @State private var lastAngle: Angle = .zero

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.orange.gradient)
            .frame(width: 150, height: 150)
            .rotationEffect(angle)
            .gesture(
                RotationGesture()
                    .onChanged { value in
                        angle = lastAngle + value
                    }
                    .onEnded { _ in
                        lastAngle = angle
                    }
            )
    }
}

RotationGesture 的 value 是旋转角度。同样用 lastAngle 记录上一次的角度。

2.10 组合手势:同时缩放和旋转

swift 复制代码
struct TransformGestureView: View {
    @State private var scale: CGFloat = 1.0
    @State private var angle: Angle = .zero
    @State private var lastScale: CGFloat = 1.0
    @State private var lastAngle: Angle = .zero

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.purple.gradient)
            .frame(width: 150, height: 150)
            .scaleEffect(scale)
            .rotationEffect(angle)
            .gesture(
                SimultaneousGesture(
                    MagnificationGesture()
                        .onChanged { value in
                            scale = lastScale * value
                        }
                        .onEnded { _ in
                            lastScale = scale
                        },
                    RotationGesture()
                        .onChanged { value in
                            angle = lastAngle + value
                        }
                        .onEnded { _ in
                            lastAngle = angle
                        }
                )
            )
    }
}

SimultaneousGesture 让两个手势同时识别。你可以用双指同时缩放和旋转方块。

2.11 组合手势:先长按再拖拽

swift 复制代码
struct LongPressDragView: View {
    @State private var offset = CGSize.zero
    @State private var isActive = false

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(isActive ? .green.gradient : .gray.gradient)
            .frame(width: 100, height: 100)
            .offset(offset)
            .scaleEffect(isActive ? 1.1 : 1.0)
            .gesture(
                LongPressGesture(minimumDuration: 0.3)
                    .sequenced(before: DragGesture())
                    .onChanged { value in
                        switch value {
                        case .first(true):
                            withAnimation(.spring()) {
                                isActive = true
                            }
                        case .second(true, let drag):
                            offset = drag?.translation ?? .zero
                        default:
                            break
                        }
                    }
                    .onEnded { _ in
                        withAnimation(.spring()) {
                            isActive = false
                            offset = .zero
                        }
                    }
            )
    }
}

sequenced(before:) 让手势按顺序识别:先长按,再拖拽。这种交互在拖动排序、拖放操作里很常见。

2.12 手势的优先级

当多个手势叠加时,可以用 .highPriorityGesture() 和 .simultaneousGesture() 控制优先级:

swift 复制代码
Rectangle()
    .fill(.blue)
    .frame(width: 200, height: 200)
    .onTapGesture {
        print("普通点击")
    }
    .highPriorityGesture(
        TapGesture()
            .onEnded {
                print("高优先级点击")
            }
    )

高优先级手势会先被识别。这在嵌套交互里很重要,比如一个卡片里既有整体点击,又有内部按钮。

2.13 手势与 ScrollView 的冲突

在 ScrollView 里放拖拽手势,可能会和滚动冲突。常见的解决办法:

swift 复制代码
ScrollView {
    // 内容
}
.simultaneousGesture(
    DragGesture()
        .onChanged { value in
            // 处理拖拽
        }
)

或者用 .highPriorityGesture() 让拖拽优先:

swift 复制代码
.gesture(
    DragGesture()
        .onChanged { value in
            // 处理拖拽
        }
)

如果手势只在水平方向有效,可以判断方向:

swift 复制代码
DragGesture()
    .onChanged { value in
        let horizontal = abs(value.translation.width)
        let vertical = abs(value.translation.height)
        if horizontal > vertical {
            // 水平拖拽
        }
    }

三、动画的起点:withAnimation

3.1 没有动画的世界

swift 复制代码
struct NoAnimationView: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 16)
                .fill(.blue)
                .frame(
                    width: isExpanded ? 300 : 100,
                    height: isExpanded ? 200 : 100
                )

            Button("切换") {
                isExpanded.toggle()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

点击按钮,矩形会"啪"地一下从 100x100 变成 300x200。没有过渡,很生硬。

3.2 加上 withAnimation

swift 复制代码
Button("切换") {
    withAnimation {
        isExpanded.toggle()
    }
}

只加了一个 withAnimation,矩形就会平滑地从小变大。SwiftUI 自动帮你补间了宽度和高度。

withAnimation 的作用是:在这个闭包里发生的状态变化,都会以动画的形式呈现。

3.3 指定动画曲线

swift 复制代码
withAnimation(.easeInOut(duration: 0.5)) {
    isExpanded.toggle()
}

常用动画曲线:

  • .linear:匀速;
  • .easeIn:慢进快出;
  • .easeOut:快进慢出;
  • .easeInOut:慢进慢出;
  • .spring():弹簧效果;
  • .bouncy:更弹的弹簧;
  • .smooth:平滑过渡。

例子:

swift 复制代码
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
    isExpanded.toggle()
}

response 控制弹簧的响应速度,越小越快;dampingFraction 控制阻尼,越小越弹。

3.4 动画的延迟和重复

swift 复制代码
withAnimation(
    .easeInOut(duration: 0.5)
    .delay(0.2)
    .repeatCount(3, autoreverses: true)
) {
    isExpanded.toggle()
}
  • .delay(0.2):延迟 0.2 秒开始;
  • .repeatCount(3, autoreverses: true):重复 3 次,来回播放;
  • .repeatForever(autoreverses: true):永远重复。

3.5 多个状态一起动画

swift 复制代码
struct MultiStateAnimationView: View {
    @State private var isOn = false

    var body: some View {
        VStack(spacing: 24) {
            RoundedRectangle(cornerRadius: isOn ? 50 : 16)
                .fill(isOn ? .green : .blue)
                .frame(
                    width: isOn ? 200 : 100,
                    height: isOn ? 200 : 100
                )
                .rotationEffect(.degrees(isOn ? 180 : 0))

            Toggle("开关", isOn: $isOn)
                .padding(.horizontal)
        }
        .padding()
        .animation(.spring(response: 0.5, dampingFraction: 0.7), value: isOn)
    }
}

这里用了 .animation(_:value:),它会把绑定到 isOn 的所有变化都加上动画。开关一拨,圆角、颜色、尺寸、旋转同时变化,非常流畅。

3.6 withAnimation 与 .animation 的区别

  • withAnimation:写在状态变化的地方,只影响这次变化;
  • .animation(_:value:):写在 View 上,只要 value 变化,就自动动画。

经验法则:

  • 如果是按钮触发的、一次性的变化,用 withAnimation;
  • 如果是绑定到某个状态、持续变化,用 .animation(_:value:)。

两者可以混用,但不要过度。动画太多会让人眼花缭乱。

3.7 动画的范围控制

withAnimation 会把它闭包内所有状态变化都加上动画。如果你只想让某一部分动画,把状态变化分开:

swift 复制代码
withAnimation(.spring()) {
    scale = 1.2
}
// 其他状态变化不加动画
opacity = 0.5

或者用 .animation(_:value:) 精确绑定某个值:

swift 复制代码
Text("只对这个值动画")
    .scaleEffect(scale)
    .animation(.spring(), value: scale)

3.8 动画的嵌套

swift 复制代码
withAnimation(.easeInOut(duration: 0.5)) {
    withAnimation(.spring()) {
        scale = 1.5
    }
    rotation = 180
}

内层 withAnimation 会覆盖外层的动画曲线。但通常不建议这样嵌套,容易混乱。


四、动画曲线:理解时间和节奏

4.1 线性动画

swift 复制代码
withAnimation(.linear(duration: 1.0)) {
    position += 100
}

线性动画匀速运动,适合进度条、加载指示器。

4.2 缓入缓出

swift 复制代码
withAnimation(.easeIn(duration: 0.5)) {
    opacity = 0
}

withAnimation(.easeOut(duration: 0.5)) {
    opacity = 1
}

withAnimation(.easeInOut(duration: 0.5)) {
    scale = 1.5
}
  • .easeIn:慢进快出,适合元素消失;
  • .easeOut:快进慢出,适合元素出现;
  • .easeInOut:慢进慢出,适合位置、大小变化。

4.3 自定义贝塞尔曲线

swift 复制代码
withAnimation(.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.5)) {
    position += 100
}

timingCurve 接受四个控制点,可以做出非常精确的动画节奏。适合需要和设计稿严格对齐的场景。

4.4 弹簧动画

swift 复制代码
withAnimation(.spring()) {
    scale = 1.2
}

withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
    scale = 1.2
}

withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
    scale = 1.2
}
  • .spring():默认弹簧,适合大部分场景;
  • response / dampingFraction:精细控制;
  • duration / bounce:更直观的新 API。

4.5 bouncy 和 smooth

swift 复制代码
withAnimation(.bouncy) {
    isLiked.toggle()
}

withAnimation(.smooth) {
    isExpanded.toggle()
}

.bouncy 是预设的弹簧效果,适合点赞、切换、弹窗;.smooth 是平滑过渡,适合颜色、透明度、位置变化。


五、transition:视图的出现与消失

5.1 默认的插入和移除

swift 复制代码
struct TransitionView: View {
    @State private var showDetail = false

    var body: some View {
        VStack {
            Button(showDetail ? "隐藏" : "显示") {
                withAnimation {
                    showDetail.toggle()
                }
            }
            .buttonStyle(.borderedProminent)

            if showDetail {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.blue.gradient)
                    .frame(height: 200)
                    .padding()
            }
        }
    }
}

点击按钮,矩形会出现或消失。默认情况下,SwiftUI 会使用淡入淡出。但你可以自定义过渡效果。

5.2 常用过渡

swift 复制代码
if showDetail {
    RoundedRectangle(cornerRadius: 16)
        .fill(.blue.gradient)
        .frame(height: 200)
        .padding()
        .transition(.slide)
}

可选过渡:

  • .opacity:淡入淡出;
  • .slide:从边缘滑入;
  • .scale:缩放;
  • .move(edge: .top):从顶部移入;
  • .asymmetric(insertion: .slide, removal: .opacity):插入和移除用不同效果;
  • .combined(with:):组合多个过渡。

例子:

swift 复制代码
.transition(.move(edge: .bottom).combined(with: .opacity))

从底部滑入,同时淡入。

5.3 不对称过渡

swift 复制代码
.transition(
    .asymmetric(
        insertion: .move(edge: .top).combined(with: .opacity),
        removal: .move(edge: .bottom).combined(with: .opacity)
    )
)

插入时从顶部滑入,移除时从底部滑出。这种效果在通知、提示条里很常见。

5.4 自定义过渡

swift 复制代码
struct FlipTransition: ViewModifier {
    var progress: Double

    func body(content: Content) -> some View {
        content
            .rotation3DEffect(
                .degrees(progress * 180),
                axis: (x: 0, y: 1, z: 0)
            )
            .opacity(progress)
    }
}

extension AnyTransition {
    static var flip: AnyTransition {
        .modifier(
            active: FlipTransition(progress: 0),
            identity: FlipTransition(progress: 1)
        )
    }
}

使用:

swift 复制代码
.transition(.flip)

自定义过渡可以做出非常酷的效果,比如翻转、折叠、爆炸等。

5.5 transition 和 animation 的配合

.transition 只定义"出现和消失的方式",具体动画曲线由触发状态变化时的 withAnimation 或 .animation 决定。

swift 复制代码
Button("切换") {
    withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
        showDetail.toggle()
    }
}

这样,过渡效果和动画曲线就搭配起来了。


六、弹簧动画:让界面有生命感

6.1 为什么弹簧动画好看?

因为真实世界的东西,很少是匀速运动的。一个球掉到地上会弹几下,一扇门被推开会有惯性,一张卡片被拖动会有回弹。弹簧动画模拟了这种物理感,让界面看起来更自然、更有生命。

SwiftUI 的弹簧动画有几种写法:

swift 复制代码
.spring()
.spring(response: 0.5, dampingFraction: 0.7)
.spring(duration: 0.6, bounce: 0.4)
.interpolatingSpring(stiffness: 200, damping: 20)
.bouncy
.smooth

6.2 response 和 dampingFraction

  • response:弹簧的响应时间,越小越快,越大越慢;
  • dampingFraction:阻尼系数,0 到 1 之间。越小越弹,越大越稳。
swift 复制代码
withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
    scale = 1.2
}

这个组合会快速弹到 1.2 倍,并且有点回弹。

6.3 duration 和 bounce

新的 API 更直观:

swift 复制代码
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
    isExpanded.toggle()
}
  • duration:动画持续时间;
  • bounce:弹跳程度,0 表示不弹,越大越弹。

推荐新手用这种写法,更容易理解。

6.4 bouncy 和 smooth

swift 复制代码
withAnimation(.bouncy) {
    isLiked.toggle()
}

withAnimation(.smooth) {
    isExpanded.toggle()
}

.bouncy 是预设的弹簧效果,适合点赞、切换、弹窗;.smooth 是平滑过渡,适合颜色、透明度、位置变化。

6.5 一个点赞按钮

swift 复制代码
struct LikeButtonView: View {
    @State private var isLiked = false

    var body: some View {
        Button {
            withAnimation(.bouncy) {
                isLiked.toggle()
            }
        } label: {
            Image(systemName: isLiked ? "heart.fill" : "heart")
                .font(.system(size: 40))
                .foregroundStyle(isLiked ? .red : .gray)
                .scaleEffect(isLiked ? 1.2 : 1.0)
        }
    }
}

点击时心形放大、变红,松手后弹回。非常简单的代码,效果却很讨喜。

6.6 弹簧动画的物理参数

如果你对物理感兴趣,可以用 interpolatingSpring 直接设置刚度和阻尼:

swift 复制代码
withAnimation(.interpolatingSpring(stiffness: 200, damping: 20)) {
    scale = 1.2
}
  • stiffness:刚度,越大越快;
  • damping:阻尼,越大越稳;
  • initialVelocity:初始速度。

这组参数更接近物理引擎,适合需要精确控制的场景。


七、相位动画:给视图加一点节奏

7.1 什么是相位动画?

相位动画(PhaseAnimator)是 SwiftUI 较新的功能,它可以让一个视图在一组状态之间循环变化。

swift 复制代码
struct PhaseAnimationView: View {
    var body: some View {
        PhaseAnimator([1.0, 1.2, 0.9, 1.0]) { scale in
            Circle()
                .fill(.blue)
                .frame(width: 100, height: 100)
                .scaleEffect(scale)
        } animation: { phase in
            .spring(duration: 0.4)
        }
    }
}

圆会按照 [1.0, 1.2, 0.9, 1.0] 的序列不断缩放。animation 闭包可以为每个相位指定不同的动画。

7.2 用相位动画做呼吸效果

swift 复制代码
struct BreathingView: View {
    var body: some View {
        PhaseAnimator([false, true]) { isExpanded in
            Circle()
                .fill(.blue.opacity(0.3))
                .frame(width: 200, height: 200)
                .scaleEffect(isExpanded ? 1.2 : 1.0)
        } animation: { _ in
            .easeInOut(duration: 2.0)
        }
    }
}

圆会慢慢放大、缩小,像在呼吸。这种效果适合做加载、冥想、等待界面。

7.3 相位动画与触发

swift 复制代码
struct TriggeredPhaseView: View {
    @State private var trigger = false

    var body: some View {
        VStack {
            PhaseAnimator([1.0, 1.5, 1.0], trigger: trigger) { scale in
                RoundedRectangle(cornerRadius: 16)
                    .fill(.green.gradient)
                    .frame(width: 100, height: 100)
                    .scaleEffect(scale)
            }

            Button("触发") {
                trigger.toggle()
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

每次点击按钮,矩形会播放一次缩放动画。trigger 变化时,相位动画重新开始。

7.4 每个相位不同动画

swift 复制代码
PhaseAnimator([1.0, 1.5, 0.8, 1.0]) { scale in
    Circle()
        .fill(.purple.gradient)
        .frame(width: 100, height: 100)
        .scaleEffect(scale)
} animation: { phase in
    switch phase {
    case 1.5:
        return .spring(duration: 0.3, bounce: 0.5)
    case 0.8:
        return .easeInOut(duration: 0.2)
    default:
        return .spring(duration: 0.4)
    }
}

不同的相位可以用不同的动画曲线,做出更有节奏感的效果。

相位动画适合做一次性的强调效果、循环的等待效果、多步骤的动画序列。它比手动写 withAnimation 链更简洁。


八、关键帧动画:精确控制每一帧

8.1 什么是关键帧动画?

有时候,你想要的动画不是简单的"从 A 到 B",而是"先到 A,再到 B,最后到 C"。这时可以用关键帧动画(KeyframeAnimator)。

swift 复制代码
struct KeyframeView: View {
    @State private var trigger = false

    var body: some View {
        KeyframeAnimator(initialValue: 0.0, trigger: trigger) { value in
            RoundedRectangle(cornerRadius: 16)
                .fill(.blue.gradient)
                .frame(width: 100, height: 100)
                .offset(y: value)
        } keyframes: { _ in
            KeyframeTrack(\.self) {
                CubicKeyframe(-100, duration: 0.3)
                CubicKeyframe(0, duration: 0.3)
                CubicKeyframe(-50, duration: 0.2)
                CubicKeyframe(0, duration: 0.2)
            }
        }

        Button("跳一下") {
            trigger.toggle()
        }
        .buttonStyle(.borderedProminent)
    }
}

点击按钮,方块会先跳到 -100,再落回 0,再弹到 -50,最后回到 0。这种多段动画用 withAnimation 很难写,但用关键帧动画就很直观。

8.2 关键帧动画的组成

  • initialValue:初始值;
  • trigger:触发动画的值;
  • content:根据当前值构建视图;
  • keyframes:定义关键帧轨道。

关键帧轨道里可以放:

  • LinearKeyframe:线性插值;
  • CubicKeyframe:三次贝塞尔插值;
  • SpringKeyframe:弹簧插值。

8.3 同时控制多个属性

swift 复制代码
struct MultiKeyframeView: View {
    struct AnimationValues {
        var scale = 1.0
        var rotation = 0.0
        var y = 0.0
    }

    @State private var trigger = false

    var body: some View {
        KeyframeAnimator(
            initialValue: AnimationValues(),
            trigger: trigger
        ) { values in
            RoundedRectangle(cornerRadius: 16)
                .fill(.purple.gradient)
                .frame(width: 80, height: 80)
                .scaleEffect(values.scale)
                .rotationEffect(.degrees(values.rotation))
                .offset(y: values.y)
        } keyframes: { _ in
            KeyframeTrack(\.scale) {
                SpringKeyframe(1.5, duration: 0.3)
                SpringKeyframe(1.0, duration: 0.3)
            }
            KeyframeTrack(\.rotation) {
                LinearKeyframe(180, duration: 0.6)
                LinearKeyframe(360, duration: 0.6)
            }
            KeyframeTrack(\.y) {
                CubicKeyframe(-80, duration: 0.3)
                CubicKeyframe(0, duration: 0.3)
            }
        }

        Button("播放") {
            trigger.toggle()
        }
        .buttonStyle(.borderedProminent)
    }
}

这个例子同时控制缩放、旋转、位移。三条轨道独立运行,合成一个复杂的动画。

8.4 关键帧动画的常见用途

关键帧动画适合做:

  • 图标动画;
  • 加载动画;
  • 庆祝效果;
  • 过场动画;
  • 游戏化反馈。

它比手动拼 withAnimation 更精确,比 CAKeyframeAnimation 更声明式。


九、触觉反馈:让手指感受到界面

9.1 什么是触觉反馈?

触觉反馈就是手机震动。iPhone 的 Taptic Engine 可以产生非常细腻的震动,比如轻点、成功、警告、失败等。合理使用触觉反馈,能让交互更有质感。

9.2 SwiftUI 里的触觉反馈

SwiftUI 提供了 .sensoryFeedback 修饰符:

swift 复制代码
struct HapticView: View {
    @State private var count = 0

    var body: some View {
        VStack(spacing: 20) {
            Text("计数:\(count)")
                .font(.title)

            Button("加一") {
                count += 1
            }
            .buttonStyle(.borderedProminent)
        }
        .sensoryFeedback(.impact(weight: .medium), trigger: count)
    }
}

每次 count 变化,都会触发一次中等强度的冲击反馈。

9.3 常用的触觉反馈

swift 复制代码
.sensoryFeedback(.success, trigger: isSuccess)
.sensoryFeedback(.warning, trigger: isWarning)
.sensoryFeedback(.error, trigger: isError)
.sensoryFeedback(.selection, trigger: selectedIndex)
.sensoryFeedback(.impact(weight: .light), trigger: count)
.sensoryFeedback(.impact(weight: .heavy), trigger: count)
  • .success:操作成功,比如保存完成;
  • .warning:警告,比如输入不合法;
  • .error:错误,比如操作失败;
  • .selection:选择变化,比如滚动选择器;
  • .impact:冲击,可以指定轻、中、重。

9.4 触觉反馈的时机

触觉反馈不要滥用。好的触觉反馈是"恰到好处"的:

  • 按钮点击:轻冲击;
  • 开关切换:选择反馈;
  • 操作成功:成功反馈;
  • 操作失败:错误反馈;
  • 下拉刷新:轻冲击。

不要每个交互都加震动,否则用户会烦。一般来说,重要的、有结果的操作才加触觉反馈。

9.5 触觉反馈与动画结合

swift 复制代码
struct SuccessButtonView: View {
    @State private var isSuccess = false

    var body: some View {
        Button {
            withAnimation(.bouncy) {
                isSuccess = true
            }
        } label: {
            Label(
                isSuccess ? "已完成" : "提交",
                systemImage: isSuccess ? "checkmark.circle.fill" : "paperplane.fill"
            )
            .padding()
            .frame(maxWidth: .infinity)
        }
        .buttonStyle(.borderedProminent)
        .tint(isSuccess ? .green : .blue)
        .sensoryFeedback(.success, trigger: isSuccess)
    }
}

点击后,按钮变绿、文字变化、播放成功触觉。三种反馈叠加,体验非常完整。


十、交互式动画:让动画跟着手指走

10.1 什么是交互式动画?

普通的动画是"点一下,播放"。交互式动画是"手指动,动画跟着动"。比如下拉刷新、侧滑菜单、拖动排序、卡片翻转。

10.2 跟手拖拽

swift 复制代码
struct FollowDragView: View {
    @State private var offset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.blue.gradient)
            .frame(width: 120, height: 120)
            .offset(offset)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                    }
                    .onEnded { value in
                        withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                            offset = .zero
                        }
                    }
            )
    }
}

手指移动时,方块实时跟手;松手后,弹簧回位。这就是最简单的交互式动画。

10.3 用拖拽控制进度

swift 复制代码
struct DragProgressView: View {
    @State private var progress: CGFloat = 0.5

    var body: some View {
        VStack {
            GeometryReader { geo in
                ZStack(alignment: .leading) {
                    RoundedRectangle(cornerRadius: 8)
                        .fill(.gray.opacity(0.2))

                    RoundedRectangle(cornerRadius: 8)
                        .fill(.blue)
                        .frame(width: geo.size.width * progress)
                }
                .gesture(
                    DragGesture()
                        .onChanged { value in
                            let newProgress = value.location.x / geo.size.width
                            progress = min(max(0, newProgress), 1)
                        }
                )
            }
            .frame(height: 20)
            .padding()

            Text("进度:\(Int(progress * 100))%")
                .font(.headline)
        }
    }
}

拖动进度条,蓝色部分跟手变化。这是做音量、亮度、播放进度条的常用方式。

10.4 下拉刷新

swift 复制代码
struct PullToRefreshView: View {
    @State private var offset: CGFloat = 0
    @State private var isRefreshing = false

    var body: some View {
        ScrollView {
            VStack {
                if isRefreshing {
                    ProgressView()
                        .padding()
                } else {
                    Text(offset > 80 ? "松开刷新" : "下拉刷新")
                        .foregroundStyle(.secondary)
                        .padding()
                }

                ForEach(1...20, id: \.self) { index in
                    Text("第 \(index) 行")
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding()
                        .background(Color(.systemBackground))
                }
            }
            .background(
                GeometryReader { geo in
                    Color.clear
                        .onChange(of: geo.frame(in: .global).minY) { _, newValue in
                            offset = max(0, newValue)
                        }
                }
            )
        }
        .onChange(of: offset) { _, newValue in
            if newValue > 80 && !isRefreshing {
                isRefreshing = true
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
                    withAnimation {
                        isRefreshing = false
                    }
                }
            }
        }
    }
}

这个例子用 GeometryReader 监听滚动位置,模拟下拉刷新。真实的刷新可以用 .refreshable:

swift 复制代码
ScrollView {
    // 内容
}
.refreshable {
    // 刷新逻辑
}

.refreshable 是 SwiftUI 内置的下拉刷新,配合 List 或 ScrollView 使用,非常方便。

10.5 交互式动画的关键:跟手 + 阈值 + 回弹

做交互式动画,通常有三个要素:

  1. 跟手 :onChanged 里实时更新状态;
  2. 阈值 :onEnded 里判断位移是否超过某个值;
  3. 回弹 :用 withAnimation(.spring()) 回位或完成过渡。

掌握这个模式,你就能做大部分交互式动画。


十一、动画与布局的结合

11.1 布局变化也能动画

swift 复制代码
struct LayoutAnimationView: View {
    @State private var isGrid = true

    let items = Array(1...9)

    var body: some View {
        VStack {
            Button("切换布局") {
                withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
                    isGrid.toggle()
                }
            }
            .buttonStyle(.borderedProminent)

            if isGrid {
                LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
                    ForEach(items, id: \.self) { item in
                        cell(item)
                    }
                }
                .padding()
            } else {
                VStack(spacing: 12) {
                    ForEach(items, id: \.self) { item in
                        cell(item)
                    }
                }
                .padding()
            }
        }
    }

    func cell(_ item: Int) -> some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(.blue.opacity(0.3))
            .frame(height: 60)
            .overlay(Text("\(item)"))
    }
}

切换时,网格和列表之间的布局变化会平滑过渡。SwiftUI 会自动处理每个元素的位置和大小变化。

11.2 matchedGeometryEffect:共享元素过渡

swift 复制代码
struct MatchedGeometryView: View {
    @Namespace private var namespace
    @State private var isExpanded = false

    var body: some View {
        ZStack {
            if isExpanded {
                RoundedRectangle(cornerRadius: 24)
                    .fill(.blue.gradient)
                    .matchedGeometryEffect(id: "card", in: namespace)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .padding()
                    .onTapGesture {
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                            isExpanded = false
                        }
                    }
            } else {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.blue.gradient)
                    .matchedGeometryEffect(id: "card", in: namespace)
                    .frame(width: 100, height: 100)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                            isExpanded = true
                        }
                    }
            }
        }
    }
}

matchedGeometryEffect 让两个不同位置的视图共享同一个"几何身份",SwiftUI 会自动计算从旧位置到新位置的过渡。这是做卡片展开、图片查看器、Tab 切换的利器。

11.3 matchedGeometryEffect 的工作原理

matchedGeometryEffect 需要两个东西:

  • id:两个视图共享的标识;
  • namespace:命名空间,避免不同组的 id 冲突。

当视图 A 消失、视图 B 出现时,如果它们共享同一个 id 和 namespace,SwiftUI 会把 A 的几何信息(位置、大小、形状)动画到 B。这就是"共享元素过渡"。

11.4 转场与布局的配合

swift 复制代码
struct ExpandableListView: View {
    @State private var expandedItems: Set<Int> = []

    var body: some View {
        VStack {
            ForEach(1...5, id: \.self) { index in
                VStack(alignment: .leading) {
                    HStack {
                        Text("项目 \(index)")
                            .font(.headline)
                        Spacer()
                        Image(systemName: expandedItems.contains(index) ? "chevron.up" : "chevron.down")
                    }
                    .contentShape(Rectangle())
                    .onTapGesture {
                        withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
                            if expandedItems.contains(index) {
                                expandedItems.remove(index)
                            } else {
                                expandedItems.insert(index)
                            }
                        }
                    }

                    if expandedItems.contains(index) {
                        Text("这是项目 \(index) 的详细内容。展开和收起都有平滑的动画。")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                            .transition(.opacity.combined(with: .move(edge: .top)))
                    }
                }
                .padding()
                .background(Color(.secondarySystemBackground))
                .clipShape(RoundedRectangle(cornerRadius: 12))
                .padding(.horizontal)
            }
        }
    }
}

点击标题,详情展开;再点,收起。transition 让详情内容的出现和消失更自然。

11.5 动画与 ScrollView 的结合

swift 复制代码
ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemView(item: item)
                .scrollTransition { content, phase in
                    content
                        .opacity(phase.isIdentity ? 1 : 0.5)
                        .scaleEffect(phase.isIdentity ? 1 : 0.9)
                        .rotation3DEffect(
                            .degrees(phase.value * 20),
                            axis: (x: 1, y: 0, z: 0)
                        )
                }
        }
    }
}

.scrollTransition 是较新的 API,可以让滚动时子视图有视差、缩放、旋转效果。用户滚动时,卡片会逐渐变大、变清晰,非常有质感。


十二、综合示例:一个会呼吸的任务卡片

我们把这一课的内容串起来,做一个会呼吸的任务卡片。

需求:

  • 卡片有标题、描述、完成按钮;
  • 点击完成按钮,卡片打勾并变绿;
  • 打勾时有弹簧动画和触觉反馈;
  • 卡片可以左滑删除;
  • 删除时有过渡动画;
  • 整体有呼吸感的微动画。
swift 复制代码
import SwiftUI

struct Task: Identifiable {
    let id = UUID()
    var title: String
    var description: String
    var isDone: Bool = false
}

struct BreathingTaskListView: View {
    @State private var tasks: [Task] = [
        Task(title: "学习 SwiftUI", description: "完成第3课:交互与动画"),
        Task(title: "写一个 Demo", description: "用动画做一个任务卡片"),
        Task(title: "复习布局", description: "回顾第2课的 frame 和 padding")
    ]

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 16) {
                    ForEach(tasks) { task in
                        TaskCard(task: task) {
                            toggle(task)
                        } onDelete: {
                            delete(task)
                        }
                    }
                }
                .padding()
            }
            .navigationTitle("任务清单")
        }
    }

    func toggle(_ task: Task) {
        guard let index = tasks.firstIndex(where: { $0.id == task.id }) else { return }
        withAnimation(.bouncy) {
            tasks[index].isDone.toggle()
        }
    }

    func delete(_ task: Task) {
        withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
            tasks.removeAll { $0.id == task.id }
        }
    }
}

struct TaskCard: View {
    let task: Task
    let onToggle: () -> Void
    let onDelete: () -> Void

    @State private var offset: CGFloat = 0
    @State private var isBreathing = false

    var body: some View {
        ZStack(alignment: .trailing) {
            // 删除背景
            RoundedRectangle(cornerRadius: 16)
                .fill(.red.gradient)
                .overlay(
                    Image(systemName: "trash.fill")
                        .font(.title2)
                        .foregroundStyle(.white)
                        .padding(.trailing, 24),
                    alignment: .trailing
                )

            // 卡片主体
            HStack(spacing: 16) {
                Button {
                    onToggle()
                } label: {
                    Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
                        .font(.system(size: 28))
                        .foregroundStyle(task.isDone ? .green : .secondary)
                        .scaleEffect(task.isDone ? 1.1 : 1.0)
                }
                .buttonStyle(.plain)

                VStack(alignment: .leading, spacing: 6) {
                    Text(task.title)
                        .font(.headline)
                        .strikethrough(task.isDone)
                        .foregroundStyle(task.isDone ? .secondary : .primary)

                    Text(task.description)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                        .lineLimit(2)
                }

                Spacer()
            }
            .padding()
            .background(
                RoundedRectangle(cornerRadius: 16)
                    .fill(Color(.secondarySystemBackground))
                    .overlay(
                        RoundedRectangle(cornerRadius: 16)
                            .stroke(task.isDone ? Color.green.opacity(0.5) : Color.clear, lineWidth: 2)
                    )
            )
            .offset(x: offset)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        if value.translation.width < 0 {
                            offset = value.translation.width
                        }
                    }
                    .onEnded { value in
                        if value.translation.width < -120 {
                            onDelete()
                        } else {
                            withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
                                offset = 0
                            }
                        }
                    }
            )
            .scaleEffect(isBreathing ? 1.01 : 1.0)
            .shadow(
                color: task.isDone ? .green.opacity(0.2) : .black.opacity(0.05),
                radius: isBreathing ? 12 : 6,
                y: 4
            )
        }
        .onAppear {
            withAnimation(
                .easeInOut(duration: 2.0)
                .repeatForever(autoreverses: true)
            ) {
                isBreathing = true
            }
        }
        .sensoryFeedback(.success, trigger: task.isDone)
    }
}

这个示例涵盖了:

  • Button 和 onTapGesture;
  • DragGesture 左滑删除;
  • withAnimation 和 .spring;
  • .transition 和 .scaleEffect;
  • .sensoryFeedback 触觉反馈;
  • 循环呼吸动画;
  • 状态驱动的界面更新。

你可以继续扩展:

  • 添加新任务;
  • 编辑任务;
  • 长按排序;
  • 持久化到本地;
  • 添加优先级标签。

十三、常见坑与调试建议

13.1 动画不生效

检查:

  • 状态是否用了 @State、@Binding、@Published 等可观察状态;
  • 是否在 withAnimation 或 .animation(_:value:) 里改变了状态;
  • 如果是 @State 数组里的元素,是否改变了数组本身(值类型需要整体替换)。

13.2 动画范围太大

withAnimation 会把它闭包内所有状态变化都加上动画。如果你只想让某一部分动画,把状态变化分开:

swift 复制代码
withAnimation(.spring()) {
    scale = 1.2
}
opacity = 0.5

或者用 .animation(_:value:) 精确绑定某个值。

13.3 手势冲突

如果同一个 View 上有多个手势,可能会冲突。用 .highPriorityGesture()、.simultaneousGesture() 或 .gesture(_:including:) 控制优先级。

13.4 拖拽手势与 ScrollView 冲突

在 ScrollView 里放拖拽手势,可能会和滚动冲突。可以用 .simultaneousGesture() 让两者同时识别,或者用 .highPriorityGesture() 让拖拽优先。

13.5 动画性能

  • 优先动画 transform 类属性:scaleEffect、rotationEffect、offset;
  • 避免动画 frame 的宽高变化,虽然 SwiftUI 支持,但可能触发重排;
  • 长列表里不要给每一行都加复杂动画;
  • 用 LazyVStack、LazyVGrid 代替 VStack、Grid 处理大量数据。

13.6 触觉反馈不触发

  • 检查 trigger 值是否真的变化;
  • 模拟器不支持触觉反馈,需要真机测试;
  • 不要给每个微小变化都加触觉,会显得很烦。

13.7 预览里看动画

Xcode 预览支持动画。你可以直接在预览里点击按钮、拖拽视图,看动画效果。如果预览卡顿,可以试试关闭实时预览,或者用真机调试。

13.8 不要过度动画

动画是为了让界面更自然,不是为了炫技。每个元素都在动,用户会晕。好的动画是"用户几乎注意不到,但拿掉后觉得少了点什么"。

13.9 matchedGeometryEffect 不生效

检查:

  • 两个视图是否在同一个 @Namespace 里;
  • id 是否完全一致;
  • 是否在 withAnimation 里切换状态;
  • 两个视图是否在同一个视图树里(不能在完全不同的分支里)。

13.10 PhaseAnimator 和 KeyframeAnimator 的版本要求

这两个 API 需要较新的 SwiftUI 版本(iOS 17+)。如果你的项目要支持旧版本,可以用 withAnimation 和 Timer 模拟类似效果。


十四、第3课总结

这一课我们学习了 SwiftUI 的交互和动画。我们学到了:

  1. Button 是最基本的交互,支持自定义标签、样式、角色、禁用状态、异步任务;
  2. onTapGesture、onLongPressGesture 可以给任意 View 加点击和长按;
  3. DragGesture 可以跟手拖拽,配合 onChanged 和 onEnded 做交互式动画;
  4. MagnificationGesture 和 RotationGesture 可以做缩放和旋转;
  5. 手势可以组合、排序、设置优先级;
  6. withAnimation 让状态变化带动画;
  7. .animation(_:value:) 可以精确绑定某个值的变化;
  8. 动画曲线包括线性、缓入缓出、自定义贝塞尔、弹簧;
  9. .transition 控制视图的出现和消失;
  10. 弹簧动画让界面有生命感,response 和 dampingFraction 控制手感;
  11. PhaseAnimator 可以做循环相位动画;
  12. KeyframeAnimator 可以做精确的多段关键帧动画;
  13. .sensoryFeedback 提供触觉反馈;
  14. 交互式动画的核心是跟手、阈值、回弹;
  15. matchedGeometryEffect 可以做共享元素过渡;
  16. .scrollTransition 可以做滚动视差效果;
  17. 动画和布局结合,可以让界面变化更自然。

交互和动画是 App 的灵魂。学会它们,你的界面就不只是"能用",而是"好用、好看、有质感"。

下一课,我们会学习数据持久化:UserDefaults、FileManager、SwiftData、Core Data。到那时,我们的 App 就不只是"会动",还会"记得住"。


十五、练习

练习一:点赞按钮

做一个点赞按钮,点击时心形放大、变红,再弹回,同时播放成功触觉反馈。

练习二:可拖拽卡片

做一个可以拖拽的卡片,松手后弹回原位。如果拖拽超过一定距离,卡片飞走并消失。

练习三:展开收起列表

做一个可展开收起的列表,点击标题展开详情,再点收起。展开和收起要有平滑的过渡动画。

练习四:呼吸动画

用 PhaseAnimator 做一个呼吸效果的圆,慢慢放大缩小,像在吸气呼气。

练习五:下拉刷新

用 .refreshable 给一个 List 添加下拉刷新。刷新时显示 ProgressView,1.5 秒后结束。

练习六:共享元素过渡

用 matchedGeometryEffect 做一个小方块变成大卡片的过渡动画。点击小方块展开,点击大卡片收起。

练习七:关键帧动画

用 KeyframeAnimator 做一个弹跳的小球,先向上跳,再落下,再弹几下,最后停下。

练习八:综合任务卡片

给综合示例的任务卡片添加:

  • 添加新任务;
  • 长按进入编辑;
  • 优先级标签;
  • 完成时的庆祝动画。

练习九:滚动视差

用 .scrollTransition 做一个滚动视差效果:卡片滚动到屏幕中间时最大最亮,滚出屏幕时缩小变暗。

练习十:手势组合

做一个可以用双指同时缩放和旋转的图片查看器,松手后如果缩放小于 1 或大于 3,自动弹回边界。


下一课预告

第4课,我们会学习:

  • UserDefaults:轻量级键值存储;
  • FileManager:文件读写;
  • Codable:JSON 编解码;
  • SwiftData:苹果新的数据持久化框架;
  • Core Data:经典的数据持久化方案;
  • 数据迁移、版本管理、性能优化。

交互让界面活起来,持久化让数据留下来。我们下一课见。

记住一句话:动画不是装饰,而是沟通。它告诉用户发生了什么,将要发生什么。

相关推荐
xian_wwq1 小时前
【学习笔记】等保合规安全基线核查实战
笔记·学习
zhchyun20081 小时前
【Unity UI 进阶】仿 Element UI 打造企业级 Unity UI 组件库(10)
ui·unity·游戏引擎
可乐鸡翅yeah_1 小时前
新手梳理:M3U8 线上问题,哪些是前端锅,哪些是后端锅
前端·ios·音视频·实时音视频·m3u8·音视频在线播放
传奇开心果编程2 小时前
【Jetpack Compose进阶学与练】第14课:系列收尾复习总结;Compose项目常见坑点汇总;学习路线与后续学习方向
android·学习·ui·kotlin·android jetpack
yangmu32032 小时前
发动机是怎么把油变成动力的:四冲程内燃机原理拆解
学习
m4Rk_2 小时前
【论文阅读】Agent 记忆机制(81):EMR——用情景记忆避免 Agent 在多步推理中反复绕圈
论文阅读·人工智能·学习·开源·github
海海不掉头发2 小时前
阶段五_实验34-38_学习总结
深度学习·神经网络·学习·机器学习
M78佐菲2 小时前
ARM学习笔记(11)
linux·arm开发·笔记·嵌入式硬件·学习
M78佐菲2 小时前
ARM学习笔记(10)
linux·arm开发·笔记·嵌入式硬件·学习