开篇:静态的界面是画,动态的界面是生命
前两课,我们已经走过了 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 交互式动画的关键:跟手 + 阈值 + 回弹
做交互式动画,通常有三个要素:
- 跟手 :
onChanged里实时更新状态; - 阈值 :
onEnded里判断位移是否超过某个值; - 回弹 :用
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 的交互和动画。我们学到了:
Button是最基本的交互,支持自定义标签、样式、角色、禁用状态、异步任务;onTapGesture、onLongPressGesture可以给任意 View 加点击和长按;DragGesture可以跟手拖拽,配合onChanged和onEnded做交互式动画;MagnificationGesture和RotationGesture可以做缩放和旋转;- 手势可以组合、排序、设置优先级;
withAnimation让状态变化带动画;.animation(_:value:)可以精确绑定某个值的变化;- 动画曲线包括线性、缓入缓出、自定义贝塞尔、弹簧;
.transition控制视图的出现和消失;- 弹簧动画让界面有生命感,
response和dampingFraction控制手感; PhaseAnimator可以做循环相位动画;KeyframeAnimator可以做精确的多段关键帧动画;.sensoryFeedback提供触觉反馈;- 交互式动画的核心是跟手、阈值、回弹;
matchedGeometryEffect可以做共享元素过渡;.scrollTransition可以做滚动视差效果;- 动画和布局结合,可以让界面变化更自然。
交互和动画是 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:经典的数据持久化方案;- 数据迁移、版本管理、性能优化。
交互让界面活起来,持久化让数据留下来。我们下一课见。
记住一句话:动画不是装饰,而是沟通。它告诉用户发生了什么,将要发生什么。