【SwiftUI入门练中学】第7课 动画与手势:让界面更灵动

本课目标 :给界面加上动画和手势------掌握 withAnimation、.animation、.transition、.scaleEffect、.rotationEffect、.offset,以及点击、长按、拖拽手势。

前置知识 :第1-6课已完成,能用 @State、List、Sheet 写交互界面。

本课产出:点赞动画 + 可拖拽卡片 + 转场动画。

一、先建立四个认知

认知 1:动画是"状态变化"的副产品

SwiftUI 不需要你手动"播放动画",你只需要改变状态,并告诉系统"这次变化要带动画"。

swift 复制代码
// 没有动画:瞬间切换
isLiked.toggle()

// 有动画:平滑过渡
withAnimation {
    isLiked.toggle()
}

认知 2:显式动画 vs 隐式动画

方式 写法 适合
显式 withAnimation { 状态变化 } 精确控制某次变化
隐式 .animation(.spring(), value: 某个值) 某个值变化就动画

认知 3:手势是"输入",状态是"结果"

手势本身不改变界面,它只负责识别用户输入,然后你根据输入修改状态,界面自动更新。

复制代码
用户拖拽 → DragGesture 识别 → 更新 offset 状态 → 卡片移动

认知 4:动画优先用 transform 属性

scaleEffect、rotationEffect、offset、opacity 由 GPU 合成,性能好;frame、padding 会触发布局重算,性能差。

二、决策框架:选哪种动画和手势

需求 工具 示例
状态变化带点动画 withAnimation 点赞、展开
某个值变化就动画 .animation(_:value:) 滑块、颜色
视图插入/移除 .transition 弹窗、列表
缩放 .scaleEffect 心跳、按压
旋转 .rotationEffect 加载、翻转
偏移 .offset 拖拽、滑动
透明度 .opacity 淡入淡出
点击 .onTapGesture 点赞、切换
长按 LongPressGesture 菜单、删除
拖拽 DragGesture 卡片、滑块
手势临时状态 @GestureState 拖拽过程

三、基础动画:withAnimation

最简单的动画

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

    var body: some View {
        Image(systemName: isLiked ? "heart.fill" : "heart")
            .font(.system(size: 60))
            .foregroundStyle(isLiked ? .red : .gray)
            .onTapGesture {
                withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
                    isLiked.toggle()
                }
            }
    }
}

关键认知:

  • withAnimation 包裹状态变化。
  • .spring() 弹簧动画,最自然。
  • .easeInOut、.linear 也是常用曲线。

常用动画曲线

swift 复制代码
withAnimation(.linear(duration: 0.3)) { }          // 匀速
withAnimation(.easeIn(duration: 0.3)) { }          // 渐入
withAnimation(.easeOut(duration: 0.3)) { }         // 渐出
withAnimation(.easeInOut(duration: 0.3)) { }       // 渐入渐出
withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { } // 弹簧

四、隐式动画:.animation

swift 复制代码
struct ColorChangeView: View {
    @State private var isBlue = true

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(isBlue ? Color.blue : Color.orange)
            .frame(width: 200, height: 200)
            .animation(.easeInOut(duration: 0.5), value: isBlue)  // isBlue 变化时动画
            .onTapGesture {
                isBlue.toggle()
            }
    }
}

关键认知 :.animation(_:value:) 只监听指定的值,避免不必要的动画。

五、转场动画:.transition

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

    var body: some View {
        VStack {
            Button("切换") {
                withAnimation(.spring()) {
                    showDetail.toggle()
                }
            }

            if showDetail {
                RoundedRectangle(cornerRadius: 20)
                    .fill(.blue.gradient)
                    .frame(width: 200, height: 200)
                    .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
    }
}

常用转场:

swift 复制代码
.transition(.opacity)                        // 淡入淡出
.transition(.slide)                          // 滑动
.transition(.move(edge: .bottom))            // 从底部移入
.transition(.scale)                          // 缩放
.transition(.move(edge: .bottom).combined(with: .opacity)) // 组合

关键认知 :.transition 必须配合 withAnimation 或 .animation 才生效。

六、变换效果:缩放、旋转、偏移

swift 复制代码
struct TransformDemo: View {
    @State private var scale: CGFloat = 1.0
    @State private var rotation: Double = 0
    @State private var offset: CGSize = .zero

    var body: some View {
        VStack(spacing: 40) {
            // 缩放
            RoundedRectangle(cornerRadius: 20)
                .fill(.blue)
                .frame(width: 100, height: 100)
                .scaleEffect(scale)
                .onTapGesture {
                    withAnimation(.spring()) {
                        scale = scale == 1.0 ? 1.5 : 1.0
                    }
                }

            // 旋转
            Image(systemName: "arrow.clockwise")
                .font(.largeTitle)
                .rotationEffect(.degrees(rotation))
                .onTapGesture {
                    withAnimation(.easeInOut(duration: 0.5)) {
                        rotation += 180
                    }
                }

            // 偏移
            Circle()
                .fill(.orange)
                .frame(width: 60, height: 60)
                .offset(offset)
                .onTapGesture {
                    withAnimation(.spring()) {
                        offset = offset == .zero ? CGSize(width: 100, height: 0) : .zero
                    }
                }
        }
    }
}

七、手势:点击、长按、拖拽

点击手势

swift 复制代码
Text("点我")
    .onTapGesture {
        print("被点击")
    }

长按手势

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

拖拽手势

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

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.blue.gradient)
            .frame(width: 150, height: 150)
            .offset(
                x: offset.width + dragOffset.width,
                y: offset.height + dragOffset.height
            )
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in
                        state = value.translation  // 实时更新临时偏移
                    }
                    .onEnded { value in
                        withAnimation(.spring()) {
                            offset.width += value.translation.width
                            offset.height += value.translation.height
                        }
                    }
            )
    }
}

关键认知:

  • @GestureState 保存拖拽过程中的临时状态,手势结束自动复位。
  • @State 保存最终位置。
  • .updating 实时更新,.onEnded 结束后累加。

八、实战一:点赞动画

swift 复制代码
struct LikeAnimation: View {
    @State private var isLiked = false
    @State private var scale: CGFloat = 1.0

    var body: some View {
        Image(systemName: isLiked ? "heart.fill" : "heart")
            .font(.system(size: 60))
            .foregroundStyle(isLiked ? .red : .gray)
            .scaleEffect(scale)
            .onTapGesture {
                withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
                    isLiked.toggle()
                    scale = 1.3
                }
                // 延迟恢复大小,形成"心跳"效果
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                    withAnimation(.spring()) {
                        scale = 1.0
                    }
                }
            }
    }
}

九、实战二:可拖拽卡片

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

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.blue.gradient)
            .frame(width: 200, height: 200)
            .overlay {
                Text("拖我")
                    .font(.headline)
                    .foregroundStyle(.white)
            }
            .offset(
                x: offset.width + dragOffset.width,
                y: offset.height + dragOffset.height
            )
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in
                        state = value.translation
                    }
                    .onEnded { value in
                        withAnimation(.spring()) {
                            offset.width += value.translation.width
                            offset.height += value.translation.height
                        }
                    }
            )
    }
}

十、实战三:列表插入删除动画

swift 复制代码
struct AnimatedList: View {
    @State private var items = ["A", "B", "C"]

    var body: some View {
        VStack {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item)
                }
                .onDelete { offsets in
                    withAnimation {
                        items.remove(atOffsets: offsets)
                    }
                }
            }

            Button("添加") {
                withAnimation {
                    items.append("新项目")
                }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

关键认知 :withAnimation 包裹数组变化,列表插入和删除会平滑动画。

十一、常见错误与排查

错误 原因 解决
动画不生效 没加 withAnimation 包裹状态变化
转场不生效 没配合 withAnimation 加动画上下文
拖拽不跟手 没用 @GestureState 用 @GestureState 实时更新
松手后回弹 没保存最终偏移 在 onEnded 累加
动画卡顿 动画了 frame 优先用 scaleEffect/offset
手势冲突 多个手势叠加 用 .simultaneousGesture 或调整优先级
长按无反应 没加 .onLongPressGesture 加手势
列表删除无动画 没包 withAnimation 包裹 remove
@GestureState 报错 直接赋值 用 .updating
动画范围过大 withAnimation 包裹太多 只包必要状态

十二、习题、答案与解读

选择题

题1 :显式动画使用哪个函数?

A. withAnimation B. .animation C. .transition D. .gesture

答案:A

解读:withAnimation { ... } 包裹状态变化。

题2 :隐式动画使用哪个修饰符?

A. withAnimation B. .animation C. .transition D. .offset

答案:B

解读:.animation(.spring(), value: isLiked)。

题3 :插入或移除视图时的转场效果使用哪个修饰符?

A. .animation B. .transition C. .scaleEffect D. .gesture

答案:B

解读:.transition(.opacity)、.transition(.slide)。

题4 :拖拽手势使用哪个类型?

A. TapGesture B. LongPressGesture C. DragGesture D. RotationGesture

答案:C

解读:DragGesture 用于拖拽。

题5 :@GestureState 的主要特点是什么?

A. 永久保存状态 B. 手势结束后自动复位 C. 只能存字符串 D. 用于网络请求

答案:B

解读:@GestureState 适合手势过程中的临时状态。

题6 :让视图缩放使用哪个修饰符?

A. .scaleEffect B. .rotationEffect C. .offset D. .opacity

答案:A

解读:.scaleEffect 控制缩放。

题7 :手势结束后保存最终位置,用哪个方法?

A. .updating B. .onEnded C. .onChanged D. .gesture

答案:B

解读:.onEnded 在手势结束时触发。

判断题

题1 :withAnimation 可以包裹状态变化来产生动画。

答案:对

解读:状态变化会带动画。

题2 :SwiftUI 中所有状态变化默认都有动画。

答案:错

解读:默认没有动画,需要显式或隐式添加。

题3 :.transition 通常配合 withAnimation 或 .animation 使用。

答案:对

解读:转场需要动画上下文。

题4 :DragGesture 的 onEnded 可以获取最终拖拽位置。

答案:对

解读:value.translation 表示偏移。

题5 :@GestureState 适合保存拖拽结束后的最终位置。

答案:错

解读:@GestureState 会在手势结束后复位,最终位置应存到 @State。

题6 :动画 scaleEffect 比动画 frame 性能更好。

答案:对

解读:scaleEffect 由 GPU 合成。

题7 :.onTapGesture 可以识别点击。

答案:对

解读:点击手势。

填空题

题1 :显式动画函数是 ______。

答案:withAnimation

题2 :隐式动画修饰符是 .______。

答案:animation

题3 :转场修饰符是 .______。

答案:transition

题4 :拖拽手势类型是 ______。

答案:DragGesture

题5 :手势临时状态使用 @______。

答案:GestureState

题6 :缩放使用 .______。

答案:scaleEffect

题7 :旋转使用 .______。

答案:rotationEffect

改错题

题1:

swift 复制代码
.animation(.spring())

要求:新版 SwiftUI 推荐怎么写?

答案:

swift 复制代码
.animation(.spring(), value: isLiked)

解读:推荐指定 value,只有该值变化时才播放动画。

题2:

swift 复制代码
if isLiked {
    Text("已点赞")
}
.transition(.opacity)

要求:找出错误。

答案:

swift 复制代码
if isLiked {
    Text("已点赞")
        .transition(.opacity)
}

解读:.transition 应写在被插入或移除的视图上。

题3:

swift 复制代码
@GestureState private var dragOffset: CGSize = .zero
@State private var offset: CGSize = .zero

DragGesture()
    .onEnded { value in
        dragOffset = value.translation
    }

要求:找出错误。

答案:

swift 复制代码
DragGesture()
    .updating($dragOffset) { value, state, _ in
        state = value.translation
    }
    .onEnded { value in
        offset.width += value.translation.width
        offset.height += value.translation.height
    }

解读:@GestureState 要用 .updating 更新,不能在 onEnded 里直接赋值。

题4:

swift 复制代码
withAnimation {
    items.remove(atOffsets: offsets)
    items.append("新项目")
    items.sort()
}

要求:找出潜在问题。

答案:

swift 复制代码
withAnimation {
    items.remove(atOffsets: offsets)
}

解读:withAnimation 只包裹必要的状态变化,范围过大会导致整页重算。

代码预测题

题1:

swift 复制代码
@State private var isExpanded = false

RoundedRectangle(cornerRadius: 20)
    .fill(.blue)
    .frame(width: isExpanded ? 250 : 100,
           height: isExpanded ? 250 : 100)
    .onTapGesture {
        withAnimation(.spring()) {
            isExpanded.toggle()
        }
    }

问题:点击矩形后会发生什么?

答案:矩形在 100×100 和 250×250 之间以弹簧动画放大或缩小。

题2:

swift 复制代码
Image(systemName: isLiked ? "heart.fill" : "heart")
    .foregroundStyle(isLiked ? .red : .gray)
    .onTapGesture {
        withAnimation { isLiked.toggle() }
    }

问题:点击后图标会怎样?

答案:在空心灰色和实心红色之间切换,带平滑动画。

题3:

swift 复制代码
@State private var offset: CGSize = .zero
@GestureState private var dragOffset: CGSize = .zero

.offset(x: offset.width + dragOffset.width, y: offset.height + dragOffset.height)

问题:拖拽时和松手后分别由哪个状态控制?

答案:拖拽时由 dragOffset 实时跟随,松手后由 offset 保存最终位置。

编程题

题1 :制作点赞按钮动画。

参考代码:

swift 复制代码
struct ContentView: View {
    @State private var isLiked = false
    @State private var scale: CGFloat = 1.0

    var body: some View {
        Image(systemName: isLiked ? "heart.fill" : "heart")
            .font(.system(size: 60))
            .foregroundStyle(isLiked ? .red : .gray)
            .scaleEffect(scale)
            .onTapGesture {
                withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
                    isLiked.toggle()
                    scale = 1.3
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                    withAnimation(.spring()) {
                        scale = 1.0
                    }
                }
            }
    }
}

题2 :制作可拖拽卡片。

参考代码:

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

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.blue)
            .frame(width: 150, height: 150)
            .offset(
                x: offset.width + dragOffset.width,
                y: offset.height + dragOffset.height
            )
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in
                        state = value.translation
                    }
                    .onEnded { value in
                        withAnimation(.spring()) {
                            offset.width += value.translation.width
                            offset.height += value.translation.height
                        }
                    }
            )
    }
}

题3 :给列表插入和删除添加动画。

参考代码:

swift 复制代码
struct ContentView: View {
    @State private var items = ["A", "B", "C"]

    var body: some View {
        VStack {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item)
                }
                .onDelete { offsets in
                    withAnimation {
                        items.remove(atOffsets: offsets)
                    }
                }
            }

            Button("添加") {
                withAnimation {
                    items.append("新项目")
                }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

题4 :用 transition 做从底部滑入的弹窗。

参考代码:

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

    var body: some View {
        VStack {
            Button("显示") {
                withAnimation(.spring()) {
                    showDetail = true
                }
            }

            if showDetail {
                RoundedRectangle(cornerRadius: 20)
                    .fill(.blue.gradient)
                    .frame(width: 200, height: 200)
                    .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
    }
}

开放思考题

题1 :显式动画和隐式动画有什么区别?

参考:显式动画用 withAnimation 包裹某次状态变化;隐式动画用 .animation 监听某个值,值变就动画。

题2 :@State 和 @GestureState 在手势中有什么区别?

参考:@State 保存最终状态;@GestureState 保存手势过程中的临时状态,结束自动复位。

题3 :动画过多可能带来什么问题?

参考:影响性能、干扰用户、让界面显得杂乱。

题4 :为什么动画 scaleEffect 比动画 frame 性能好?

参考:scaleEffect 是 transform 类属性,由 GPU 合成;frame 触发布局重算,成本高。

十三、全课知识点总结

动画

  • withAnimation:显式动画,包裹状态变化。
  • .animation(_:value:):隐式动画,指定值变化时播放。
  • .transition:插入或移除视图时的转场。
  • .scaleEffect:缩放。
  • .rotationEffect:旋转。
  • .offset:偏移。
  • .opacity:透明度。

动画曲线

  • .linear:匀速。
  • .easeIn / .easeOut / .easeInOut:缓动。
  • .spring:弹簧,最自然。

手势

  • .onTapGesture:点击。
  • .onLongPressGesture:长按。
  • DragGesture:拖拽。
  • .updating:实时更新临时状态。
  • .onEnded:手势结束,保存最终状态。
  • @GestureState:手势临时状态,结束自动复位。

实战模式

  • 点赞动画:withAnimation + scaleEffect + 延迟恢复。
  • 拖拽卡片:DragGesture + @GestureState + @State。
  • 列表动画:withAnimation 包裹数组变化。
  • 转场弹窗:.transition(.move(edge:).combined(with: .opacity))。

性能原则

  • 优先动画 transform 属性(scaleEffect、rotationEffect、offset)。
  • 避免动画 frame、padding。
  • withAnimation 只包裹必要状态。
  • 隐式动画指定 value,避免不必要的动画。

常见错误

  • 没加 withAnimation。
  • .transition 位置错误。
  • @GestureState 直接赋值。
  • 动画范围过大。
  • 拖拽没保存最终位置。

本课达标标准 :能用 withAnimation 和 .animation 做缩放、旋转、偏移和转场动画,能用 DragGesture 和 @GestureState 做可拖拽卡片,能给列表插入删除添加动画,能识别常见动画性能问题。

下一课预告 :第8课学习网络请求与数据加载,用 async/await、URLSession、Codable 从网络加载数据。

相关推荐
幼儿园蛋小黄1 小时前
SPI 多点触摸屏学习嵌入式总结
学习
dadaobusi1 小时前
学习:顶点着色器输出后,三角形为什么是四个分量
学习
lengxuenong2 小时前
枚举及其优化学习手册
c++·学习·算法·青少年编程
M78佐菲3 小时前
ARM学习笔记(12)
linux·arm开发·笔记·嵌入式硬件·学习
奇思妙想聪明勤奋的小羊3 小时前
ai-agent-book第五章:Coding Agent 与通用 Agent 学习笔记
人工智能·笔记·学习
知潮网3 小时前
发售刚十天,9999 元的 iPhone 18 Pro 解锁就重启
ios·iphone
优化Henry3 小时前
BBU与RRU光链路详解:从双纤一收一发到单纤双向
网络·笔记·学习·5g·tdd
辣知4 小时前
辣知·化智68 半仙隐士渭水钓的不是鱼
学习
传奇开心果编程4 小时前
【用案例学Material 3 Expressive】第1课:从一封邮件开始,感受安卓设计新语言的表现力
android·学习·ui·kotlin·android jetpack