本课目标 :给界面加上动画和手势------掌握
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 从网络加载数据。