SwiftUI Basics

文本

1. 基础文本与系统字体大小

你可以使用 .font() 修饰符并传入苹果预设的样式(如标题、正文、副标题)。强烈推荐使用这些预设样式,因为它们会根据用户 iPhone 设置中的“辅助功能(字体大小)”自动进行缩放调整。

1
2
3
4
Text("Hello World")
.font(.title) // 设置为大标题大小
// .font(.body) // 默认的正文大小
// .font(.caption) // 较小的辅助说明文字

2. 字体粗细与样式修饰

你可以调整字体的粗细,或者为其添加斜体、下划线和删除线。下划线和删除线还可以进一步自定义颜色。

1
2
3
4
5
6
Text("SwiftUI 学习")
.fontWeight(.semibold) // 设置为半粗体
// .bold() // 直接加粗的简写
.italic() // 设置为斜体
.underline(true, color: .red) // 添加红色的下划线
.strikethrough(true, color: .green) // 添加绿色的删除线

3. 自定义精确的系统字体

如果设计师要求完全精确的字号(比如 24px),你可以使用 .system 字体。你还可以在这里改变字体的设计风格,比如等宽字体或圆润字体。注意:写死具体数值会导致字体无法随系统设置自动缩放。

1
2
Text("精确尺寸的文本")
.font(.system(size: 24, weight: .semibold, design: .rounded))

4. 多行文本对齐与间距调整

当一段文字太长自动换行时,你可以决定这些文本是居中、靠左还是靠右对齐。同时,你也可以调整行与行之间,以及字母与字母之间的间距。

1
2
3
4
Text("这是一段非常非常长的多行测试文本,用来演示如何在 SwiftUI 中进行排版和对齐操作。")
.multilineTextAlignment(.leading) // 多行文本靠左(头部)对齐
.baselineOffset(10) // 调整行间距(类似 Word 里的行距)
.kerning(2) // 调整字母之间的字间距

5. 更改文本颜色

修改文本的前景色(即字体本身的颜色)非常直观。

1
2
Text("带颜色的文本")
.foregroundColor(.blue) // 将字体颜色修改为蓝色

6. 限制框架与自适应缩放

有时候我们需要将文本限制在一个固定大小的框(Frame)内。如果文本太长,你可以允许它自动缩小字号,以确保所有内容都能完整显示在这个框内。

1
2
3
Text("这段文本会被限制在指定的框内,如果放不下会自动缩小。")
.frame(width: 200, height: 100, alignment: .leading) // 设置固定宽高,并让内容靠左对齐
.minimumScaleFactor(0.1) // 允许文本最小缩小到原字号的 10% 来适应框架

7. 字符串大小写快速转换

如果你想快速统一文本的大小写格式,可以直接在传入 Text 的字符串内部调用 Swift 的原生字符串方法。

1
2
3
Text("hello world".uppercased()) // 全部转换为大写:HELLO WORLD
// Text("HELLO WORLD".lowercased()) // 全部转换为小写
// Text("hello world".capitalized) // 每个单词首字母大写:Hello World

形状

1. 基础形状概览

SwiftUI 内置了五种最常用的几何形状。你可以直接像使用 Text 一样声明它们。默认情况下,如果不限制框架大小(Frame),它们会尽可能大地填满整个可用空间,且默认颜色为黑色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 正圆形
Circle()

// 2. 椭圆形
Ellipse()

// 3. 胶囊形(类似两端完全圆润的矩形)
Capsule(style: .circular) // 或 .continuous

// 4. 标准矩形
Rectangle()

// 5. 圆角矩形(开发中最常用的形状之一)
RoundedRectangle(cornerRadius: 20)

2. 填充与改变颜色

使用 .fill() 修饰符可以将形状内部填满指定的颜色。对于基础形状,你也可以直接使用上一节课学到的 .foregroundColor(),两者的效果在纯色填充时是等价的。

1
2
3
Circle()
.fill(Color.blue) // 填充蓝色
// .foregroundColor(.pink) // 同样可以改变形状颜色

3. 绘制边框(描边)

如果你不想要实心的形状,而是只想要一个空心的线框,可以使用 .stroke() 修饰符。你可以指定边框的颜色和线条的粗细(LineWidth)。

1
2
3
RoundedRectangle(cornerRadius: 15)
// 绘制一条红色的、粗细为 5 的实线边框
.stroke(Color.red, lineWidth: 5)

4. 高级自定义边框(虚线与端点)

.stroke() 方法还支持传入 StrokeStyle,这允许你做出非常复杂的边框效果,比如常见的虚线(Dash),或者修改线段两端的圆角样式(LineCap)。

1
2
3
4
5
6
7
8
9
Capsule()
.stroke(
Color.orange,
style: StrokeStyle(
lineWidth: 10, // 线条粗细
lineCap: .round, // 让虚线的每一段两端都是圆润的
dash: [30] // 虚线数组:每段实线长 30
)
)

5. 裁剪部分形状 (Trim)

这是形状组件中最酷的功能之一!使用 .trim(from:to:) 可以让你只绘制形状的“某一部分”。参数的范围是 0.01.0(代表 0% 到 100%)。这个功能非常适合用来制作环形进度条或加载动画。

注意:.trim() 必须写在 .fill().stroke() 之前才能生效。

1
2
3
4
5
Circle()
// 只保留圆形的 20% 到 100% 的部分(即砍掉前 20%)
.trim(from: 0.2, to: 1.0)
// 为切割后的形状加上紫色边框
.stroke(Color.purple, lineWidth: 50)

6. 限制形状大小

前面提到形状会默认铺满屏幕。如果你想画一个小一点的图形,只需使用 .frame() 限定它的宽和高。

1
2
3
4
Ellipse()
.fill(Color.green)
// 强制将椭圆限制在一个宽 200,高 100 的区域内
.frame(width: 200, height: 100)

颜色

1. 基础内置颜色 (Basic Colors)

SwiftUI 预置了许多常用的基础颜色,你可以直接使用点语法(.)来调用它们。这些颜色通常用于测试和占位。 其中最特殊且最常用的是 .primary.secondary,它们不仅代表黑色或灰色,而且会根据系统的深浅模式自动反转(例如 .primary 在浅色模式下是黑色,在深色模式下会自动变成白色)。

1
2
3
RoundedRectangle(cornerRadius: 25)
.fill(Color.blue) // 基础蓝色
// .fill(Color.primary) // 自适应的主文本颜色(黑/白转换)

2. 使用 UIColor (UIKit 颜色兼容)

虽然我们在写 SwiftUI,但我们可以直接借用旧版 UIKit 框架(UIColor)中的强大颜色库。特别是 UIColor 提供了一系列系统语义颜色(System Colors),比如专门用来做背景的 systemBackgroundsecondarySystemBackground。这些颜色自带极佳的深浅模式适配效果,非常适合用来做 App 的底层背景。

1
2
3
RoundedRectangle(cornerRadius: 25)
// 借用 UIColor 中的系统次级背景色
.fill(Color(UIColor.secondarySystemBackground))

3. 颜色字面量 (Color Literals)

注意:在较新的 Xcode 版本中,输入方式有所改变,但核心概念不变。 颜色字面量允许你在代码中直接呼出一个“取色器(Color Picker)”。你可以使用调色板直观地选取颜色,甚至可以直接在面板中输入设计师提供的 Hex 颜色代码(十六进制码,如 #FF5733。代码中会直接显示一个小色块,非常直观。

1
2
3
4
RoundedRectangle(cornerRadius: 25)
// 在旧版 Xcode 中输入 ColorLiteral() 会出现取色板
// 在新版 Xcode 中,你通常会看到类似这样的代码生成
.fill(Color(#colorLiteral(red: 0.92, green: 0.33, blue: 0.23, alpha: 1)))

4. 在 Assets 中管理自定义颜色 (Color Assets)

这是在企业级开发中最推荐、最标准的做法。 你可以在左侧导航栏找到 Assets.xcassets 文件,点击底部的 + 号选择 Color Set,为你的颜色命名(例如 “CustomColor”)。 在这里,你可以通过右侧的属性面板精确设置它的 Hex 值。最强大的是,你可以为这个名字设置两种截然不同的颜色:一种用于 Light Mode(浅色模式),另一种用于 Dark Mode(深色模式)

1
2
3
4
RoundedRectangle(cornerRadius: 25)
// 通过传入字符串名称,直接调用在 Assets 文件夹中配置好的颜色
// 系统会自动根据当前的深浅模式,无缝切换显示对应的颜色!
.fill(Color("CustomColor"))

5. 添加与自定义阴影 (Shadows)

为形状添加阴影可以极大地提升 UI 的质感和层级感。SwiftUI 提供了非常简洁的 .shadow 修饰符。除了基础的黑色阴影,你还可以自定义阴影的颜色、模糊半径(Radius)以及偏移量(x, y)。 一个高级的 UI 技巧是:将阴影的颜色设置为组件本身的颜色,并降低透明度(opacity),这会比纯黑色的阴影看起来自然得多。

1
2
3
4
5
6
7
8
9
10
11
12
RoundedRectangle(cornerRadius: 25)
.fill(Color.blue)
// 基础阴影,只设置模糊半径
// .shadow(radius: 10)

// 高级自定义阴影:同色系阴影 + 降低透明度 + 调整 Y 轴偏移量(让阴影偏下)
.shadow(
color: Color.blue.opacity(0.3),
radius: 10,
x: 0,
y: 20
)

渐变

1. 线性渐变

线性渐变是最常用的一种渐变。它会沿着一条由“起始点”和“结束点”确定的直线平滑地过渡颜色。你可以使用 .top.bottom.leading.trailing 等预设方位,也可以通过 UnitPoint 自定义精确位置。

1
2
3
4
5
6
7
8
9
RoundedRectangle(cornerRadius: 25)
.fill(
LinearGradient(
gradient: Gradient(colors: [.blue, .purple, .pink]),
startPoint: .topLeading, // 左上角开始
endPoint: .bottomTrailing // 右下角结束
)
)
.frame(width: 300, height: 200)

2. 径向渐变

径向渐变是从一个中心点向外扩散的圆环形过渡。你可以控制渐变的“中心位置”、“起始半径(颜色开始变化的距离)”以及“结束半径(过渡完成的距离)”。它非常适合用来制作高光、阴影或聚光灯效果。

1
2
3
4
5
6
7
8
9
10
Circle()
.fill(
RadialGradient(
gradient: Gradient(colors: [.yellow, .orange, .red]),
center: .center, // 从圆心开始扩散
startRadius: 20, // 20 像素后开始变色
endRadius: 200 // 200 像素后变色结束
)
)
.frame(width: 300, height: 300)

3. 角度渐变

角度渐变(也叫圆锥渐变)是颜色围绕中心点进行 360 度“扫射”产生的效果,类似于色盘。这种渐变在制作环形进度条、旋转仪表盘或者现代感十足的圆形视觉图时非常有用。

1
2
3
4
5
6
7
8
9
RoundedRectangle(cornerRadius: 25)
.fill(
AngularGradient(
gradient: Gradient(colors: [.red, .blue, .green, .yellow, .red]),
center: .center, // 围绕中心点旋转
angle: .degrees(0) // 初始旋转角度
)
)
.frame(width: 300, height: 300)

4. 高级应用技巧:结合描边与透明度

渐变可以和上一节学到的形状修饰符配合使用。例如,你可以将渐变应用在 .stroke() 边框上,或者在 Gradient 中使用带有透明度的颜色(.opacity),从而创造出更有层次感的效果。

1
2
3
4
5
6
7
8
9
10
Circle()
.stroke(
LinearGradient(
gradient: Gradient(colors: [Color.blue.opacity(0.3), .blue]),
startPoint: .top,
endPoint: .bottom
),
lineWidth: 30
)
.frame(width: 200, height: 200)

系统图标

1. 基础图标引用

在 SwiftUI 中,使用 Image(systemName:) 来调用系统图标。你需要传入图标的字符串名称,这些名称可以从苹果官方的 SF Symbols App 中找到并复制。

1
2
3
4
5
// 显示一个基础的“心形”填充图标
Image(systemName: "heart.fill")

// 显示一个“纸飞机”填充图标
Image(systemName: "paperplane.fill")

2. 调整图标尺寸的两种方式

SwiftUI 提供了两种截然不同的方式来控制图标的大小:

  • 像文本一样缩放(推荐): 使用 .font() 修饰符。这种方式最常用,因为它能让图标与周围的文字完美适配,并支持动态字体缩放。
  • 按像素比例缩放: 如果你需要精确控制图标占用的宽高,必须先调用 .resizable(),然后再设置 .frame()
1
2
3
4
5
6
7
8
9
// 方法 A:使用字体样式调整(更智能、更动态)
Image(systemName: "heart.fill")
.font(.largeTitle) // 设置为大标题尺寸
// .font(.system(size: 80)) // 设置精确的 80 像素尺寸

// 方法 B:强制调整尺寸(更精确、更可控)
Image(systemName: "heart.fill")
.resizable() // 必须先调用此方法
.frame(width: 100, height: 100) // 然后设置固定宽高

3. 颜色修改与渲染模式

默认情况下,图标会继承父视图的前景色(通常是黑色)。你可以使用 .foregroundColor() 修改它的颜色。 此外,针对一些支持多色的图标,你可以开启“原始渲染模式(Original Mode)”来显示苹果预设的多彩效果。

1
2
3
4
5
6
7
8
// 修改为单一颜色
Image(systemName: "heart.fill")
.foregroundColor(.red)

// 开启多色模式(显示图标自带的红、绿等语义化颜色)
Image(systemName: "person.fill.badge.plus")
.renderingMode(.original) // 开启此模式后,加号会显示为绿色
.font(.largeTitle)

4. 保持比例与框架裁剪

当你使用 .resizable() 调整图标大小时,图标可能会因为长宽比不一致而被拉伸。为了解决这个问题,通常会配合使用比例缩放修饰符。

  • .scaledToFit(): 缩放图标以适应框架,同时保证图标内容完整显示在框内。
  • .scaledToFill(): 缩放图标以填满整个框架,可能会导致部分边缘内容被截断(通常配合 .clipped() 使用)。
1
2
3
4
5
Image(systemName: "books.vertical.fill")
.resizable()
.scaledToFit() // 保持比例,完整放入框内
.frame(width: 300, height: 300)
// .clipped() // 如果是 scaledToFill,可以用此修饰符裁掉溢出部分

5. 获取图标名称的工具

视频强烈推荐下载苹果官方的 SF Symbols App。该工具不仅包含了所有图标的预览,还允许你搜索关键字(如 “heart”, “cloud”, “person”),直接右键即可复制准确的图标名称并在代码中使用。目前该库已包含超过 2,400 个图标。

图像

1. 引入并显示自定义图片

首先,你需要将图片文件拖入 Xcode 项目左侧导航栏的 Assets.xcassets 中,并为其命名。在代码中,直接传入图片的字符串名称即可引用它。

1
2
// 显示在 Assets 中名为 "myImage" 的自定义图片
Image("myImage")

2. 启用图像缩放

默认情况下,图片会按其原始像素大小显示。如果图片分辨率很高,它可能会超出屏幕边界。为了让图片适应你设定的框架(Frame),必须首先调用 .resizable() 方法。

1
2
3
Image("myImage")
.resizable() // 必须先调用,否则 .frame 不会生效
.frame(width: 300, height: 300)

3. 保持比例缩放

当图片被强制拉伸到特定框架时,往往会变形。SwiftUI 提供了两种模式来处理比例问题:

  • .scaledToFit(): 缩放图片以完整显示在框架内,可能会留下留白。
  • .scaledToFill(): 缩放图片以填满整个框架,图片的边缘部分可能会被截断。
1
2
3
4
5
6
7
8
9
Image("myImage")
.resizable()
.scaledToFit() // 保持比例,图片完整可见
.frame(width: 300, height: 300)

Image("myImage")
.resizable()
.scaledToFill() // 保持比例,填满整个框
.frame(width: 300, height: 300)

4. 裁剪与圆角处理

如果使用 .scaledToFill() 导致图片溢出了设定的框架,可以配合使用修饰符进行裁剪。你可以选择直接切掉框架外的部分,或者将图片修剪成特定的形状(如圆形或圆角矩形)。

1
2
3
4
5
6
7
Image("myImage")
.resizable()
.scaledToFill()
.frame(width: 300, height: 300)
.clipped() // 切掉框架外的部分
// .cornerRadius(150) // 旧版设置圆角的方法
// .clipShape(Circle()) // 👑 推荐做法:将图片裁剪成圆形

5. 修改自定义图标的颜色

如果你引入的是简单的单色图标(图标背景透明),并希望像修改文本颜色一样在代码中动态改变它的颜色,你需要调整它的“渲染模式”。

1
2
3
Image("myCustomIcon")
.renderingMode(.template) // 将图片视为模板,忽略其原始颜色
.foregroundColor(.blue) // 现在可以将其染成蓝色

6. 忽略安全区域

有时候你希望图片能够延伸到屏幕的最顶部(覆盖刘海屏区域)或最底部,可以使用忽略安全区域的修饰符。

1
2
3
4
Image("background")
.resizable()
.scaledToFill()
.edgesIgnoringSafeArea(.all) // 让图片铺满整个屏幕,包括状态栏

Frame

1. 固定尺寸框架

你可以为任何组件设定精确的宽度(width)和高度(height)。如果不设置对齐参数,内容默认会居中显示在框架内。

1
2
3
4
Text("Hello World")
.background(Color.green) // 背景在框架设置前,只覆盖文字大小
.frame(width: 200, height: 200)
.background(Color.red) // 背景在框架设置后,覆盖整个 200x200 的区域

2. 框架内的内容对齐

frame 方法提供了一个 alignment 参数,用来决定内容(如文字)在多出的空间里如何摆放。常用的选项包括 .top(顶部)、.bottom(底部)、.leading(靠左)、.trailing(靠右)以及它们的组合(如 .topLeading)。

1
2
3
4
Text("Hello")
.background(Color.yellow)
.frame(width: 200, height: 200, alignment: .topLeading) // 将文字固定在左上角
.background(Color.orange)

3. 灵活的弹性框架

在响应式设计中,我们很少写死具体的像素值。使用 maxWidth: .infinity 可以让视图自动拉伸并占满父视图横向的所有可用空间。这在设置列表背景或全屏背景时非常有用。

1
2
3
4
Text("弹性框架测试")
.background(Color.red)
.frame(maxWidth: .infinity, alignment: .leading) // 占满整行宽度,内容靠左
.background(Color.blue)

4. 修饰符顺序的重要性

在 SwiftUI 中,代码的执行顺序(从上往下)直接决定了最终的布局效果。设置背景(background)和设置框架(frame)的先后顺序会产生完全不同的视觉结果。

1
2
3
4
5
6
7
8
9
// 情况 A:先画背景再设框
Text("A")
.background(Color.blue)
.frame(width: 100, height: 100) // 蓝色只在文字周围,外圈是透明的

// 情况 B:先设框再画背景
Text("B")
.frame(width: 100, height: 100)
.background(Color.blue) // 整个 100x100 的框都被涂成蓝色

5. 嵌套多重框架

你可以为一个视图应用多个 frame 修饰符。这种“套娃式”的方法允许你像剥洋葱一样,一层一层地构建复杂的 UI 布局和背景层次。

1
2
3
4
5
6
7
8
9
10
Text("Hello")
.background(Color.red)
.frame(height: 100) // 第一层:高度 100
.background(Color.orange)
.frame(width: 150) // 第二层:增加宽度到 150
.background(Color.purple)
.frame(maxWidth: .infinity) // 第三层:横向占满全屏
.background(Color.blue)
.frame(maxHeight: .infinity) // 第四层:纵向占满全屏
.background(Color.yellow)

6. 综合对齐示例

通过合理组合 maxWidthmaxHeightalignment,你可以不使用额外的布局容器(如 VStack)就能完成简单的定位。

1
2
3
Text("右下角文字")
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.background(Color.gray)

背景

1. 背景修饰符的基础用法

.background() 允许你在某个组件下方添加颜色、形状甚至另一个视图。它会根据父视图(即被修饰的视图)的大小自动调整自己的尺寸。

1
2
3
4
5
6
7
Text("Hello")
.frame(width: 100, height: 100)
// 在文字后面放一个红色圆圈
.background(
Circle()
.fill(Color.red)
)

2. 叠加层修饰符的基础用法

.overlay() 与背景正好相反,它会将内容盖在当前组件的上方。这在制作图标上的角标(如未读消息红点)时非常有用。

1
2
3
4
5
6
7
8
Image(systemName: "heart.fill")
.font(.system(size: 40))
// 在心脏图标上方盖一个蓝色小圆点
.overlay(
Circle()
.fill(Color.blue)
.frame(width: 15, height: 15)
)

3. 内部对齐控制

无论是背景还是叠加层,都支持 alignment 参数。通过该参数,你可以精准控制背景或叠加层内容在容器内的摆放位置(例如左上角、底部等)。

1
2
3
4
5
6
7
8
9
Rectangle()
.frame(width: 100, height: 100)
// 将背景圆圈对齐到矩形的右下角
.background(
Circle()
.fill(Color.yellow)
.frame(width: 50, height: 50),
alignment: .bottomTrailing
)

4. 嵌套多层布局

你可以将背景和叠加层无限嵌套。通过这种方式,你可以不使用复杂的布局容器(如 ZStack)就构建出复杂的 UI 组件。这也是本节视频中最强调的高级布局思维。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Image(systemName: "bell.fill")
.font(.largeTitle)
.foregroundColor(.white)
.frame(width: 100, height: 100)
// 第一层背景:蓝色圆角矩形
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color.blue)
.shadow(radius: 10)
)
// 第一层叠加:红色未读提醒圆点
.overlay(
Circle()
.fill(Color.red)
.frame(width: 25, height: 25)
// 在红点上再盖一层文字
.overlay(
Text("5")
.font(.caption)
.foregroundColor(.white)
),
alignment: .topTrailing // 将整个红点组件对齐到右上角
)

5. 视图层级总结

  • .background():向后延伸(底层)。
  • 原始视图:中间层。
  • .overlay():向前延伸(顶层)。 这种层级思维允许你以“修饰”而非“重构”的方式,逐步为简单的组件增加视觉深度。

Stack

1. 垂直堆栈

VStack 将视图按垂直方向从上往下排列。

  • 排列顺序: 第一个编写的视图在顶部,最后一个在底部。
  • 默认对齐: 居中对齐。
1
2
3
4
5
VStack {
Rectangle().fill(Color.red).frame(width: 100, height: 100)
Rectangle().fill(Color.green).frame(width: 100, height: 100)
Rectangle().fill(Color.orange).frame(width: 100, height: 100)
}

2. 水平堆栈

HStack 将视图按水平方向从左往右排列。

  • 排列顺序: 第一个编写的视图在左侧(Leading),最后一个在右侧(Trailing)。
1
2
3
4
HStack {
Rectangle().fill(Color.red).frame(width: 100, height: 100)
Rectangle().fill(Color.green).frame(width: 100, height: 100)
}

3. 深度堆栈

ZStack 将视图按屏幕深度方向前后叠放。

  • 叠加顺序: 第一个编写的视图在最底层(最背面),最后一个编写的视图在最顶层(最前面,盖在其他视图上)。
  • 用途: 适合制作带背景图的卡片或重叠的 UI 元素。
1
2
3
4
5
6
ZStack {
// 处于最底层
Rectangle().fill(Color.red).frame(width: 150, height: 150)
// 盖在红色上面
Rectangle().fill(Color.green).frame(width: 100, height: 100)
}

4. 间距与对齐控制

你可以通过给堆栈传递参数来精确控制布局:

  • Spacing (间距): 控制堆栈内元素之间的距离(设置为 0 则无缝连接)。
  • Alignment (对齐):
    • VStack 支持 .leading (靠左), .trailing (靠右)。
    • HStack 支持 .top (靠上), .bottom (靠下)。
    • ZStack 支持各种复合方向,如 .topLeading (左上角)。
1
2
3
4
5
// 创建一个靠左对齐、元素间距为 20 的垂直堆栈
VStack(alignment: .leading, spacing: 20) {
Text("标题").font(.headline)
Text("副标题").font(.subheadline)
}

5. 堆栈的嵌套使用

这是构建真实 App 界面的核心技巧。你可以在 VStack 里面嵌套 HStack,或者在 ZStack 里面嵌套 VStack。通过层层嵌套,可以实现极其复杂的布局逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ZStack {
// 背景层
Color.yellow.frame(width: 350, height: 500)

// 内容层:垂直排列
VStack {
Text("购物车详情").font(.title)

// 嵌套水平堆栈:展示图标和文字
HStack(spacing: 10) {
Image(systemName: "cart")
Text("共有 5 件商品")
}
.background(Color.white)
}
}

6. 堆栈与背景/叠加层的关系

视频最后提到,简单的两层叠加可以使用 .background()(向后叠加)或 .overlay()(向前叠加),而更复杂的多层嵌套则推荐使用 ZStack

  • 简单场景: Text("1").background(Circle())(文字在圆圈前面)。
  • 复杂场景: 使用 ZStack 可以让代码逻辑更清晰。

内边距

1. 基础内边距

使用 .padding() 修饰符而不带任何参数时,SwiftUI 会根据当前的平台和环境自动在视图的上下左右四个方向添加系统标准的空白距离。

1
2
3
4
Text("Hello, World!")
.background(Color.yellow)
.padding() // 自动添加全方位内边距
.background(Color.blue)

2. 指定方向与多方向组合

你可以精确控制在哪些边缘添加间距。常用的方向参数包括:.top(上)、.bottom(下)、.leading(前/左)、.trailing(后/右)。你还可以使用组合参数,如 .horizontal(左右两侧)或 .vertical(上下两侧)。

1
2
3
4
Text("指定方向间距")
.padding(.top, 50) // 仅在顶部添加 50 像素
.padding(.horizontal, 20) // 在左和右各添加 20 像素
.background(Color.green)

3. 自定义间距数值

除了使用系统默认值,你可以传入具体的浮点数(CGFloat)来精确定义边距的大小。这在需要实现特定设计稿要求时非常有用。

1
2
3
Text("自定义数值")
.padding(10) // 四周统一添加 10 像素
.padding(.leading, 30) // 在左侧额外追加 30 像素

4. 修饰符顺序对布局的影响

在 SwiftUI 中,修饰符的排列顺序决定了视图的层次。如果你想让背景颜色包含内边距区域,必须先写 .padding() 再写 .background()。如果顺序颠倒,背景色将只覆盖文字本身,而内边距区域则是透明的。

1
2
3
4
5
6
7
8
9
// ✅ 正确做法:让背景包裹间距
Text("有背景的卡片内容")
.padding()
.background(Color.white)

// ❌ 错误做法:背景无法延伸到间距区
Text("文字")
.background(Color.white)
.padding()

5. 真实场景应用:构建现代感卡片

通过巧妙嵌套多层内边距,你可以轻松构建出带有阴影和圆角的精美卡片布局,而不需要去计算复杂的宽高数值。

1
2
3
4
5
6
7
8
9
10
11
12
VStack(alignment: .leading) {
Text("内边距实战")
.font(.headline)
Text("这是卡片的描述文字。通过 padding 让内容不至于紧贴着背景边缘,看起来更舒服。")
}
.padding() // 第一层:内部文字与背景的间距
.background(
Color.white
.cornerRadius(10)
.shadow(color: Color.black.opacity(0.3), radius: 10, x: 0, y: 10)
)
.padding(.horizontal) // 第二层:整个卡片与屏幕左右边缘的间距

占位间距

1. 什么是 Spacer 及其基本作用

Spacer 本质上是一个占据所有剩余空间的视图。在一个堆栈(HStack 或 VStack)中,它会尽可能地伸展,直到把周边的其他组件推到屏幕的边缘。

1
2
3
4
5
6
7
8
9
10
11
HStack {
Rectangle()
.frame(width: 50, height: 50)

// 它会吃掉中间所有的空间,将两个方块推向左右两端
Spacer()

Rectangle()
.frame(width: 50, height: 50)
}
.background(Color.yellow)

2. 在水平堆栈中使用 Spacer

HStack 中,Spacer 会在水平方向上伸展。这是制作自定义导航栏或列表条目的常用技巧,可以将图标和文字完美推向两侧。

1
2
3
4
5
6
7
8
9
HStack {
Image(systemName: "xmark")

Spacer() // 推开左右两边的图标

Image(systemName: "gear")
}
.font(.title)
.padding(.horizontal)

3. 在垂直堆栈中使用 Spacer

VStack 中,Spacer 会在垂直方向上伸展。如果你想让一组内容保持在屏幕顶部,而另一组保持在底部,只需在它们中间加一个 Spacer 即可。

1
2
3
4
5
6
7
VStack {
Text("屏幕顶部内容")

Spacer() // 占据中间垂直空间

Text("屏幕底部内容")
}

4. 使用多个 Spacer 平分空间

如果你在一个堆栈中放置了多个 Spacer,它们会表现得非常公平:它们会平分剩下的所有空间。这对于在页面中创建均匀分布的间隔非常有用。

1
2
3
4
5
6
7
8
9
HStack {
Spacer() // 占据 1/3 的剩余空间
Rectangle().frame(width: 50, height: 50)

Spacer() // 占据 1/3 的剩余空间
Rectangle().frame(width: 50, height: 50)

Spacer() // 占据 1/3 的剩余空间
}

5. 设置 Spacer 的最小长度

默认情况下,如果屏幕内容非常拥挤,Spacer 可能会缩小到 0。通过传入 minLength 参数,你可以强制规定它最小必须保留多大的间距。

1
2
3
4
5
6
7
8
HStack(spacing: 0) {
Rectangle().frame(width: 100, height: 100)

// 即使右侧方块很大,这个间距也至少会有 20 像素
Spacer(minLength: 20)

Rectangle().frame(width: 100, height: 100)
}

6. 结合布局构建真实界面

通过组合使用 VStackHStackSpacer,你可以轻松实现复杂的布局,而无需硬编码任何坐标数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
VStack {
HStack {
Image(systemName: "xmark")
Spacer()
Image(systemName: "gear")
}
.font(.title)
.padding()

Spacer() // 将导航栏推到最顶部

Text("这是主内容区域")
}

初始化与枚举

1. 什么是初始化器

初始化器(init)是在视图被创建那一刻自动运行的函数。它的主要作用是为视图的属性赋予初始值。 如果你没有手动写 init,SwiftUI 会自动为你生成一个包含所有属性的默认初始化器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct MyView: View {
let backgroundColor: Color
let count: Int

// 即使不写下面这段代码,SwiftUI 也会默认提供它
init(count: Int, color: Color) {
self.count = count
self.backgroundColor = color
}

var body: some View {
VStack {
Text("\(count)")
Text("Items")
}
.background(backgroundColor)
}
}

2. 自定义初始化器中的逻辑

手动编写 init 的一大好处是:你可以在数据进入视图之前对其进行加工。例如,你可以只接收一个数字,然后在 init 内部判断这个数字是大还是小,从而决定显示什么颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct CustomInitView: View {
let backgroundColor: Color
let title: String

// 自定义初始化逻辑:根据传入的字符串决定背景色
init(fruit: String) {
if fruit == "Apple" {
self.title = "苹果"
self.backgroundColor = .red
} else {
self.title = "橙子"
self.backgroundColor = .orange
}
}

var body: some View {
Text(title)
.padding()
.background(backgroundColor)
}
}

3. 使用枚举增强类型安全

在上面的例子中,使用字符串(”Apple”)来做判断是非常危险的,因为容易拼写错误。更好的做法是使用枚举(Enum)。枚举能限制输入的范围,让代码更健壮,且 Xcode 会提供自动补全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 定义水果类型的枚举
enum Fruit {
case apple
case orange
}

struct EnumView: View {
let fruitType: Fruit

var body: some View {
// 在 body 中使用 switch 处理不同枚举情况
switch fruitType {
case .apple:
Text("这是苹果")
case .orange:
Text("这是橙子")
}
}
}

4. 结合初始化器与枚举构建可复用组件

这是本节视频的核心技巧:创建一个通用的组件,通过 init 接收一个枚举值,并在 init 内部完成所有的 UI 配置工作(如颜色、图标、文本)。这样,你的 body 就会变得非常简洁,且逻辑清晰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
struct DrinkCardView: View {
let title: String
let color: Color
let iconName: String

enum DrinkType {
case coffee
case tea
}

// 结合枚举和逻辑的初始化器
init(type: DrinkType) {
if type == .coffee {
self.title = "咖啡"
self.color = .brown
self.iconName = "cup.and.saucer.fill"
} else {
self.title = "绿茶"
self.color = .green
self.iconName = "leaf.fill"
}
}

var body: some View {
HStack {
Image(systemName: iconName)
Text(title)
}
.padding()
.background(color)
.cornerRadius(10)
}
}

// 使用示例
// DrinkCardView(type: .coffee)

5. 提高性能的思维

视频最后强调了一个重要的观点:尽量将数据逻辑放在 body 之外(即放在 init 或变量中)。 因为 SwiftUI 的 body 会频繁重新渲染,如果把复杂的逻辑计算塞在 body 里,会浪费系统资源。通过初始化器预先算好值,能让你的 App 运行得更流畅。

ForEach 循环

1. 使用基础数字范围进行循环

最简单的 ForEach 用法是给定一个数值范围(Range)。比如你想在屏幕上垂直排列 10 个圆圈,你不需要写 10 次 Circle(),只需让 ForEach 循环 10 次即可。

  • 索引 (Index): 闭包内部提供的变量(通常命名为 index)代表当前是第几次循环。
1
2
3
4
5
6
7
8
9
10
VStack {
// 循环 10 次:从 0 到 9
ForEach(0..<10) { index in
HStack {
Circle()
.frame(width: 30, height: 30)
Text("这是第 \(index) 个项目")
}
}
}

2. 基于数组数据的循环

更实用的场景是根据一个现成的数组(Array)来生成视图。你可以遍历数组的索引(.indices),并根据当前索引从数组中提取对应的真实数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct MyListView: View {
// 准备一个字符串数组作为数据源
let data: [String] = ["苹果", "香蕉", "橘子", "西瓜"]

var body: some View {
VStack {
// 遍历数组的索引
ForEach(data.indices) { index in
Text("索引 \(index) 对应的是:\(data[index])")
.font(.headline)
}
}
}
}

3. 动态数据与 UI 自动更新

ForEach 的强大之处在于它是响应式的。如果你的数据源(数组)发生了变化,ForEach 会自动感知并更新屏幕上的 UI,无需手动刷新。

1
2
3
4
5
6
7
8
9
10
11
VStack {
// 每一个循环产生的视图都可以独立设置样式
ForEach(0..<5) { index in
RoundedRectangle(cornerRadius: 10)
.fill(index % 2 == 0 ? Color.blue : Color.red) // 奇偶行变色
.frame(height: 50)
.overlay(
Text("卡片 \(index)").foregroundColor(.white)
)
}
}

4. 为什么不直接用 Swift 的 for...in

在 SwiftUI 的 View 构建块中,我们必须使用 ForEach 而不是原生的 for...in 循环。这是因为 ForEach 本身也是一个视图容器(View),它可以被嵌入到 VStackHStack 中,并且符合 SwiftUI 的渲染机制。

5. 局限性与接下来的课程

ForEach 生成的视图过多(例如 100 个)超出屏幕范围时,你会发现无法滑动。视频最后提醒:在下一节课中,我们将通过 ScrollView 来解决这个问题,让循环生成的长列表变得可以滚动。

滚动视图

1. 基础垂直滚动

要让一个列表可滚动,只需将布局容器(通常是 VStack)包裹在 ScrollView 中即可。默认情况下,ScrollView 是垂直滚动的。

1
2
3
4
5
6
7
8
9
ScrollView {
VStack {
ForEach(0..<10) { index in
Rectangle()
.fill(Color.blue)
.frame(height: 300)
}
}
}

2. 控制滚动条显示

ScrollView 的初始化器允许你配置两个重要的参数:

  • axis (轴向): 设置为 .horizontal(水平)或 .vertical(垂直)。
  • showsIndicators (显示指示器): 设置为 false 可以隐藏滑动时右侧或底部的滚动条,让界面看起来更清爽(类似于 Instagram 的信息流)。
1
2
3
4
5
6
7
8
9
// 创建一个隐藏滚动条的水平滚动视图
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(0..<10) { index in
Circle()
.frame(width: 100, height: 100)
}
}
}

3. 嵌套滚动视图(实现类 Netflix/Spotify 布局)

你可以将水平滚动视图嵌套在垂直滚动视图中。这是构建复杂 App 主页(如 Netflix 的分类推荐流)的常用技巧:外层上下滑动切换分类,内层左右滑动浏览分类下的电影。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ScrollView { // 外层:垂直滑动
VStack {
ForEach(0..<5) { rowIndex in
ScrollView(.horizontal, showsIndicators: false) { // 内层:水平滑动
HStack {
ForEach(0..<10) { columnIndex in
RoundedRectangle(cornerRadius: 20)
.fill(Color.white)
.frame(width: 200, height: 150)
.shadow(radius: 5)
.padding()
}
}
}
}
}
}

4. 性能优化:LazyVStack 与 LazyHStack

当你处理成百上千条数据时,如果使用普通的 VStack,系统会尝试在页面加载的一瞬间同时创建这 1000 个视图,这会导致 App 卡顿甚至崩溃。 解决方案: 使用 LazyVStack(或 LazyHStack)。“Lazy(懒加载)”意味着只有当视图即将出现在屏幕上时,系统才会去创建它。这能极大地节省内存并提高响应速度。

1
2
3
4
5
6
7
8
9
10
11
ScrollView {
// 只有当用户向下滚动到某个方块时,该方块才会被渲染
LazyVStack {
ForEach(0..<100) { index in
Text("项目 \(index)")
.frame(maxWidth: .infinity)
.frame(height: 100)
.background(Color.gray.opacity(0.2))
}
}
}

5. 什么时候该用“Lazy”?

  • 如果你的数据量很小(如 10-20 个静态组件),普通的 VStack 即可。
  • 如果你的数据量较大,或者需要从网络下载图片,务必使用 LazyVStack

网格布局

1. 定义网格列 (GridItem)

在使用网格之前,你需要先告诉系统你的网格长什么样。通过定义一个 GridItem 数组,你可以控制每一列(或每一行)的大小策略。共有三种主要的尺寸模式:

  • Fixed(固定尺寸): 严格锁定像素宽度。
  • Flexible(灵活尺寸): 自动平分剩余空间,可设置最小/最大值。
  • Adaptive(自适应尺寸): 在指定的最小宽度下,尽可能多地在一个列定义中塞入更多的列(这是实现响应式照片墙的关键)。
1
2
3
4
5
6
7
8
9
10
11
// 定义 3 列灵活伸缩的列
let columns: [GridItem] = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]

// 或者:自适应模式(只要宽度够,就会自动增加列数)
let adaptiveColumns: [GridItem] = [
GridItem(.adaptive(minimum: 50))
]

2. 实现垂直网格 (LazyVGrid)

LazyVGrid 会按照你定义的列数,从左往右、从上往下填充内容。通常它会放在 ScrollView 中,因为它也是“懒加载(Lazy)”的,只有在用户滚动到该行时才会创建视图。

1
2
3
4
5
6
7
8
9
ScrollView {
LazyVGrid(columns: columns) {
ForEach(0..<50) { index in
Rectangle()
.frame(height: 150)
.overlay(Text("\(index)").foregroundColor(.white))
}
}
}

3. 实现水平网格 (LazyHGrid)

LazyHGrid 的逻辑与垂直网格相反,它需要你定义行数(rows),内容会从上往下、从左往右横向填充。它通常配合水平滚动的 ScrollView 使用。

1
2
3
4
5
6
7
8
9
10
11
let rows: [GridItem] = [GridItem(.fixed(100)), GridItem(.fixed(100))]

ScrollView(.horizontal) {
LazyHGrid(rows: rows) {
ForEach(0..<20) { index in
Circle()
.fill(Color.red)
.frame(width: 100)
}
}
}

4. 网格中的 Section 与吸顶效果

你可以像在 List 中一样,在网格中使用 Section 来对内容进行分组,并添加页眉(Header)或页脚(Footer)。LazyVGrid 还支持 pinnedViews 参数,让页眉在滚动时保持“吸顶(Sticky)”在屏幕顶端。

1
2
3
4
5
6
7
8
9
10
11
12
LazyVGrid(
columns: columns,
alignment: .center,
spacing: nil,
pinnedViews: [.sectionHeaders] // 开启页眉吸顶效果
) {
Section(header: Text("我的照片").font(.title).background(Color.white)) {
ForEach(0..<20) { _ in
Rectangle().frame(height: 100)
}
}
}

5. 什么时候该用网格而不是堆栈

  • 使用堆栈(Stacks): 当你只需要单行(HStack)或单列(VStack)简单排列时。
  • 使用网格(Grids): 当你需要多列排列(如 3x3 布局),或者希望布局能够根据屏幕宽度自动调整列数(Adaptive)时。

安全区域

1. 默认尊重安全区域

在 SwiftUI 中,所有的容器(如 VStack, ZStack, ScrollView)默认都会自动避开安全区域。这意味着即使你设置了背景颜色,如果直接加在容器上,屏幕最顶部和最底部通常会留下一片空白。

1
2
3
4
5
6
7
8
9
10
ZStack {
// 背景色默认只在安全区域内显示
Color.blue

VStack {
Text("内容在安全区域内")
.font(.largeTitle)
Spacer()
}
}

2. 忽略安全区域的修饰符

如果你希望某个视图(通常是背景图或背景色)铺满整个屏幕,可以使用 .ignoresSafeArea() 修饰符。这是 iOS 14 之后推荐的新方法,取代了旧版的 edgesIgnoringSafeArea

1
2
3
4
5
6
7
8
9
10
ZStack {
Color.blue
.ignoresSafeArea() // 让蓝色铺满整个屏幕,包括刘海和底部指示条

VStack {
Text("背景全屏,文字安全")
.foregroundColor(.white)
Spacer()
}
}

3. 指定忽略特定的边缘

有时候你只想让背景延伸到顶部,但底部的交互区仍需保留空间;或者相反。你可以通过参数精确控制忽略哪些方向。

1
2
3
4
5
6
7
8
VStack {
Text("只忽略顶部安全区域")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.red)
// 仅在顶部忽略安全区域
.ignoresSafeArea(edges: .top)
// 可选值包括:.all, .top, .bottom, .leading, .trailing

4. 滚动视图与安全区域的进阶用法

当使用 ScrollView 时,默认内容会在滑到底部时正好停在安全区域上方。如果你想让滚动区域看起来是全屏的,但最后的文字不被底部指示条遮挡,通常会将背景设置在 ScrollView 之外并忽略安全区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ZStack {
// 全屏底色
Color.green.ignoresSafeArea()

ScrollView {
VStack {
Text("滚动视图标题").font(.title)

ForEach(0..<10) { _ in
RoundedRectangle(cornerRadius: 25)
.fill(Color.white)
.frame(height: 200)
.padding()
}
}
}
// 注意:不要在 ScrollView 本身加 ignoresSafeArea
// 否则滑到最后的内容会被底部挡住
}

5. 最佳实践总结

  • 背景层: 始终使用 .ignoresSafeArea() 让视觉效果更现代。
  • 内容层(文字/按钮): 绝对不要随意忽略安全区域,否则用户可能点击不到按钮,或者文字会被刘海遮住。
  • 层级思维: 使用 ZStack 将“全屏背景层”和“安全内容层”分开处理,是构建专业界面的标准做法。

按钮

1. 基础按钮语法(纯文本)

这是最简单的初始化方式,只需要提供一个字符串作为标题。当用户点击按钮时,大括号 { } 里的代码就会执行。

1
2
3
4
Button("点我执行") {
// 这里编写点击按钮后要运行的代码
print("按钮被点击了!")
}

2. 自定义标签按钮(Label)

如果你想要按钮显示图标、形状或者复杂的文字排版,你需要使用带 label 参数的初始化方式。这种方式允许你把任何视图(View)放进按钮里。

1
2
3
4
5
6
7
8
9
10
Button(action: {
// 动作部分
print("带图标的按钮被点击")
}, label: {
// 外观部分:可以包含图片和文字的组合
HStack {
Image(systemName: "save")
Text("保存资料")
}
})

3. 使用按钮改变页面状态(@State)

为了让点击按钮后屏幕上的内容发生变化,我们需要使用 @State 变量。当我们在按钮动作中修改这个变量时,SwiftUI 会自动重新渲染受影响的视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ButtonStateView: View {
// 声明一个状态变量
@State var title: String = "等待点击..."

var body: some View {
VStack {
Text(title) // 这里的文字会随变量改变

Button("点击修改标题") {
self.title = "按钮已被按下!"
}
}
}
}

4. 深度自定义按钮样式

通过结合之前学到的修饰符(如 padding, background, cornerRadius),你可以做出极具设计感的按钮。

  • 技巧: 先加 padding 再加 background,背景才会包裹住间距。
1
2
3
4
5
6
7
8
9
10
11
Button(action: {
// 动作
}, label: {
Text("立即完成")
.font(.headline)
.foregroundColor(.white)
.padding()
.background(Color.blue)
.cornerRadius(10)
.shadow(radius: 5)
})

5. 制作圆形图标按钮

利用 Circle 形状和 overlay(叠加层),可以快速制作出像社交应用中那样的圆形功能按钮。

1
2
3
4
5
6
7
8
9
10
11
Button(action: { }) {
Circle()
.fill(Color.white)
.frame(width: 75, height: 75)
.shadow(radius: 10)
.overlay(
Image(systemName: "heart.fill")
.font(.largeTitle)
.foregroundColor(.red)
)
}

6. 按钮的内置着色 (Accent Color)

默认情况下,SwiftUI 按钮的文字和图标是系统蓝色的。你可以使用 .accentColor() 来全局修改这个可点击项的颜色。

1
2
Button("红色风格按钮") { }
.accentColor(.red) // 将默认蓝色改为红色

@State

1. @State 的核心定义与“事实来源”

@State 是一个属性包装器,它允许视图拥有一块可以被修改的内存空间。它是该视图内部数据的“事实来源(Source of Truth)”。当你在变量名前加上 @State 时,你就告诉了 SwiftUI:“请密切观察这个变量,它可能会发生变化。”

1
2
3
4
5
6
7
8
9
10
struct StateBasicView: View {
// 声明一个状态变量,通常建议使用 private 修饰,因为它只属于这个视图内部
@State private var myTitle: String = "初始标题"

var body: some View {
VStack {
Text(myTitle)
}
}
}

2. 自动触发 UI 刷新机制

这是 @State 最强大的特性:反应式编程。一旦你修改了标记为 @State 的变量值,SwiftUI 会立刻感知到变化,并自动重新运行 body 属性里的代码,将最新的数据渲染在屏幕上。

1
2
3
4
Button("点击修改标题") {
// 修改状态变量,视图会自动刷新显示新内容
self.myTitle = "按钮已被按下!"
}

3. 数据的持久性与内存管理

SwiftUI 的视图结构体是非常廉价的,它们会频繁地被系统销毁和重建。普通的属性在视图重建时会丢失数据并重置。但是,标记为 @State 的变量会被 SwiftUI 存放在一个特殊的、持久的内存区域。即使视图重建,状态值依然会被保留。

1
2
// 即使页面因为其他原因刷新,count 的值也会被系统记住
@State private var count: Int = 0

4. 提取 UI 属性为动态状态

你可以通过提取原本写死的 UI 属性(如颜色、背景、边距、字号)并将它们转换为 @State 变量,从而轻松实现动态界面。比如点击按钮切换背景颜色或增加页面间距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct ColorToggleView: View {
@State private var backgroundColor: Color = Color.green
@State private var title: String = "我是绿色背景"
@State private var count: Int = 0

var body: some View {
ZStack {
// 背景颜色会随状态变量自动改变
backgroundColor.ignoresSafeArea()

VStack(spacing: 20) {
Text(title)
Text("点击次数: \(count)")

Button("点击改变背景") {
backgroundColor = .orange
title = "背景变橙色了!"
count += 1
}
}
}
}
}

5. 开发建议与最佳实践

  • 保持私有性: 始终将 @State 属性标记为 private。这能确保该状态只被当前视图管理,避免外部代码意外干扰。
  • 适用场景: @State 仅适用于简单的、局部的数据(如字符串、布尔值、整数或简单的结构体)。
  • 进阶提示: 如果你需要将这个状态传递给子视图进行修改,你会用到下节课的知识点:@Binding

提取函数与子视图

1. 提取为计算属性 (Variables)

如果你有一段纯 UI 代码(不涉及复杂的参数传递),最简单的方法是将其提取为一个计算属性(Computed Property)。这相当于给这块 UI 起了一个名字,让 body 读起来像是在读大纲。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct PropertyView: View {
var body: some View {
ZStack {
Color.red.ignoresSafeArea()

// 使用提取出来的计算属性
contentLayer
}
}

// 技巧:将 UI 层级提取为变量
var contentLayer: some View {
VStack {
Text("标题")
.font(.largeTitle)
Text("副标题")
}
}
}

2. 提取为函数 (Functions)

当你需要根据不同的逻辑状态(比如传入不同的参数)来返回不同的视图时,应该使用函数。函数允许你像调用普通代码一样传递数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct FunctionView: View {
@State var backgroundColor: Color = Color.blue

var body: some View {
VStack {
// 调用函数,并获取它返回的视图
createButton(title: "设为红色", color: .red)
createButton(title: "设为绿色", color: .green)
}
}

// 技巧:使用函数提取带参数的 UI 组件
func createButton(title: String, color: Color) -> some View {
Button(title) {
backgroundColor = color
}
.padding()
.background(Color.white)
}
}

3. 提取为独立的子视图 (Subviews/Structs)

这是最彻底的重构方式。将一部分 UI 提取为一个独立的 struct。这样做有两大好处:

  1. 代码解耦: 子视图可以有自己的状态和逻辑。
  2. 跨文件复用: 这个子视图以后可以在 App 的任何地方被重复使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 主视图
struct MainView: View {
var body: some View {
VStack {
// 像使用系统组件一样使用自定义子视图
MyCustomRow(title: "我的收藏", icon: "heart.fill")
MyCustomRow(title: "系统设置", icon: "gear")
}
}
}

// 提取出来的独立子视图结构体
struct MyCustomRow: View {
let title: String
let icon: String

var body: some View {
HStack {
Image(systemName: icon)
Text(title)
Spacer()
}
.padding()
.background(Color.gray.opacity(0.1))
}
}

4. 重构的核心原则

  • 保持 Body 苗条: 理想情况下,你的 body 应该只有几十行代码,通过名称清晰的变量和函数来组合界面。
  • 优先使用变量: 如果没有参数,用变量(计算属性)性能更好。
  • 逻辑复杂用子视图: 如果某部分 UI 需要处理很多数据或被多次重复,务必提取为独立的 struct

提取子视图

1. 为什么要提取为独立的结构体

在上一节课中,我们学习了提取为“变量”或“函数”。但如果某个 UI 组件需要在不同地方多次出现,且每次显示的文字、颜色都不一样,那么将其提取为一个独立的 struct 是最佳选择。 这样做的最大优势是:子视图可以拥有自己的初始化器(Initializer),从而接收动态数据。

2. 使用 Xcode 快捷功能一键提取

你不需要手动创建结构体。在 Xcode 中:

  1. 按住 Command 键并点击你想要提取的代码块(如一个 VStack)。
  2. 在弹出菜单中选择 Extract Subview
  3. Xcode 会自动在文件下方生成一个新的 struct,你只需要给它起个名字即可(例如 MyItemView)。

3. 为子视图添加动态属性

提取出来的子视图默认是静态的。为了让它变强,你需要给它定义属性(变量)。这样,你在主页面调用它时,就像调用系统组件(如 Text)一样,可以传入不同的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 提取出来的独立子视图
struct MyItemView: View {
let title: String
let count: Int
let color: Color

var body: some View {
VStack {
Text("\(count)")
Text(title)
}
.padding()
.background(color)
.cornerRadius(10)
}
}

4. 在主视图中复用子视图

一旦定义好了带参数的子视图,你就可以在主视图的 body 中轻松地多次复用它,并传入截然不同的数据。这让主视图的代码逻辑变得像读“菜单”一样简单明了。

1
2
3
4
5
6
7
8
9
10
struct MainContentView: View {
var body: some View {
HStack {
// 通过传入不同的参数,复用同一个组件
MyItemView(title: "苹果", count: 1, color: .red)
MyItemView(title: "橘子", count: 10, color: .orange)
MyItemView(title: "香蕉", count: 34, color: .yellow)
}
}
}

5. 综合重构策略:层级化布局

视频中展示了一个专业的布局思维:

  • 背景层(Background Layer): 提取为变量。
  • 内容层(Content Layer): 提取为变量。
  • 具体条目(Individual Items): 提取为独立的子视图结构体。 这种层级化的重构方式,能确保即使是非常复杂的屏幕,其主 body 代码也能控制在 10-20 行之内。

@Binding

1. @Binding 的核心定义

@Binding 不会存储数据,它只是一个指向其他地方(通常是父视图的 @State)的链接引用。它允许子视图对父视图拥有的“事实来源”拥有读写权限。

1
2
3
4
5
6
7
8
9
10
11
struct ChildView: View {
// 声明一个绑定变量,不需要初始值
@Binding var backgroundColor: Color

var body: some View {
Button("改变背景色") {
// 这里修改的值会直接反映到父视图的状态中
backgroundColor = .orange
}
}
}

2. 实现双向数据绑定

@Binding 创造了一条“双向通道”:

  1. 父传子: 父视图的状态改变,子视图自动刷新。
  2. 子改父: 子视图修改绑定变量,父视图的状态也随之更新。

3. 传递绑定的语法($ 符号)

当你在父视图中调用子视图并传递绑定关系时,必须在变量名前加上 $ 符号。这个符号的作用是提取该状态的“投影值(Projected Value)”,即获取一个 Binding 对象。

1
2
3
4
5
6
7
8
9
10
11
12
struct ParentView: View {
@State private var bgColor: Color = .green

var body: some View {
ZStack {
bgColor.ignoresSafeArea()

// 使用 $ 符号将绑定关系传递给子视图
ChildButtonView(color: $bgColor)
}
}
}

4. 为什么需要 @Binding(应用场景)

  • 代码重构: 当你将一个巨大的 body 拆分成多个小的 struct 时,子视图需要访问主页面的数据。
  • 组件化开发: 比如你写了一个自定义的开关按钮或选择器组件,这个组件必须能修改外部传入的状态值才有意义。
  • 逻辑解耦: 让子视图只负责交互逻辑(比如按钮点击),而让父视图负责持有和管理最终的数据。

5. 综合实战示例

下面的代码演示了如何在点击子视图按钮时,同时修改父视图的背景颜色和标题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct MainView: View {
@State private var title: String = "等待操作"
@State private var backgroundColor: Color = .blue

var body: some View {
ZStack {
backgroundColor.ignoresSafeArea()

VStack {
Text(title).font(.largeTitle)

// 传递两个绑定关系
MyCustomButton(title: $title, bg: $backgroundColor)
}
}
}
}

struct MyCustomButton: View {
@Binding var title: String
@Binding var bg: Color

var body: some View {
Button("点击执行重置") {
title = "状态已重置!"
bg = .red
}
.padding()
.background(Color.white)
.cornerRadius(10)
}
}

6. 开发建议

  • 不要滥用: 视频中提到,如果只是想在子视图中显示数据而不需要修改,请直接使用普通的 let 常量。
  • 层级深度: 建议只在相邻层级(父子)之间使用 @Binding。如果数据需要跨越 3-4 层传递,建议考虑后续课程会讲到的 @EnvironmentObject

if-else

1. 在布局容器中使用 if-else

你可以在 VStackHStackZStack 内部直接编写 if 语句。这通常用于完全切换视图组件。例如:点击按钮显示加载动画,或者切换登录/注册页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@State var isLoading: Bool = false

var body: some View {
VStack {
Button("切换状态") {
isLoading.toggle() // 切换布尔值
}

// 根据状态决定渲染哪个视图
if isLoading {
ProgressView() // 显示加载圈
} else {
Circle()
.frame(width: 100, height: 100)
}
}
}

2. 使用三元运算符 (Ternary Operator)

如果你只是想改变某个视图的属性(如颜色、大小、透明度),而不是替换整个视图,使用三元运算符 (条件 ? 结果A : 结果B) 会比 if-else 更简洁、更高效。

1
2
3
4
5
6
7
8
9
@State var isCircle: Bool = false

var body: some View {
Button("变形") { isCircle.toggle() }

RoundedRectangle(cornerRadius: isCircle ? 50 : 0) // 圆角随状态切换
.fill(isCircle ? Color.red : Color.blue) // 颜色随状态切换
.frame(width: 100, height: 100)
}

3. 结合动画 (withAnimation)

在 SwiftUI 中,条件判断触发的 UI 变化默认是瞬间完成的,看起来比较生硬。为了获得平滑的过渡效果,通常会在修改状态变量时包裹在 withAnimation 函数中。

1
2
3
4
5
Button("点击平滑切换") {
withAnimation(.easeInOut) {
isLoading.toggle()
}
}

4. 逻辑判断的进阶用法 (&& 与 !)

你可以在条件中使用逻辑运算符:

  • && (且): 只有当两个条件都满足时才显示。
  • ! (非): 反转布尔值。
1
2
3
4
// 只有当用户已登录 且 且数据未加载时显示提示
if isLoggedIn && !isLoading {
Text("欢迎回来!")
}

5. If-Else vs. Ternary 的选择建议

  • 使用 if-else 当你需要添加或移除整个视图层级(例如从首页切换到详情页)时。
  • 使用三元运算符: 当你只是在微调同一个视图的样式修饰符(Modifiers)时。这样可以保持视图层级的稳定性,对系统性能更友好。

6. 综合示例:构建一个动态反馈界面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct ConditionalView: View {
@State var isShowingCircle: Bool = false
@State var isShowingRectangle: Bool = false

var body: some View {
VStack(spacing: 20) {
Button("显示圆形: \(isShowingCircle.description)") {
isShowingCircle.toggle()
}
Button("显示矩形: \(isShowingRectangle.description)") {
isShowingRectangle.toggle()
}

if isShowingCircle {
Circle().frame(width: 100, height: 100)
}

if isShowingRectangle {
Rectangle().frame(width: 100, height: 100)
}

// 使用三元运算符控制颜色和文字
Text(isShowingCircle && isShowingRectangle ? "两个都在!" : "缺了一个")
.foregroundColor(isShowingCircle ? .green : .red)
}
}
}

三元运算符

三元运算符本质上是 if-else 语句的缩写版本。在 SwiftUI 的“声明式”环境中,我们经常需要根据某个状态(State)来改变一个属性(如颜色、大小),使用传统的 if-else 会让代码层级变得非常臃肿,而三元运算符可以将逻辑写在单行内。

它的逻辑可以总结为:“这个条件成立吗?如果成立就用 A,否则就用 B。”

1
2
// 语法:
条件 ? 结果为真时的值 : 结果为假时的值
  1. 动态改变颜色

这是三元运算符最常见的用途。与其编写整个 if-else 块来替换视图,不如直接在 .fill().foregroundColor() 中进行切换。

1
2
3
4
5
6
7
8
9
10
11
12
@State var isPressed: Bool = false

var body: some View {
Button("切换状态") {
isPressed.toggle()
}

RoundedRectangle(cornerRadius: 25)
// 如果 isPressed 为真,显示红色;否则显示蓝色
.fill(isPressed ? Color.red : Color.blue)
.frame(width: 200, height: 100)
}
  1. 动态调整尺寸与边距

你可以用它来控制视图的物理属性,实现类似“展开/收起”或“缩放”的视觉效果。

1
2
3
4
5
6
Circle()
.frame(
width: isPressed ? 200 : 100,
height: isPressed ? 200 : 100
)
.padding(isPressed ? 50 : 20)
  1. 多重属性联合切换

你可以在同一个组件上叠加多个三元运算符,让整个 UI 随着一个变量的改变而产生连带反应。

1
2
3
4
5
VStack {
Text(isPressed ? "正在运行" : "已停止")
.font(isPressed ? .title : .headline)
.foregroundColor(isPressed ? .green : .gray)
}

动画

1. 显式动画 (withAnimation)

显式动画是指在修改状态变量的代码块上包裹 withAnimation 函数。它告诉 SwiftUI:“所有因为这个状态改变而受到影响的视图,请以动画形式过渡。”

1
2
3
4
5
6
7
8
@State var isAnimated: Bool = false

Button("开始动画") {
// 显式告知系统:这里的状态改变需要动画
withAnimation(Animation.default) {
isAnimated.toggle()
}
}

2. 隐式动画 (.animation 修饰符)

隐式动画是将修饰符直接作用于特定的视图组件上。当该视图关联的状态发生变化时,它会自动应用指定的动画效果。

  • 注意: 从 iOS 15 开始,建议在使用 .animation 时明确指定它监听的变量(value: someValue),以避免不必要的重复动画。
1
2
3
4
5
RoundedRectangle(cornerRadius: isAnimated ? 50 : 0)
.fill(isAnimated ? Color.red : Color.blue)
.frame(width: isAnimated ? 100 : 200, height: 100)
// 监听 isAnimated 变量,一旦改变就应用动画
.animation(.default, value: isAnimated)

3. 动画计时曲线 (Timing Curves)

计时曲线决定了动画的速度如何变化(是匀速、先快后慢,还是具有弹跳感)。

  • .linear:匀速运动。
  • .easeIn:由慢变快(适合物体进入屏幕)。
  • .easeOut:由快变慢(适合物体停止运动)。
  • .easeInOut:两头慢,中间快(最常用的自然效果)。

4. 弹簧动画 (Spring Animations)

弹簧动画是 iOS 系统的灵魂,它能赋予界面真实的物理感。你可以通过设置响应速度(response)和阻尼系数(dampingFraction)来微调效果。

  • Damping (阻尼): 越小越弹;如果设置为 0.5 以下,会有明显的往复弹跳。
1
2
3
4
5
6
7
8
9
withAnimation(
.spring(
response: 0.5, // 动画完成的时长感
dampingFraction: 0.7, // 弹跳强度(0-1,越小越弹)
blendDuration: 0 // 混合时长
)
) {
isAnimated.toggle()
}

5. 动画属性的联动

你可以同时动画化多个属性。当你点击一个按钮时,视图可以同时改变位置、颜色、圆角、旋转角度和阴影,所有的过渡都会同步进行。

1
2
3
4
RoundedRectangle(cornerRadius: isAnimated ? 50 : 25)
.fill(isAnimated ? Color.red : Color.green)
.rotationEffect(Angle(degrees: isAnimated ? 360 : 0)) // 旋转
.offset(y: isAnimated ? 300 : 0) // 位移

6. 总结建议

  • 优先使用 withAnimation:因为它能让你更精确地控制哪些代码触发动画,且逻辑清晰。
  • 不要过度动画:过多的弹簧效果会让用户感到眼花缭乱,保持简洁自然的过渡是关键。
  • 结合条件判断:动画通常与 if-else 或三元运算符配合使用,实现视图的平滑显现或消失。

动画曲线

1. 动画曲线的核心定义

动画曲线(Animation Curves)本质上是在控制动画执行过程中的速度变化规律。虽然两个动画可能都是从 A 点移动到 B 点,且耗时相同,但由于曲线不同,它们在路径中间的快慢节奏会完全不同。这能让 UI 交互从“机械化”变得“人性化”。

2. 四种基础内置曲线

视频对比了 SwiftUI 提供的四种核心计时曲线,它们涵盖了大部分日常开发需求:

  • .linear (线性):从头到尾匀速运动。虽然简单,但看起来比较生硬,通常用于不间断的循环动画(如旋转进度条)。
  • .easeIn (渐入):开始时非常缓慢,随后逐渐加速。适合物体从屏幕外飞入的场景。
  • .easeOut (渐出):开始时很快,快结束时有明显的减速。这最符合真实世界的物理感觉(摩擦力让物体停下),是视觉上最舒适的曲线之一。
  • .easeInOut (渐入渐出):两头慢,中间快。这是系统的默认选项,能够平衡启动和停止的突兀感。
1
2
3
4
5
6
7
// 基础曲线应用代码片段
VStack {
RoundedRectangle(cornerRadius: 20)
.frame(width: isAnimating ? 350 : 50, height: 100)
// 传递 value 参数以确保只有该状态变化触发动画
.animation(.easeOut(duration: 1.0), value: isAnimating)
}

3. 弹簧动画 (Spring Animation)

.spring() 是视频重点推荐的内容。它模拟了物理弹簧的震动,自带一种“灵动感”和“弹性”。

  • 默认弹簧:直接使用 .spring() 即可获得非常自然的反馈。
  • 自定义控制:通过参数微调,可以让弹簧的表现各异。
1
2
3
4
5
6
7
8
9
10
11
// 具有回弹效果的自定义弹簧示例
RoundedRectangle(cornerRadius: 20)
.frame(width: isAnimating ? 350 : 50, height: 100)
.animation(
.spring(
response: 0.5, // 灵敏度:数值越小,回弹越快
dampingFraction: 0.6, // 阻尼感:值越小(如0.2),回弹次数越多;1.0 则完全不回弹
blendDuration: 1.0 // 动画切换时的过渡平滑度
),
value: isAnimating
)

4. 参数调试与视觉观察

为了更好地理解这些曲线,视频中提到了两个实用的调试技巧:

  • 拉长时长:在测试曲线差异时,将 duration 设置为 5.0 或 10.0 秒。这样你可以肉眼识别出 .easeIn 在起步时的迟疑,或者 .easeOut 在结尾时的优雅减速。
  • 对比测试:像视频中那样,将多个视图纵向排列并赋予不同的曲线,点击同一个按钮触发,这是学习动画节奏最直观的方式。

5. 开发建议与实战总结

  • 优先考虑弹簧:对于按钮反馈、弹窗出现等交互,.spring() 通常比普通曲线更有高级感。
  • 遵循物理逻辑
    • 物体进入屏幕:.easeIn
    • 物体离开屏幕:.easeOut
    • 位置/颜色平滑过渡:.easeInOut
  • 克制使用回弹:除非是为了增加趣味性,否则不要把弹簧的 dampingFraction 设置得过低,频繁的剧烈抖动会让用户感到视觉疲劳。

转场

1. Transition 与 Animation 的本质区别

  • Animation(动画):用于处理已经在屏幕上的视图。比如改变它的颜色、大小或位置。
  • Transition(转场):用于处理视图的插入和移除(Add or Remove)。当你使用 if 语句或 switch 来切换视图是否显示时,就需要用到转场。

2. 基础转场类型

SwiftUI 内置了几种非常直观的转场效果,你可以直接通过 .transition() 修饰符调用:

  • .opacity:最基础的淡入淡出效果。
  • .scale:视图从中心点放大出现,或缩小消失。
  • .slide:视图从左侧滑入,并向右侧滑出(这是默认方向)。
  • .move(edge:):比 slide 更灵活,你可以指定从哪个边缘滑入滑出(.top, .bottom, .leading, .trailing)。
1
2
3
4
5
6
7
if isShowing {
RoundedRectangle(cornerRadius: 25)
.fill(Color.yellow)
.frame(width: 300, height: 300)
// 指定视图从屏幕底部滑入滑出
.transition(.move(edge: .bottom))
}

3. 组合转场与非对称转场

如果你觉得单一效果太单调,SwiftUI 允许你把多种效果“叠加”在一起:

  • .combined(with:):同时执行两种效果。例如:一边放大一边淡入。
  • .asymmetric(insertion:removal:):这是进阶技巧,允许你为“出现”和“消失”设置完全不同的动画。例如:从左边滑进来,但通过淡化消失。
1
2
3
4
5
// 非对称转场示例
.transition(.asymmetric(
insertion: .move(edge: .leading), // 出现时:从左侧滑入
removal: .scale.combined(with: .opacity) // 消失时:同时进行缩放和淡化
))

4. 触发转场的关键:withAnimation

这是视频中特别强调的“巨坑”:仅仅给视图添加 .transition() 修饰符是不会产生动画效果的。

转场必须配合环境动画才能生效。你必须在改变触发状态(比如那个布尔值)时,将其包装在 withAnimation 块中。

1
2
3
4
5
6
Button("切换视图") {
// 必须包裹在 withAnimation 中,否则转场会瞬间完成,没有动效
withAnimation(.easeInOut) {
showView.toggle()
}
}

5. 实战避坑指南:ZStack 与 zIndex

ZStack 中使用转场时,有时会发现消失的视图瞬间就不见了,或者被新出现的视图挡住。

  • 现象:由于 SwiftUI 在处理视图移除时,图层层级(Z-axis)可能会发生微妙变化,导致消失动画被遮盖。
  • 对策:给正在进行转场的视图手动添加 .zIndex(1.0)。这能确保视图在消失的过程中,始终保持在图层的最顶层,让动画完整展示。

6. 综合案例演示

下面的代码展示了如何利用非对称转场创建一个优雅的弹出面板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct TransitionBootcamp: View {
@State private var showView: Bool = false

var body: some View {
ZStack(alignment: .bottom) {
VStack {
Button("点我展示详情") {
withAnimation(.spring()) {
showView.toggle()
}
}
Spacer()
}

if showView {
RoundedRectangle(cornerRadius: 30)
.fill(Color.white)
.shadow(radius: 20)
.frame(height: UIScreen.main.bounds.height * 0.5)
// 组合转场:移动 + 缩放 + 透明度
.transition(.asymmetric(
insertion: .move(edge: .bottom),
removal: AnyTransition.opacity.combined(with: .move(edge: .bottom))
))
// 确保消失时不会被底部背景遮挡
.zIndex(1)
}
}
.ignoresSafeArea(edges: .bottom)
}
}

弹出视图

1. Sheet 与 FullScreenCover 的定义与区别

这两种修饰符都用于在当前视图之上弹出另一个视图,但视觉效果和交互逻辑有所不同:

  • .sheet():弹出后不会完全遮盖父视图,顶部会留出一小部分父视图的边缘。它默认支持手势下拉关闭
  • .fullScreenCover():顾名思义,它会完全覆盖整个屏幕(包括状态栏区域)。它不支持手势下拉关闭,必须手动通过代码(如关闭按钮)来移除。

2. 实现弹出层的基础逻辑

要显示一个 Sheet,你需要三个核心要素:

  1. 一个 @State 布尔变量,用于控制是否显示。
  2. 一个触发动作(如按钮点击),将布尔值设为 true
  3. 一个 .sheet() 修饰符,绑定该布尔变量并提供要显示的视图内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct SheetsBootcamp: View {
@State var showSheet: Bool = false

var body: some View {
ZStack {
Color.green.ignoresSafeArea()

Button("显示详情") {
showSheet.toggle()
}
.sheet(isPresented: $showSheet) {
// 这里通常放置另一个 Struct 视图
SecondScreen()
}
}
}
}

3. 如何在子视图中主动关闭弹出层

当你弹出一个新页面后,除了 Sheet 自带的下拉手势,通常还需要在页面内提供一个“关闭”按钮。这需要用到 @Environment 变量。

  • 核心变量\.presentationMode(在较新版本的 SwiftUI 中,建议使用 \.dismiss,但视频中讲解的是经典用法)。
  • 执行逻辑:调用其 wrappedValue.dismiss() 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct SecondScreen: View {
// 获取环境中的展示模式变量
@Environment(\.presentationMode) var presentationMode

var body: some View {
ZStack(alignment: .topLeading) {
Color.red.ignoresSafeArea()

Button(action: {
// 主动关闭当前视图
presentationMode.wrappedValue.dismiss()
}, label: {
Image(systemName: "xmark")
.foregroundColor(.white)
.font(.largeTitle)
.padding(20)
})
}
}
}

4. 开发中的重要限制(避坑指南)

视频中特别强调了新手最容易犯的几个错误,请务必注意:

  • 单一性限制:在一个视图层级(View Hierarchy)中,只能使用一个 .sheet().fullScreenCover()。如果你在一个 VStack 里写了两个 sheet 修饰符,通常只有一个会生效。
  • 严禁条件逻辑:不要在 .sheet 的闭包内部编写 if-else 来切换不同的视图(例如:if a { View1() } else { View2() })。
    • 原因:Sheet 的内容在视图加载时就已经初始化了,条件逻辑往往无法如预期般动态更新。
    • 正确做法:如果需要弹出不同的内容,建议在后面进阶课程中学习如何通过绑定 Item 来动态展示。

5. 总结建议

  • 交互选择:如果只是展示简单的信息或简单的输入框,优先使用 .sheet,因为下拉关闭的手势非常符合 iOS 用户的直觉。
  • 全屏选择:如果是视频播放、拍照界面或需要用户强制完成的任务,使用 .fullScreenCover 以确保用户不会误操作滑走。
  • 代码组织:虽然视频中为了演示将两个 Screen 写在了同一个文件里,但在实际开发中,建议将每一个 Screen 提取成独立的 Swift 文件,保持代码整洁。

弹出层

1. 三种实现弹出层的方法概览

在 SwiftUI 中,想要在当前页面上“盖”一层新的视图,通常有三种主流方案。视频通过对比演示了它们在性能、控制力和视觉效果上的差异。

2. 方法一:使用内置的 .sheet()

这是最标准、最快速的方法。它调用系统底层的模态演示逻辑。

  • 优点:代码最少,自带顺滑的交互手势(下拉关闭),视觉上符合 iOS 系统规范。
  • 局限:你很难控制它弹出的精确高度,且无法在弹出层显示的同时与底层视图进行复杂的交互。
1
2
3
4
// 标准 Sheet 写法
.sheet(isPresented: $showNewScreen) {
NewScreen()
}

3. 方法二:使用 Transitions(转场)

通过 ZStack 结合 if 语句和 .transition 来手动控制视图的插入和移除。

  • 实现要点:必须配合 withAnimation 触发状态改变。
  • 优点:完全自定义。你可以决定它是从左边滑入、还是从中间放大,甚至可以实现“非对称”的进出效果。
  • 注意点:视图在 iffalse 时会从层级中彻底销毁。
1
2
3
4
5
6
7
8
9
10
ZStack {
// 底层内容...

if showNewScreen {
NewScreen()
.padding(.top, 100)
.transition(.move(edge: .bottom))
.zIndex(2.0) // 关键:确保消失动画不被遮挡
}
}

4. 方法三:使用 Offsets(偏移量)

这种方法最为“暴力”但也最灵活:视图始终存在于 ZStack 中,只是平时被推到了屏幕之外(例如 offset(y: 1000))。

  • 实现要点:通过计算属性或三元运算符控制 y 轴偏移。
  • 优点:性能极佳,因为视图不需要反复创建和销毁。它能实现极其精细的跟手动画(Drag Gesture 的基础)。
  • 缺点:即使视图不可见,它依然占据内存,且逻辑上它“就在那儿”。
1
2
3
4
5
6
7
ZStack {
NewScreen()
.padding(.top, 100)
// 根据布尔值改变 Y 轴位置
.offset(y: showNewScreen ? 0 : UIScreen.main.bounds.height)
.animation(.spring(), value: showNewScreen)
}

5. 核心避坑技巧:zIndex 的妙用

在使用 方法二(Transitions) 时,新手经常遇到“消失动画瞬间失效”的问题。

  • 原因:SwiftUI 在移除视图时,默认可能会改变 Z 轴的渲染顺序,导致正在退出的视图被背景直接覆盖,看起来就像动画断掉了一样。
  • 解决方案:给弹出层强制指定一个较高的 .zIndex(2.0),给背景视图指定较低的 .zIndex(1.0)。这样无论视图如何切换,弹出层始终在最前端完成动画。

6. 开发建议:我该选哪一个?

  • 如果是标准的设置页、输入框:无脑选择 .sheet()。它能提供用户最熟悉的 iOS 原生体验。
  • 如果需要独特的动画效果:选择 Transitions。比如从中间淡入并轻微缩放。
  • 如果要实现类似 Apple Maps 那种可以停在半路、随手拖动的面板:必须选择 Offsets。它是实现复杂手势交互(Gesture)的唯一基石。

导航

虽然在较新版本的 SwiftUI 中,NavigationStack 逐渐取代了 NavigationView,但理解这些基础概念对于维护旧代码和掌握导航原理依然至关重要。

1. NavigationView 的核心定义

NavigationView 是一个容器视图,它为整个视图层级提供了导航能力。它会在屏幕顶部自动预留出导航栏区域,并管理视图的“压入(Push)”和“弹出(Pop)”逻辑。在 UIKit 中,它的对应概念是 UINavigationController

NavigationLink 是触发跳转的“开关”。它包含两个核心部分:Destination(目的地)Label(标签)。当用户点击标签中的 UI 元素时,系统会将目的地视图推入导航栈。

Swift

1
2
3
4
5
6
NavigationView {
VStack {
// Destination: 点击后去哪;Label: 界面上显示什么
NavigationLink("跳转至详情页", destination: Text("这是详情页的内容"))
}
}

3. 导航栏标题的设置 (navigationTitle)

视频强调了一个非常关键的细节:.navigationTitle 修饰符必须附加在 NavigationView 内部的子视图上,而不是 NavigationView 本身。

  • Display Mode: 你可以通过 .navigationBarTitleDisplayMode 控制标题样式。
    • .large: 大标题(默认,随滚动收缩)。
    • .inline: 标准的小标题,位于导航栏中心。
1
2
3
4
5
6
7
8
NavigationView {
ScrollView {
Text("内容区域")
}
// 注意:修饰符写在 ScrollView 上,而非 NavigationView
.navigationTitle("我的首页")
.navigationBarTitleDisplayMode(.automatic)
}

4. 导航栏按钮 (NavigationBarItems)

在导航栏的左右两侧添加按钮。虽然现在推荐使用 .toolbar,但视频中讲解的是经典的 .navigationBarItems

1
2
3
4
5
6
7
.navigationBarItems(
leading: Image(systemName: "person.fill"),
trailing: NavigationLink(
destination: MySettingsView(),
label: { Image(systemName: "gear") }
)
)

5. 数据传递实战

通常我们需要在跳转时将数据从父视图传递给子视图。这可以通过初始化子视图时直接传入变量来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct MyDetailView: View {
let itemTitle: String

var body: some View {
Text("您选择的是:\(itemTitle)")
.navigationTitle(itemTitle)
}
}

// 在父视图中使用:
NavigationLink(destination: MyDetailView(itemTitle: "苹果")) {
Text("点击查看苹果详情")
}

6. 核心开发建议与注意事项

  • 单一容器原则:在一个 App 视图层级中,永远只使用一个 NavigationView。不要在子页面中再次嵌套 NavigationView,否则会导致导航栏重叠或手势失效。
  • 隐藏导航栏:如果某个页面不需要显示顶部栏,可以使用 .navigationBarHidden(true)
  • 关于 Deprecation:视频提到随着 iOS 的更新,Apple 引入了 NavigationStack。虽然用法相似,但 NavigationStack 在处理大规模动态列表跳转时性能更优。

List 组件

1. List 的基础设置与动态数据

在实现编辑功能之前,首先需要一个动态的数据源(通常是 @State 数组)。为了让 List 能够高效识别每个元素,数据模型通常需要遵循 Identifiable 协议,或者在 List 中指定 id: \.self

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct ListBootcamp: View {
@State var fruits: [String] = ["苹果", "香蕉", "橘子", "桃子"]

var body: some View {
NavigationView {
List {
Section(header: Text("水果列表")) {
ForEach(fruits, id: \.self) { fruit in
Text(fruit.capitalized)
}
}
}
.navigationTitle("水果清单")
}
}
}

2. 实现删除功能 (.onDelete)

onDeleteForEach 上的一个修饰符(注意:不是直接加在 List 上)。它会向关联的函数传递一个 IndexSet,代表用户滑动的行索引。

  • 实现步骤
    1. ForEach 上添加 .onDelete(perform: delete)
    2. 编写 delete 函数,使用 remove(atOffsets:) 方法更新数组。
1
2
3
4
5
6
7
// 在 ForEach 上添加
.onDelete(perform: delete)

// 逻辑实现
func delete(indexSet: IndexSet) {
fruits.remove(atOffsets: indexSet)
}

3. 实现移动与排序功能 (.onMove)

.onMove 允许用户在编辑模式下重新排列列表顺序。它同样作用于 ForEach

  • 实现步骤
    1. 添加 .onMove(perform: move)
    2. 编写 move 函数,使用数组自带的 move(fromOffsets:toOffset:) 方法。
1
2
3
4
5
6
7
// 在 ForEach 上添加
.onMove(perform: move)

// 逻辑实现
func move(from: IndexSet, to: Int) {
fruits.move(fromOffsets: from, toOffset: to)
}

4. 使用内置的编辑按钮 (EditButton)

虽然用户可以通过左滑删除,但为了更好的用户体验,iOS 提供了一个标准的“编辑”模式。SwiftUI 内置了 EditButton(),点击它会自动开启当前导航栏下所有列表的编辑状态。

1
2
3
4
.navigationBarItems(
leading: EditButton(), // 左侧:进入/退出编辑模式
trailing: addButton // 右侧:自定义添加按钮
)

5. 添加新元素

添加操作通常通过按钮触发。你只需要向 @State 数组中 append 新的数据,SwiftUI 就会自动检测到状态变化并刷新界面。

1
2
3
4
5
6
7
8
9
var addButton: some View {
Button("添加") {
add()
}
}

func add() {
fruits.append("新水果")
}

6. 核心开发建议与注意事项

  • ForEach 是关键onDeleteonMove 必须写在 ForEach 上。如果你直接在 List 里写死视图,这些修饰符将无法工作。
  • 环境一致性EditButton 只有在 NavigationView(或 NavigationStack)的环境下才能发挥最大作用,因为它通常被放置在导航栏中。
  • 数据同步:在执行删除或移动操作时,务必确保数组的修改是在主线程上完成的,否则可能会引起界面刷新崩溃。
  • 视觉优化:当列表为空时,可以考虑使用 if fruits.isEmpty 展示占位图,提升用户体验。

Alert(警告弹窗)

1. Alert 的核心定义与触发逻辑

在 SwiftUI 中,Alert 的显示是声明式的。你不是通过调用一个函数来“弹出”它,而是将其绑定到一个布尔值的状态(@State)。当布尔值变为 true 时,系统会自动渲染并显示弹窗。

2. 基础 Alert 的代码实现

要实现一个基础 Alert,你需要一个 @State 变量来控制其显示状态,并在视图上添加 .alert 修饰符。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct AlertBootcamp: View {
@State var showAlert: Bool = false

var body: some View {
Button("点击显示弹窗") {
showAlert.toggle()
}
// 绑定布尔值,当为 true 时显示内容
.alert(isPresented: $showAlert) {
Alert(title: Text("这是标题"))
}
}
}

3. 自定义按钮与交互

Alert 允许你配置不同类型的按钮(如取消、销毁等),并为这些按钮绑定具体的逻辑操作。

  • Primary Button & Secondary Button:你可以同时提供两个选项,比如“确定”和“取消”。
  • Destructive Button:这种按钮通常显示为红色,用于提醒用户该操作具有破坏性(如删除)。
1
2
3
4
5
6
7
8
9
10
11
.alert(isPresented: $showAlert) {
Alert(
title: Text("确认删除吗?"),
message: Text("此操作无法撤销。"),
primaryButton: .destructive(Text("删除"), action: {
// 在这里执行删除逻辑
print("文件已删除")
}),
secondaryButton: .cancel(Text("取消"))
)
}

4. 动态显示不同内容的 Alert

在实际开发中,你可能需要根据不同的错误类型显示不同的弹窗。视频中介绍了一种进阶方案:通过定义一个 Enum(枚举) 来管理不同的 Alert 状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum MyAlertError {
case success
case noInternet
}

@State var alertType: MyAlertError? = nil

// 在 View 中
.alert(isPresented: $showAlert) {
getAlert()
}

func getAlert() -> Alert {
switch alertType {
case .noInternet:
return Alert(title: Text("网络连接错误"))
case .success:
return Alert(title: Text("上传成功!"))
default:
return Alert(title: Text("发生未知错误"))
}
}

5. 开发建议与避坑指南

  • 保持简洁:Alert 的标题应该言简意赅。如果需要详细解释,请利用 message 参数,而不是把所有文字塞进标题。
  • 单一职责:不要在一个 .alert 里尝试处理过于复杂的业务流。Alert 的本质是“中断”,应该只用于关键的确认或提示。
  • 关于新版本 API:视频中讲解的是经典版本的 Alert 构造器。在 iOS 15 之后,苹果引入了新的 .alert("Title", isPresented: $show) { Button(...) } 语法,虽然用法更灵活(支持直接写 Button 视图),但逻辑核心(由状态驱动)依然是一致的。

ActionSheet

ActionSheet 与 Alert 非常相似,但它通常用于提供一组选项(如分享、编辑、删除),并从屏幕底部滑出。

虽然在 iOS 15 之后 Apple 推荐使用 confirmationDialog,但理解 actionSheet 的声明式逻辑对掌握 SwiftUI 的弹窗体系非常有帮助。

1. ActionSheet 的核心定义与场景

ActionSheet 是一种特定的模态展示方式,它主要用于以下场景:

  • 用户需要从多个相关选项中做出选择。
  • 需要执行一个破坏性操作(如删除)并需要二次确认。
  • 相比 Alert,ActionSheet 提供了更大的空间来放置多个按钮,且视觉上更接近系统菜单。

2. 基础 ActionSheet 的实现

实现 ActionSheet 的逻辑与 Alert 完全一致:通过一个 @State 变量驱动,并在视图上添加 .actionSheet 修饰符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ActionSheetBootcamp: View {
@State var showActionSheet: Bool = false

var body: some View {
Button("点击显示操作表") {
showActionSheet.toggle()
}
.actionSheet(isPresented: $showActionSheet) {
ActionSheet(
title: Text("这是标题"),
message: Text("这是副标题"),
buttons: [
.default(Text("常规按钮")),
.destructive(Text("危险操作")),
.cancel()
]
)
}
}
}

3. 构建“可复用”的动态 ActionSheet

这是本视频的进阶重点。在复杂的 App 中,你可能在多个地方需要不同的 ActionSheet。视频介绍了一种通过 Enum(枚举) 配合函数来动态生成内容的方法。

通过这种方式,你只需要一个 .actionSheet 修饰符,就可以根据不同的触发源显示完全不同的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
enum ActionSheetOptions {
case isMyPost
case isOtherPost
}

@State var actionSheetOption: ActionSheetOptions = .isOtherPost

// 核心函数:根据选项返回不同的 ActionSheet
func getActionSheet() -> ActionSheet {
let shareButton: ActionSheet.Button = .default(Text("分享"))
let reportButton: ActionSheet.Button = .destructive(Text("举报"))
let deleteButton: ActionSheet.Button = .destructive(Text("删除"))
let cancelButton: ActionSheet.Button = .cancel()

switch actionSheetOption {
case .isMyPost:
return ActionSheet(
title: Text("管理我的帖子"),
buttons: [shareButton, deleteButton, cancelButton]
)
case .isOtherPost:
return ActionSheet(
title: Text("操作选项"),
buttons: [shareButton, reportButton, cancelButton]
)
}
}

4. ActionSheet 的按钮类型

SwiftUI 为 ActionSheet 提供了三种预设样式的按钮:

  • .default():标准样式的按钮,通常用于常规操作。
  • .destructive():文本颜色通常为红色,暗示该操作是不可逆的(如删除、重置)。
  • .cancel():取消按钮,通常会自动放在列表的最底部。如果用户点击 ActionSheet 之外的区域,也会默认触发取消动作。

5. 开发建议与避坑指南

  • 不要堆叠过多选项:虽然 ActionSheet 支持多个按钮,但如果超过 5-6 个,用户体验会迅速下降。此时应考虑使用全屏模态(Sheet)或专门的选择页面。
  • 语义化代码:像视频中演示的那样,将 Button 提取为变量(如 let shareButton),会让 switch 逻辑看起来非常清爽,易于维护。
  • 关于 API 更新:虽然本课讲解的是 ActionSheet,但在实际开发 2026 年的项目时,你会发现 Xcode 可能会提示它已废弃。新版本的替代方案是 .confirmationDialog(),其逻辑几乎完全相同,但支持更多的自定义样式。

ContextMenu

ContextMenu 是 iOS 中非常高效的交互方式,当用户长按某个元素时,会弹出一系列相关的操作选项,同时背景会产生自然的模糊效果。

1. ContextMenu 的核心定义与交互逻辑

ContextMenu 本质上是一个“隐藏的快捷菜单”。它不会占用 UI 空间,只有在用户通过长按(Long Press)\或在 macOS 上**右击**时才会激活。它的设计初衷是为用户提供与当前选中内容高度相关的二次操作(Secondary Actions)。

2. 基础实现语法

ContextMenu 的使用非常简单,只需要在任何 View 上添加 .contextMenu 修饰符,并在闭包中定义菜单项。菜单项通常由 Button 组成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
struct ContextMenuBootcamp: View {
@State var backgroundColor: Color = Color.blue

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Image(systemName: "house.fill")
.font(.title)
Text("Swiftful Thinking")
.font(.headline)
Text("长按此卡片查看菜单")
.font(.subheadline)
}
.padding()
.background(backgroundColor.cornerRadius(20))
// 添加上下文菜单
.contextMenu {
Button(action: {
backgroundColor = .red
}) {
Text("设置为红色")
}

Button(action: {
backgroundColor = .green
}) {
Text("设置为绿色")
}

Button(action: {
backgroundColor = .yellow
}) {
Text("设置为黄色")
}
}
}
}

3. 优化菜单项:添加图标 (Label)

为了让菜单看起来更专业,建议使用 Label 或在 Button 中结合 Image(系统图标)来展示。这样用户可以更直观地识别操作意图。

1
2
3
4
5
6
7
8
9
10
11
12
13
.contextMenu {
Button(action: {
// 执行逻辑
}) {
Label("分享内容", systemImage: "shareplay")
}

Button(action: {
// 执行逻辑
}) {
Label("举报该帖子", systemImage: "flag.fill")
}
}

4. 系统自动生成的视觉反馈

视频中强调了 ContextMenu 的几个自带特性,你不需要额外写代码即可获得:

  • 自动缩放:长按时,目标视图会轻微放大并浮起。
  • 背景模糊:除了目标视图外,屏幕其余部分会自动应用高斯模糊。
  • 触感反馈:系统会自动触发触感引擎(Haptics),给用户一个物理反馈。

文本框

1. TextField 的核心定义

TextField 是一个让用户输入单行可编辑文本的控件。它通过双向数据绑定(Two-way Binding)与一个字符串变量连接:当用户输入时,变量会自动更新;当变量改变时,文本框显示的内容也会随之改变。

2. 基础用法与双向绑定

要使用 TextField,你需要:

  1. 一个 @State 字符串变量来存储输入的内容。
  2. 一个占位符(Placeholder),在文本框为空时显示。
1
2
3
4
5
6
7
8
9
10
11
12
struct TextFieldBootcamp: View {
// 1. 创建状态变量
@State var textFieldText: String = ""

var body: some View {
// 2. 使用 $ 符号进行双向绑定
TextField("请在此输入内容...", text: $textFieldText)
.padding()
.background(Color.gray.opacity(0.3).cornerRadius(10))
.font(.headline)
}
}

3. 常用样式与自定义

虽然系统提供了 .textFieldStyle(RoundedBorderTextFieldStyle()) 等内置样式,但视频推荐通过基础修饰符进行完全自定义,以获得更好的视觉控制:

  • .padding():增加输入文字与边缘的距离。
  • .background():设置背景颜色(通常配合 opacitycornerRadius)。
  • .foregroundColor():改变输入文字的颜色。
  • .font():调整输入文字的大小和粗细。

4. 输入验证与逻辑处理

在实际 App 中,我们通常需要验证用户输入的内容(例如:字符数是否达标)再决定是否允许保存。

  • 重置输入:在保存数据后,将绑定的字符串设为空 "",可以清空文本框。
  • 动态按钮状态:根据输入内容长度,动态改变按钮的颜色或禁用状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 逻辑示例:验证文字是否超过3个字符
func textIsAppropriate() -> Bool {
return textFieldText.count >= 3
}

// 在 View 中应用
Button(action: {
if textIsAppropriate() {
saveText()
}
}) {
Text("保存")
}
.disabled(!textIsAppropriate()) // 如果不达标则禁用按钮
.background(textIsAppropriate() ? Color.blue : Color.gray) // 动态颜色反馈

5. 高级交互参数

除了基础的 text 绑定,TextField 还支持以下回调:

  • onEditingChanged:当用户点击进入或离开文本框时触发(例如:用于显示/隐藏清除按钮)。
  • onCommit:当用户点击键盘上的“换行/确认”键时触发。

文本编辑器

1. TextEditor 的核心定义

TextEditor 是 SwiftUI 中专门用于处理多行、可滚动文本输入的控件。它同样依赖于双向数据绑定,将用户在输入框中填写的长篇内容实时同步到后台变量中。

2. 基础用法与状态绑定

实现 TextEditor 的第一步是创建一个 @State 字符串变量,并将其绑定到控件上。与 TextField 不同的是,TextEditor 没有内置的占位符(Placeholder)参数,这通常需要开发者手动实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct TextEditorBootcamp: View {
@State var textEditorText: String = "这里是初始文字..."
@State var savedText: String = ""

var body: some View {
NavigationView {
VStack {
// 基础 TextEditor 声明
TextEditor(text: $textEditorText)
.frame(height: 250) // 通常需要限制高度,否则会撑满剩余空间

Button(action: {
savedText = textEditorText
}, label: {
Text("保存内容".uppercased())
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(10)
})

Text(savedText)
Spacer()
}
.padding()
.navigationTitle("TextEditor 教学")
}
}
}

3. 自定义外观与背景色处理

TextEditor 的背景处理比 TextField 稍微复杂一些,因为它默认带有系统的白色背景(在浅色模式下)。

  • 修改背景色:在较早版本的 SwiftUI 中,你可能需要修改 UITextView 的外观,或者使用 .colorMultiply。在标准做法中,通常会给它包裹一个容器并设置颜色。
  • 圆角与间距:为了让它看起来更专业,建议添加 .cornerRadius 并配合背景容器。
1
2
3
4
TextEditor(text: $textEditorText)
.frame(height: 250)
.colorMultiply(Color.gray.opacity(0.3)) // 一种快速改变背景色感的方式
.cornerRadius(10)

4. 实战案例:带有保存逻辑的编辑器

视频演示了如何通过一个按钮将 TextEditor 中的内容“保存”到另一个变量中,并在操作完成后清空或更新界面。

1
2
3
4
5
// 保存并清空的逻辑
func saveText() {
savedText = textEditorText
textEditorText = "" // 保存后重置编辑器内容
}

5. 开发建议与对比总结

  • TextField vs TextEditor:如果只需要输入一行文字(如用户名、标题),永远优先选择 TextField,因为它的 API 更简洁且自带占位符功能。
  • 占位符方案:如果你需要给 TextEditor 添加占位符,可以在其下方放置一个 Text 视图,并通过判断输入内容是否为空(text.isEmpty)来控制该 Text 的显示与隐藏。
  • 行间距:利用 .lineSpacing() 修饰符可以改善长文本的可读性。

切换开关

1. Toggle 的核心定义与逻辑

Toggle 的本质是控制一个 Boolean(布尔值)。它通过双向绑定(Binding)@State 变量连接。当你点击开关时,该变量会在 truefalse 之间自动切换,从而触发界面的重新渲染。

2. 基础用法与代码示例

要创建一个 Toggle,你需要提供一个绑定的布尔变量以及一个标签(Label)。标签通常用于描述该开关的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct ToggleBootcamp: View {
// 1. 定义控制状态的变量
@State var toggleIsOn: Bool = false

var body: some View {
VStack {
// 2. 绑定变量并在界面上显示
HStack {
Text("状态:")
Text(toggleIsOn ? "在线" : "离线")
}
.font(.title)

Toggle(
isOn: $toggleIsOn,
label: {
Text("更改状态")
}
)
.padding()
}
}
}

3. 自定义开关颜色 (.toggleStyle)

默认情况下,开关开启时显示为绿色。你可以通过 .toggleStyle 修饰符结合 SwitchToggleStyle 来修改其开启状态的色调(Tint)。

1
2
3
4
Toggle("切换颜色", isOn: $toggleIsOn)
// 修改开关开启时的填充颜色
.toggleStyle(SwitchToggleStyle(tint: Color.purple))
.padding()

4. 样式调整与布局建议

  • 自动扩展宽度:默认情况下,Toggle 会占据父容器的所有剩余宽度,将标签推向左侧,开关推向右侧。如果你希望它不那么宽,可以给它添加 .padding() 或限制其 .frame(width:)
  • 自定义 Label:虽然可以直接传字符串,但通过 label: { ... } 闭包,你可以放入更复杂的视图,比如带图标的文字(Label)。

Picker(选择器)

1. Picker 的核心定义与数据绑定

Picker 的本质是将一个变量(Selection)与一组可选项(Options)进行关联。它采用了双向绑定机制:当用户在界面上选择了某一项,绑定的变量会同步更新;反之,如果你在代码中修改了变量,Picker 的选中状态也会随之跳转。

2. 基础用法与代码示例

创建一个 Picker 需要三个要素:一个用于显示的 Label(部分样式下会隐藏)、一个绑定的 selection 变量,以及一系列子视图(通常是 Text)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct PickerBootcamp: View {
@State var selection: String = "1"

var body: some View {
VStack {
HStack {
Text("当前选中:")
Text(selection)
}

// selection 必须是绑定类型 ($)
Picker(
selection: $selection,
label: Text("选择器名称"),
content: {
Text("1").tag("1")
Text("2").tag("2")
Text("3").tag("3")
}
)
}
}
}

3. 核心知识点:Tag(标签)的重要性

视频中特别强调了 .tag() 的作用。Picker 的核心匹配逻辑全靠 Tag。

  • 当你点击 Picker 中的某个选项时,SwiftUI 会查看该选项的 tag 值。
  • 如果这个 tag 的值与你绑定的 selection 变量类型和数值完全匹配,这个选项才会被视为“选中”。
  • 避坑指南:如果你的 selectionInt 类型,但 tag 给了 String,那么无论你怎么点,选择器都不会有任何反应。

4. 多样的 PickerStyle(样式解析)

改变 Picker 的外观不需要重写代码,只需要切换 .pickerStyle 修饰符。视频展示了以下几种主流样式:

  • .wheel (滚轮样式):最经典的 iOS 样式,适合在有限空间内展示较多选项(如闹钟时间设置)。
  • .segmented (分段样式):将选项横向排列成一排按钮,适合选项较少(通常 2-5 个)且需要频繁切换的场景。
  • .menu (菜单样式):点击后弹出一个下拉菜单,是现代 iOS App 中最常用的紧凑型样式。
  • .inline (内联样式):在 List 中直接展开所有选项,通常用于表单配置。

5. 结合 ForEach 实现动态选择

在处理大量数据或动态列表时,手动写 Text().tag() 显然不现实。视频演示了如何利用 ForEach 循环生成选项。

1
2
3
4
5
6
7
8
9
@State var selectedFlavor: String = "巧克力"
let flavors = ["巧克力", "香草", "草莓", "抹茶"]

Picker("选择口味", selection: $selectedFlavor) {
ForEach(flavors, id: \.self) { flavor in
Text(flavor).tag(flavor)
}
}
.pickerStyle(.segmented)

6. 开发建议与实战总结

  • 样式决定交互:如果选项超过 5 个,尽量避免使用 .segmented,否则文字会挤在一起或者被截断。
  • 语义化选择:在表单(Form)中,Picker 会自动根据平台习惯进行适配(比如在 iOS 上点击后跳转到新页面选择),这能极大降低开发复杂度。
  • 一致性:确保 tag 的数据类型与 @State 变量严格一致。这是新手最常卡住的地方,如果 Picker 点了没反应,回头检查一下类型匹配。

ColorPicker(颜色选择器)

1. ColorPicker 的核心定义

ColorPicker 是 SwiftUI 提供的一个标准 UI 组件,专门用于颜色选择交互。它不仅提供了一个点击触发的颜色按钮,还自动集成了 iOS 系统级的颜色选择界面(包括网格、光谱、滑块以及取色器功能)。

2. 基础用法与双向绑定

实现 ColorPicker 的核心在于将一个 Color 类型的变量 与控件进行双向绑定。当用户选择新颜色时,绑定的状态变量会自动更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ColorPickerBootcamp: View {
// 1. 定义状态变量,用于存储选中的颜色
@State var backgroundColor: Color = .green

var body: some View {
ZStack {
// 背景随选择的颜色变化
backgroundColor.ignoresSafeArea()

// 2. 实现 ColorPicker
ColorPicker("选择背景颜色", selection: $backgroundColor, supportsOpacity: true)
.padding()
.background(Color.black.opacity(0.8))
.cornerRadius(10)
.foregroundColor(.white)
.font(.headline)
.padding(50)
}
}
}

3. 核心参数解析

在调用 ColorPicker 时,有两个非常实用的配置项:

  • selection:必须绑定一个 Color 类型的状态变量。
  • supportsOpacity:这是一个布尔值。如果设置为 true(默认值),用户可以在选择器中调整颜色的透明度(Alpha 通道);如果设置为 false,则只能选择纯色。

4. 视觉表现与自定义

  • 自适应布局ColorPicker 默认会横向填满容器,将标签文字放在左侧,颜色预览圆点放在右侧。
  • 样式调整:虽然 ColorPicker 的内部样式是固定的,但你可以通过修饰符改变其标签的字体、颜色,或者像示例中那样给整个控件添加背景和圆角,使其更像一个浮动菜单。

5. 实战应用场景

视频中提到,ColorPicker 非常适合以下开发场景:

  • 个性化设置:允许用户自定义 App 的主题色、字体颜色或背景色。
  • 绘图/设计类应用:作为画笔或形状填充颜色的快速入口。
  • 实时预览:结合 SwiftUI 的响应式特性,用户在调节颜色时,界面可以实时产生视觉反馈。

6. 开发建议

  • 空间优化:如果你觉得默认的标签太占空间,可以传一个空的 Text("") 或者使用 labelsHidden() 修饰符,只保留右侧的颜色圆点。
  • 数据持久化:在实际 App 中,用户选中的颜色通常需要保存。由于 Color 对象不能直接存入 UserDefaults,你可能需要将其转换为十六进制字符串或 RGB 组件进行存储。

DatePicker(日期选择器)

1. DatePicker 的核心定义与数据绑定

DatePicker 的作用是让用户选择一个绝对的日期或时间。它通过双向绑定与一个 Date 类型的变量连接。每当用户在选择器上进行操作,绑定的变量就会实时更新。

2. 基础用法与代码示例

创建一个基础的选择器只需要一个标签字符串和一个绑定的日期变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct DatePickerBootcamp: View {
// 1. 初始化日期变量(默认为当前时间)
@State var selectedDate: Date = Date()

var body: some View {
VStack {
Text("选中的日期是:")
// 使用特定的格式化显示日期
Text(selectedDate.formatted(date: .abbreviated, time: .shortened))
.font(.headline)

// 2. 基础选择器
DatePicker("选择日期", selection: $selectedDate)
.padding()
}
}
}

3. 自定义显示组件 (displayedComponents)

默认情况下,DatePicker 会同时显示日期和时间。你可以通过 displayedComponents 参数来限制用户只能选择其中之一:

  • .date:只显示年、月、日。
  • .hourAndMinute:只显示小时和分钟。
1
DatePicker("仅选择日期", selection: $selectedDate, displayedComponents: [.date])

4. 设置日期范围 (Date Range)

你可以限制用户选择的日期范围。例如,只允许选择今天及以后的日期,或者只允许选择过去的日期。这通过 in 参数实现,支持 Swift 的范围语法:

  • Date()...:从现在开始到未来。
  • ...Date():从过去到此刻。
  • startDate...endDate:特定的时间段。
1
2
// 限制只能选择未来的日期
DatePicker("选择预约时间", selection: $selectedDate, in: Date()..., displayedComponents: [.date])

5. 不同的展示样式 (DatePickerStyle)

改变 DatePicker 的外观非常简单,只需要添加 .datePickerStyle 修饰符:

  • .compact (紧凑样式):默认样式。点击后会弹出一个小的日历或时间选择框,节省空间。
  • .graphical (图形样式):直接在页面上展开一个完整的日历视图。
  • .wheel (滚轮样式):经典的 iOS 滚轮效果,适合放在底部弹出层中。

Stepper(步进器)

1. Stepper 的核心定义

Stepper 的作用是让用户通过离散的步长(Step)来增加或减少绑定的数值。它非常适合处理那些需要精确数字的交互场景,比如购物车里的商品数量、选定的年份、或者字号大小调节。

2. 基础用法与数值绑定

实现 Stepper 的基础方式是将其绑定到一个数值变量(通常是 IntDouble)。

1
2
3
4
5
6
7
8
9
10
11
struct StepperBootcamp: View {
@State var stepperValue: Int = 10

var body: some View {
VStack {
// 基础用法:显示标签并绑定变量
Stepper("当前数值: \(stepperValue)", value: $stepperValue)
.padding(50)
}
}
}

3. 设置步长与范围 (Step & Range)

在实际开发中,我们通常需要限制数值的波动区间,并设定每次点击变化的幅度。

  • in:使用 Swift 的范围语法(如 0...100)限制数值的边界。
  • step:设定每次点击增减的确切数值。
1
2
// 数值限制在 0 到 50 之间,每次点击增减 5
Stepper("步长调节 (5): \(stepperValue)", value: $stepperValue, in: 0...50, step: 5)

4. 高级自定义逻辑 (onIncrement & onDecrement)

如果你不希望直接绑定变量,而是想在用户点击加/减按钮时执行特定的函数(例如触发震动反馈或计算其他逻辑),可以使用闭包方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@State var widthIncrement: CGFloat = 0

Stepper(label: {
Text("手动控制宽度: \(Int(widthIncrement))")
}, onIncrement: {
// 每次点击增加矩形宽度
withAnimation(.easeInOut) {
widthIncrement += 20
}
}, onDecrement: {
// 每次点击减少矩形宽度
withAnimation(.easeInOut) {
widthIncrement -= 20
}
})

RoundedRectangle(cornerRadius: 10)
.frame(width: 100 + widthIncrement, height: 100)

5. 开发建议与交互细节

  • 边界反馈:当数值达到 in 参数设定的最大或最小值时,对应的按钮会自动变为灰色且不可点击,这种视觉反馈是系统自带的。
  • 隐藏标签:如果你只需要显示加减按钮而不需要左侧的文字描述,可以使用 .labelsHidden() 修饰符,或者将 label 设置为空文本。
  • 对比 Slider:视频中提到,如果数值范围很大(比如 0 到 1000)且不需要极端精确,使用 Slider 体验更好;但如果是需要逐个增减的小数值,Stepper 是唯一选择。

Slider(滑块)

1. Slider 的核心定义与基本绑定

Slider 的作用是调节数值(通常是 DoubleFloat 类型)。它通过双向绑定与状态变量连接:当用户左右拖动滑块时,绑定的变量值会实时更新,从而触发界面的响应式变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct SliderBootcamp: View {
// 1. 定义状态变量,通常是 Double
@State var sliderValue: Double = 3.0

var body: some View {
VStack {
Text("当前数值:")
// 使用 specifier 限制显示的小数位数
Text(String(format: "%.1f", sliderValue))

// 2. 基础滑块:绑定变量,设定范围
Slider(value: $sliderValue, in: 1...5)
.padding()
}
}
}

2. 设定步长与精确控制 (Step)

默认情况下,滑块是连续变化的。但有时我们需要用户按固定的增量(例如 0.5 或 1.0)进行选择,这时就可以使用 step 参数。

  • 连续变化:滑动极其平滑,数值可以是任意小数。
  • 步进变化:滑块会“跳动”到最近的步长点上,实现精确控制。
1
2
// 限制范围为 1 到 10,每次滑动的最小单位是 0.5
Slider(value: $sliderValue, in: 1...10, step: 0.5)

3. 添加自定义标签 (Labels)

为了提升用户体验,Slider 支持在轨道的前后添加标签,甚至可以为滑块整体添加一个描述性标签。

  • Minimum Value Label:显示在滑块最左侧的内容。
  • Maximum Value Label:显示在滑块最右侧的内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
Slider(
value: $sliderValue,
in: 1...5,
step: 1.0,
onEditingChanged: { (_) in
// 拖动开始或结束时的回调
},
minimumValueLabel: Text("1"),
maximumValueLabel: Text("5"),
label: {
Text("选择评分") // 该标签在某些平台上可能不会直接显示,但对辅助功能很重要
}
)

4. 监听拖动状态 (onEditingChanged)

Slider 提供了一个名为 onEditingChanged 的闭包,它可以让你感知到用户什么时候“开始”拖动,以及什么时候“松开”手。

  • 这在需要通过颜色变化来提示用户正在操作,或者在拖动结束时才执行重型计算(如 API 请求)时非常有用。
1
2
3
4
5
6
7
8
9
10
11
@State var color: Color = .red

Slider(
value: $sliderValue,
in: 1...10,
onEditingChanged: { editing in
// 如果正在拖动,改变颜色
color = editing ? .green : .red
}
)
.accentColor(color) // 动态改变滑块轨道的颜色

TabView

1. TabView 的核心定义与基础导航

TabView 是 SwiftUI 中实现多页面切换的容器。在默认状态下,它会在屏幕底部创建一个标准的标签栏(Tab Bar)。每一个放在 TabView 内部的视图都会成为一个独立的标签页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct TabViewBootcamp: View {
var body: some View {
// 创建一个基础的选项卡视图
TabView {
// 第一个页面
RoundedRectangle(cornerRadius: 25)
.fill(Color.red)
.tabItem {
Image(systemName: "house.fill")
Text("首页")
}

// 第二个页面
Text("搜索页面")
.tabItem {
Label("搜索", systemImage: "magnifyingglass")
}
}
}
}

2. 程序化控制跳转与 Tag 的配合

为了在代码中控制当前显示哪个 Tab(比如点击首页的一个按钮后跳转到设置页),我们需要给 TabView 绑定一个状态变量,并给每个子视图添加 .tag() 标识。

  • Selection 绑定:将一个 @State 变量绑定到 TabView
  • Tag 标识tag 的值必须与 selection 变量的类型和数值严格匹配。
1
2
3
4
5
6
7
8
9
@State var selectedTab: Int = 0

TabView(selection: $selectedTab) {
HomeView().tag(0)
.tabItem { Image(systemName: "house") }

SettingsView().tag(1)
.tabItem { Image(systemName: "gear") }
}

3. 实现分页滚动效果 (PageTabViewStyle)

这是本集视频的亮点。通过修改 .tabViewStyle,你可以瞬间将底部的导航栏隐藏,并将整个 TabView 变成一个支持左右轻扫切换的分页视图

  • 场景应用:非常适合用于 App 首次启动时的引导页(Onboarding)或详情页的图片轮播。
1
2
3
4
5
6
7
8
TabView {
RoundedRectangle(cornerRadius: 25).fill(Color.red)
RoundedRectangle(cornerRadius: 25).fill(Color.blue)
RoundedRectangle(cornerRadius: 25).fill(Color.green)
}
.frame(height: 300)
// 核心代码:切换为分页样式
.tabViewStyle(PageTabViewStyle())

4. 自定义分页指示器 (IndexDisplayMode)

当使用分页样式时,底部默认会显示白色的圆点(Page Indicator)。你可以通过参数控制这些圆点的显示时机:

  • .always:无论几页,始终显示圆点。
  • .never:隐藏圆点。
  • 背景底色:通过 indexDisplayMode 配合背景设置,可以增强圆点的可见度。
1
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))

5. 开发建议与实战技巧

  • 层级结构:通常 TabView 应该作为 App 的根视图或者非常高层的容器,避免在 NavigationView 内部嵌套 TabView(除非有特殊设计需求)。
  • 颜色自定义:使用 .accentColor() 可以改变底部标签栏选中的图标和文字颜色。
  • Page 样式的背景:当使用 PageTabViewStyle 时,如果背景是白色的,白色的圆点指示器会看不见。此时可以给整个 TabView 添加一个非白色的背景色,或者使用特殊的配置来增强对比。
  • 性能TabView 会一次性加载所有标签页的内容。如果你的标签页非常多(比如几十个页面),建议结合 LazyVStack 或其他延迟加载技术来优化性能。

深色模式(Dark Mode)

1. 适配深色模式的核心逻辑

SwiftUI 的深色模式适配基于“自适应”原则。当你使用系统定义的颜色(如 .primary, .secondary)或语义化颜色时,系统会自动根据用户的环境设置(浅色或深色)切换具体的色值。

2. 系统颜色的自动切换

系统颜色不仅仅是基础的颜色,它们是“智能”的。视频推荐优先使用系统提供的语义化颜色,这样可以省去大量的逻辑判断工作。

  • .primary:在浅色模式下是黑色,深色模式下自动变为白色。
  • .secondary:自带透明度的灰色,适合用于副标题。
  • 自适应背景:虽然视频中未深入,但使用系统背景色可以确保视图层级在深色模式下依然清晰。
1
2
3
4
5
6
7
8
9
VStack(spacing: 20) {
Text("主标题文字")
.font(.title)
.foregroundColor(.primary) // 自动适配黑白

Text("副标题文字")
.font(.subheadline)
.foregroundColor(.secondary) // 自动适配深浅灰
}

3. 在资源目录 (Assets) 中定义自定义自适应颜色

当你需要使用品牌特有的颜色时,可以在 Assets.xcassets 中创建 Color Set

  • 操作步骤:新建 Color Set,在右侧的 Appearances 选项中选择 Any, Dark
  • 配置:为 Any Appearance(通常指浅色)设置一个颜色,为 Dark Appearance 设置另一个颜色。
  • 调用:在代码中通过字符串名称引用。
1
2
3
4
// 使用在 Assets 中定义的自适应颜色
RoundedRectangle(cornerRadius: 25)
.fill(Color("CustomBackgroundColor"))
.frame(width: 200, height: 200)

4. 使用 @Environment 监听模式变化

有时仅仅改变颜色是不够的,你可能需要在不同模式下显示不同的图片或执行不同的逻辑。这时可以调用环境变量 @Environment(\.colorScheme)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct DarkModeBootcamp: View {
// 监听当前系统的颜色方案
@Environment(\.colorScheme) var colorScheme

var body: some View {
VStack {
if colorScheme == .dark {
Text("欢迎来到暗黑世界")
.foregroundColor(.yellow)
} else {
Text("阳光明媚的浅色模式")
.foregroundColor(.blue)
}
}
}
}

5. 在预览 (Previews) 中同时测试两种模式

为了提高效率,你不需要反复在模拟器里切换系统设置。通过 Group.preferredColorScheme(),你可以同时在 Canvas 中查看两种效果。

1
2
3
4
5
6
7
8
9
10
11
12
struct DarkModeBootcamp_Previews: PreviewProvider {
static var previews: some View {
Group {
// 浅色预览
DarkModeBootcamp()

// 强制指定深色预览
DarkModeBootcamp()
.preferredColorScheme(.dark)
}
}
}

6. 开发建议与避坑指南

  • 避免硬编码颜色:尽量不要使用类似于 Color(red: 0.1, green: 0.1, blue: 0.1) 这种固定数值,除非你确定该色值在两种背景下都能清晰展示。
  • 阴影处理:在深色模式下,传统的黑色阴影往往不可见。视频建议在深色模式下可以适当调低阴影的透明度,或者直接取消阴影,转而使用边框(Stroke)来区分层级。
  • 图片素材:如果你的图标或 Logo 在深色背景下看不清,记得在 Assets 里也为它们配置 Dark Appearance 版本的图片。

注释

1. 基础注释与文档注释的区别

在 Swift 中,注释分为普通注释和文档注释,它们的用途完全不同:

  • 普通注释 (//):仅供开发者阅读,编译器和 Xcode 会直接忽略。
  • 文档注释 (///):这是“三斜杠”注释。Xcode 会解析这些内容,并将其展示在 Quick Help(按住 Option 键点击变量名)中。这能让你的函数看起来像 Apple 官方 API 一样专业。

2. 结构化文档标注 (Documentation Header)

当你为一个复杂的函数编写文档时,可以使用特定的关键词来结构化信息。Xcode 会自动识别并格式化这些内容:

  • 第一行:简短的摘要(Summary)。
  • 讨论区:在摘要后空一行,编写详细的使用说明。
  • Keywords
    • - Parameters::描述输入参数。
    • - Returns::描述返回值。
    • - Throws::描述可能抛出的错误。
1
2
3
4
5
6
7
8
9
10
11
12
/// 获取用户信息
///
/// 这是一个复杂的函数,它会连接数据库并检索与给定 ID 匹配的用户模型。
/// 确保在调用此函数前已经初始化了数据库连接。
///
/// - Parameters:
/// - id: 用户的唯一识别码 (UUID)
/// - folder: 要搜索的特定目录
/// - Returns: 返回一个包含用户数据的 String
func getUserData(id: String, folder: String) -> String {
return "User: \(id) in \(folder)"
}

3. 使用 MARK 组织代码结构

随着视图文件越来越大,在代码中快速定位非常困难。// MARK: 可以在 Xcode 顶部的 Jump Bar(跳转栏) 中创建索引和视觉分割线。

  • 粗体标题// MARK: PROPERTIES
  • 带分割线的标题// MARK: - FUNCTIONS(多出的横杠会在跳转菜单中生成一条水平分割线)。
1
2
3
4
5
6
7
8
9
10
11
12
// MARK: - PROPERTIES
@State var data: [String] = []

// MARK: - BODY
var body: some View {
Text("Hello")
}

// MARK: - FUNCTIONS
func fetchData() {
// 逻辑代码
}

4. 任务追踪:TODO 与 FIXME

这两个标记是开发者必备的备忘录。它们同样会出现在跳转栏中,并配有特殊的图标,方便你快速找到未完成的工作:

  • // TODO::记录将来需要实现的功能或优化的逻辑。
  • // FIXME::记录已知但暂时未修复的 Bug。
1
2
3
4
5
func calculateTotal() {
// FIXME: 这里的逻辑在高并发下可能会产生误差

// TODO: 增加对多种货币的支持
}

5. 快速生成文档的快捷键

视频中分享了一个极其实用的 Xcode 技巧: 将光标置于函数名或类名上,按下 Command + Option + /,Xcode 会自动根据函数的参数和返回值,为你生成一个完整的文档模板,你只需要填入描述文字即可。

6. 开发建议与实战总结

  • 为未来投资:文档注释不是写给现在的你,而是写给“三个月后忘记这段逻辑的你”。
  • 保持代码整洁:虽然注释很重要,但优先编写“自解释”的代码(变量名清晰)。只有当逻辑变得复杂或需要解释“为什么要这么写”时,才添加文档。
  • 养成 MARK 的习惯:在每个文件的开头,使用 MARK:Properties, Body, Functions 区分开。当你习惯通过跳转栏(Jump Bar)定位代码时,开发效率会显著提升。

onAppear 与 onDisappear

1. onAppear 与 onDisappear 的核心定义

在 SwiftUI 中,这两个修饰符属于视图的生命周期(Life Cycle)管理工具:

  • onAppear:当视图加载并出现在屏幕上时触发。这通常是初始化数据、启动定时器或触发入场动画的最佳时机。
  • onDisappear:当视图从屏幕上移除或被销毁时触发。通常用于停止后台任务、取消网络请求或保存用户进度。

2. 基础用法示例

这两个修饰符可以附加在任何 View 上,其闭包内的代码会在特定时刻自动运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct LifecycleBootcamp: View {
@State var myText: String = "等待启动..."

var body: some View {
Text(myText)
.onAppear {
// 当文本出现在屏幕时,修改它的内容
myText = "视图已就绪!"
print("onAppear 触发了")
}
.onDisappear {
// 当文本离开屏幕时执行(例如跳转到了新页面)
print("onDisappear 触发了")
}
}
}

3. 核心应用场景

  • 异步数据加载:在用户进入页面时,立即发起 API 请求加载内容。
  • 埋点统计:统计某个页面被用户查看了多少次。
  • 动态动画:在 onAppear 中改变某个 @State 变量的值,配合 .animation 产生自动播放的入场效果。

4. 在 Lazy 容器中的特殊表现

视频中强调了一个进阶细节:当你在 ScrollView 中配合 LazyVStack 使用这两个修饰符时,它们的表现非常智能。

  • 按需触发onAppear 只有在某个 Cell 滚动到可见区域时才会触发,而不是在列表创建时一次性全部触发。
  • 性能优化:这种机制保证了即使有几千个数据项,你的 App 也只会在必要时执行代码。

5. 综合实战案例

下面的代码模拟了一个简单的计数器,每当一个视图单元格“出现”在用户面前,计数就会增加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct OnAppearBootcamp: View {
@State var count: Int = 0

var body: some View {
NavigationView {
ScrollView {
LazyVStack {
ForEach(0..<50) { _ in
RoundedRectangle(cornerRadius: 25)
.frame(height: 200)
.padding()
.onAppear {
// 每当一个新的矩形滚入屏幕,计数加一
count += 1
}
}
}
}
.navigationTitle("滚动计数: \(count)")
}
}
}

可选类型

1. 可选类型 (Optionals) 的核心概念

在 Swift 中,一个变量如果可能没有值,必须声明为可选类型(在类型后加 ?)。你可以把它想象成一个盒子:盒子里面可能装着我们要的礼物(值),也可能空无一物(nil)。

直接使用这个“盒子”会导致编译错误或程序崩溃,因此我们需要“拆开”它。

2. 使用 if-let 进行安全拆包

if let 是最常用的安全拆包方式。它的逻辑是:“如果这个盒子里有东西,就把里面的东西取出来赋值给一个临时常量,并执行大括号里的代码。”

  • 作用域限制:解包后的常量仅在 if 语句的大括号 { } 内有效。
  • 适用场景:当你只需要在某个特定条件下使用这个值时。
1
2
3
4
5
6
7
8
9
10
11
@State var userID: String? = "user_123"

func checkLogin() {
// 尝试拆包
if let safeID = userID {
print("登录成功,用户 ID 是:\(safeID)")
// safeID 仅在此处可用
} else {
print("未找到用户 ID")
}
}

3. 使用 guard-let 进行早期退出 (Early Exit)

guard 语句是 Swift 风格的精髓。它的逻辑是:“先检查盒子里是否有值,如果没有,立即滚出这个函数。”

  • 作用域优势:一旦通过 guard 检查,解包后的常量在整个函数剩余的部分都是有效的。
  • 适用场景:作为函数的“门禁”,防止逻辑在缺少必要数据的情况下继续执行。
1
2
3
4
5
6
7
8
9
10
func processData() {
// 门禁:如果没有值,直接 return
guard let safeID = userID else {
print("错误:缺少用户 ID,终止操作")
return
}

// 核心逻辑:这里可以直接使用 safeID,不需要再嵌套大括号
print("正在为用户 \(safeID) 处理大数据...")
}

4. if-let 与 guard 的实战对比

视频中强调了两者在代码可读性上的差异:

  • if-let:容易导致“末日金字塔”(Nested Closures),如果需要解包五个变量,你的代码会向右缩进得非常厉害。
  • guard:保持代码“扁平化”。它把错误处理放在函数开头,让核心逻辑始终保持在最左侧。
1
2
3
4
5
// 多重解包的 guard 写法(更优雅)
guard let id = userID, let name = userName, let age = userAge else {
return
}
// 此处可以直接使用 id, name, age

5. 零迷路建议:何时该用哪一个?

  1. 首选 guard:如果你正在写一个函数,且接下来的逻辑全部依赖于某个可选值,请务必使用 guard。它能显著减少嵌套,让代码更易读。
  2. 次选 if-let:如果你只是偶尔在视图的某个角落需要显示一个值,或者逻辑并不需要因为缺值而中断,if-let 是更简单的选择。
  3. 永远不要 Force Unwrap:除非你 100% 确定变量有值,否则不要使用 !(强行拆包)。这在 Swift 中被视为“自杀行为”,是导致 App 崩溃的头号元凶。

6. 开发中的“Nil 合并”小技巧

虽然视频主讲 if-letguard,但也提到了 ??(空合并运算符)。当你需要一个默认值时,它是最快的选择。

1
2
// 如果 userID 为 nil,则显示 "Guest"
let displayName = userID ?? "访客用户"

Tap Gesture(点击手势)

1. Tap Gesture 的核心定义

onTapGesture 是一个查看视图层级的修饰符。它的作用是监听用户的点击动作并触发相应的闭包代码。与 Button 不同,它不带有任何默认的系统样式(如点击时的半透明闪烁效果),这使得它在构建自定义 UI 时非常纯净。

2. 基础用法与代码示例

你可以将 .onTapGesture 附加在几乎任何组件上。下面的代码演示了如何点击一个圆角矩形来改变它的背景颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct TapGestureBootcamp: View {
@State var isSelected: Bool = false

var body: some View {
VStack(spacing: 40) {
RoundedRectangle(cornerRadius: 25)
.fill(isSelected ? Color.green : Color.red)
.frame(height: 200)

// 为文本添加点击手势
Text("点击此处切换颜色")
.font(.headline)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
.onTapGesture {
isSelected.toggle()
}
}
.padding()
}
}

3. 进阶用法:多击检测 (count)

onTapGesture 有一个非常实用的参数 count。通过设定这个参数,你可以轻松实现“双击点赞”或“三击触发特殊彩蛋”的功能。

  • 默认值:1(单击)。
  • 双击:设置为 2。
1
2
3
4
5
6
7
Image(systemName: "heart.fill")
.font(.largeTitle)
.foregroundColor(isSelected ? .red : .gray)
.onTapGesture(count: 2) {
// 只有双击时才会触发
isSelected.toggle()
}

4. Tap Gesture 与 Button 的深度对比

视频中重点对比了这两者的应用场景,这是面试中经常被问到的知识点:

  • Button
    • 优点:自带点击视觉反馈(High-light)、支持辅助功能(Accessibility)、更符合 iOS 系统规范。
    • 适用:表单提交、核心功能操作。
  • Tap Gesture
    • 优点:完全自定义、没有视觉干扰。
    • 适用:非传统交互(如在图片上双击)、全屏背景点击关闭、或者当你不需要按钮那种“闪烁”反馈时。

自定义模型

1. 为什么需要自定义模型?

在之前的课程中,我们可能只是用一个 [String] 数组来显示列表。但现实中的数据往往很复杂,比如一个“用户”不仅有名字,还有粉丝数、是否认证、头像等信息。

模型(Model) 的本质就是创建一个自定义的 struct,将这些相关的属性打包在一起。这样你的代码会变得极其整洁,逻辑也更加清晰。

2. 创建一个基础 Model (Struct)

在 Swift 中,模型通常使用 struct 来构建。

1
2
3
4
5
6
7
struct UserModel: Identifiable {
let id: String = UUID().uuidString // 唯一标识符
let displayName: String
let userName: String
let followerCount: Int
let isVerified: Bool
}

3. 核心知识点:Identifiable 协议

这是视频中强调的重点。为了让 ListForEach 能够高效地渲染和追踪数据的变化,你的模型必须遵循 Identifiable 协议。

  • 要求:模型内部必须有一个名为 id 的属性。
  • 优势:在使用 ForEach 时,你不再需要写 id: \.self,代码更简洁,性能也更好。

4. 在视图中集成模型数据

视频演示了如何创建一个包含多个模型实例的数组,并将其展示在列表里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
struct CustomModelBootcamp: View {

// 创建一个包含模型数据的数组
@State var users: [UserModel] = [
UserModel(displayName: "Nick", userName: "nick123", followerCount: 100, isVerified: true),
UserModel(displayName: "Emily", userName: "itsemily", followerCount: 55, isVerified: false),
UserModel(displayName: "Samantha", userName: "sam22", followerCount: 350, isVerified: true)
]

var body: some View {
NavigationView {
List {
ForEach(users) { user in
HStack(spacing: 15.0) {
Circle()
.frame(width: 35, height: 35)

VStack(alignment: .leading) {
Text(user.displayName)
.font(.headline)
Text("@\(user.userName)")
.foregroundColor(.gray)
.font(.caption)
}

Spacer()

if user.isVerified {
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.blue)
}

VStack {
Text("\(user.followerCount)")
.font(.headline)
Text("Followers")
.font(.caption)
.foregroundColor(.gray)
}
}
.padding(.vertical, 10)
}
}
.navigationTitle("用户列表")
}
}
}

5. 数据的传递与子视图解耦

视频还提到了一个高级技巧:将每个列表项提取成一个独立的子视图。你只需要将整个 user 模型传递给子视图,而不是一个一个传递属性。

1
2
3
4
5
6
7
8
// 子视图只需要接收一个 UserModel 对象
struct UserRowView: View {
let user: UserModel

var body: some View {
// 使用 user.displayName 等属性构建 UI
}
}

6. 开发建议与实战总结

  • 唯一性:始终使用 UUID().uuidString 或者是数据库自带的唯一 ID 作为 id 属性,避免列表渲染出错。
  • 命名规范:模型的命名通常以 Model 结尾(如 ProductModel, NoteModel),这能让你在复杂的项目中一眼分辨出哪些是数据结构。

@ObservableObject 和 @StateObject

1. 核心概念的定义

在 SwiftUI 中,当数据不再只是简单的字符串或整数,而是一个包含逻辑的“业务模型”时,我们需要使用类(Class)来管理。

  • ObservableObject (协议):让一个类具备“可观察”的能力。
  • @Published (属性包装器):在类内部使用。每当标记为 @Published 的变量发生变化时,它会通知所有观察该对象的视图进行刷新。
  • @StateObject (属性包装器):用于创建(实例化)对象。它保证即使视图重新渲染,该对象也不会被销毁或重置。
  • @ObservedObject (属性包装器):用于传递对象。它不拥有数据,只是观察传入的对象。

2. 实现数据模型层 (ViewModel)

我们将数据逻辑从 View 中提取出来,放入一个遵循 ObservableObject 协议的类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class FruitViewModel: ObservableObject {
// 使用 @Published 确保变量改变时触发 UI 刷新
@Published var fruitArray: [String] = []
@Published var isLoading: Bool = false

init() {
getFruits()
}

func getFruits() {
isLoading = true
// 模拟网络请求加载数据
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.fruitArray.append(contentsOf: ["苹果", "香蕉", "橘子"])
self.isLoading = false
}
}

func deleteFruit(index: IndexSet) {
fruitArray.remove(atOffsets: index)
}
}

3. 在视图中使用 @StateObject 与 @ObservedObject

视频强调了两者最关键的区别:谁拥有数据源

  • 父视图(创建者):使用 @StateObject
  • 子视图(使用者):使用 @ObservedObject
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 父视图:负责创建数据模型
struct ObservedObjectBootcamp: some View {
// 关键点:首次创建对象务必使用 @StateObject
@StateObject var viewModel: FruitViewModel = FruitViewModel()

var body: some View {
NavigationView {
List {
if viewModel.isLoading {
ProgressView()
} else {
ForEach(viewModel.fruitArray, id: \.self) { fruit in
NavigationLink(
destination: SecondScreen(vm: viewModel), // 传递给子视图
label: { Text(fruit) })
}
.onDelete(perform: viewModel.deleteFruit)
}
}
.navigationTitle("水果列表")
}
}
}

// 子视图:负责使用传入的数据模型
struct SecondScreen: View {
// 关键点:接收传递来的对象,使用 @ObservedObject
@ObservedObject var vm: FruitViewModel

var body: some View {
VStack {
ForEach(vm.fruitArray, id: \.self) { fruit in
Text(fruit)
}
}
}
}

4. @StateObject 与 @ObservedObject 的致命区别

这是视频中最重要的避坑指南:

  • 如果父视图刷新:使用 @ObservedObject 创建的对象会随之重新初始化,导致数据丢失或重置。
  • 如果使用 @StateObject:对象会与视图的生命周期绑定,即使视图重新渲染,对象的状态也会保持不变。
  • 结论:在视图的顶层实例化类对象时,永远使用 @StateObject。只有在将对象传递到子视图时,子视图才使用 @ObservedObject

@EnvironmentObject

1. @EnvironmentObject 的核心定义

@EnvironmentObject 允许你在整个视图层级中共享数据,而无需手动通过每一个子视图的初始化构造器进行传递。你可以把它想象成一个“视图层级内的全局变量”:你在父视图中“注入”一个对象,随后该父视图下的任何子视图(无论嵌套多深)都可以直接“读取”并观察它。

2. 实现步骤与基本语法

要成功使用 @EnvironmentObject,必须遵循以下三个固定步骤:

  1. 准备数据模型:创建一个遵循 ObservableObject 协议的类,并使用 @Published 标记需要观察的属性。
  2. 注入对象:在视图层级的根部(父视图),使用 .environmentObject() 修饰符将其实例化并注入。
  3. 提取对象:在任何子视图中,使用 @EnvironmentObject 属性包装器来声明该变量。

3. 综合代码示例

下面的示例展示了如何将一个“用户设置”对象直接从第一层传递到第三层,而跳过中间的第二层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 1. 数据模型
class EnvironmentViewModel: ObservableObject {
@Published var dataArray: [String] = ["iPhone", "iPad", "Macbook", "Apple Watch"]
}

struct EnvironmentObjectBootcamp: View {
// 创建初始对象
@StateObject var viewModel: EnvironmentViewModel = EnvironmentViewModel()

var body: some View {
NavigationView {
List {
ForEach(viewModel.dataArray, id: \.self) { item in
NavigationLink(
destination: DetailView(selectedItem: item),
label: { Text(item) })
}
}
.navigationTitle("产品列表")
}
// 2. 将对象注入到整个导航栈的环境中
.environmentObject(viewModel)
}
}

struct DetailView: View {
let selectedItem: String

var body: some View {
NavigationLink(
destination: FinalView(),
label: {
Text("查看详情: \(selectedItem)")
})
}
}

struct FinalView: View {
// 3. 直接从环境中获取对象,无需从 DetailView 传递过来
@EnvironmentObject var vm: EnvironmentViewModel

var body: some View {
VStack {
Text("最终页面读取到的数据:")
ForEach(vm.dataArray, id: \.self) { item in
Text(item)
}
}
}
}

4. @EnvironmentObject vs @ObservedObject

视频中深入对比了这两种传递方式的使用场景:

  • @ObservedObject:适合相邻层级之间的简单传递。如果数据只传给直接子视图,它是首选。
  • @EnvironmentObject:适合跨层级(超过 2 层)的数据共享。它让中间层的代码更干净,因为中间层不需要声明任何它自己用不到的变量。

5. 开发建议与致命错误预防

  1. 防止应用崩溃(致命伤):如果你在一个视图中声明了 @EnvironmentObject var vm: MyClass,但在其上级的任何父视图中都忘记注入 .environmentObject(vm),你的 App 会在打开该视图时直接崩溃。这是新手最常遇到的问题。
  2. 预览测试(Previews):在进行 Xcode 预览时,如果你的预览视图依赖环境对象,你也必须在预览代码中手动注入该对象,否则预览器会报错。
  3. 全局状态首选:对于像“用户登录状态”、“App 主题颜色”、“全局设置”这类几乎每个页面都可能用到的数据,@EnvironmentObject 是最佳实践。
  4. 性能考量:虽然方便,但不要把所有变量都塞进一个巨大的环境对象中。一旦环境对象中的 @Published 属性改变,所有观察它的子视图都会重新渲染。建议按功能模块拆分不同的环境对象。

@AppStorage

1. @AppStorage 的核心定义

@AppStorage 是一个属性包装器(Property Wrapper),它的底层实际上是 UserDefaults。 它的神奇之处在于:它不仅能把数据持久化存储到手机磁盘上,还能像 @State 一样,在数据发生变化时自动触发 UI 刷新

2. 基础用法与代码示例

在没有 @AppStorage 之前,我们需要手动从 UserDefaults 读取值,并在视图启动时进行初始化。现在,只需要一行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct AppStorageBootcamp: View {

// "user_name" 是存储在磁盘上的键(Key)
// 如果磁盘上没有这个值,则默认使用 "匿名用户"
@AppStorage("user_name") var currentUserName: String?

var body: some View {
VStack(spacing: 20) {
// 界面会根据 currentUserName 的变化实时刷新
Text(currentUserName ?? "请输入用户名")

Button("保存新名称") {
let name: String = "Nick"
currentUserName = name
// 这一行代码不仅更新了内存中的变量,还直接把 "Nick" 存进了磁盘
}
}
}
}

3. 为什么选择 @AppStorage?

  • 持久化:当你退出 App 甚至重启手机后,数据依然存在。
  • 响应式:它完美融入 SwiftUI 的状态管理。如果你在 App 的 A 页面修改了 @AppStorage 的值,B 页面如果也引用了同一个 Key,它会自动同步更新。
  • 简洁性:消除了手动调用 UserDefaults.standard.set()object(forKey:) 的繁琐步骤。

4. 支持的数据类型

@AppStorage 并不是万能的,它主要用于存储轻量级数据。支持的类型包括:StringIntBoolDoubleURLData

注意:如果你想存储复杂的对象(如自定义的 struct),通常需要先将其编码为 Data(JSON 格式),然后再存入 @AppStorage

5. 开发建议与实战总结

  • 不要存储敏感数据UserDefaults 的数据是未加密的。永远不要在里面存储密码、Token 或个人隐私信息。
  • 不要存储大数据:它适合存“是否开启深色模式”、“用户评分”或“最后阅读位置”等小信息。如果是大量的图片或数据库条目,应该使用 CoreData 或 SwiftData(iOS 17+)。
  • 统一 Key 管理:在大型项目中,建议把所有的 Key 字符串定义成常量或枚举,防止因为手抖打错一个字母导致数据读取失败。

6. 进阶:如何处理复杂的 Struct?

虽然原生不支持直接存 struct,但你可以让你的模型遵循 RawRepresentable 协议,或者手动进行 JSON 转换:

1
2
// 伪代码示例:将结构体转为 Data 存入
@AppStorage("settings") var settingsData: Data = Data()

用户引导流程

这一集视频是 Bootcamp 系列的一个小高潮,它是一次综合性实战。Nick 展示了如何利用之前学过的所有零散知识点(@AppStorageTransitionAnimationState 等),构建一个专业且流畅的 用户引导流程(Onboarding Flow)

其核心逻辑是:利用 @AppStorage 记住用户的登录状态,并配合 Transition 在不同步骤间实现丝滑切换。

核心逻辑架构:App 的“分流器”

在处理用户首次登录或引导时,最优雅的做法是在根视图中根据持久化状态进行条件渲染。

  • @AppStorage 哨兵:使用 @AppStorage 存储一个布尔值(如 signedIn),它直接决定了 App 启动时展现哪个界面。
  • 条件转场:利用 if-else 配合 withAnimation,当用户点击“完成”时,引导页消失,主界面以预设的转场方式滑入。

引导页状态机:多步交互设计

引导页内部不使用复杂的导航,而是通过一个整数状态变量来驱动内容的切换。

  • 步骤追踪:通过 @State var onboardingState: Int 记录当前处于哪一页(0: 欢迎, 1: 姓名, 2: 年龄等)。
  • 组件化 UI:将每一页的内容提取为独立的 private var 函数(如 addNameSection),确保 body 代码块清晰易读。

核心代码实现

以下代码展示了如何利用 非对称转场(Asymmetric Transitions) 实现专业的“右进左出”翻页效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import SwiftUI

struct OnboardingView: View {
// MARK: - 状态管理
@State var onboardingState: Int = 0
@State var name: String = ""
@State var age: Double = 25

// MARK: - 持久化存储
@AppStorage("name") var currentUserName: String?
@AppStorage("signed_in") var currentUserSignedIn: Bool = false

// 定义转场动画:从右侧进入,向左侧离开
let transition: AnyTransition = .asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading))

var body: some View {
ZStack {
// 背景层
Color.purple.ignoresSafeArea()

// 内容层:根据状态切换视图
ZStack {
switch onboardingState {
case 0: welcomeSection.transition(transition)
case 1: addNameSection.transition(transition)
case 2: addAgeSection.transition(transition)
default: Text("完成").transition(transition)
}
}

// 底部固定按钮
VStack {
Spacer()
bottomButton
}
.padding(30)
}
}
}

// MARK: - 逻辑与视图扩展
extension OnboardingView {
private var bottomButton: some View {
Text(onboardingState == 2 ? "完成" : "下一步")
.font(.headline)
.frame(height: 55)
.frame(maxWidth: .infinity)
.background(Color.white)
.cornerRadius(10)
.onTapGesture {
handleNextButtonPressed()
}
}

func handleNextButtonPressed() {
// 校验逻辑
if onboardingState == 1 && name.count < 3 {
// 这里可以触发 Alert 提示用户
return
}

if onboardingState == 2 {
signIn()
} else {
withAnimation(.spring()) {
onboardingState += 1
}
}
}

func signIn() {
currentUserName = name
withAnimation(.spring()) {
currentUserSignedIn = true
}
}
}

关键知识点总结

  • Asymmetric Transition (非对称转场):通过 .asymmetric(insertion:removal:) 可以精准控制视图“进来”和“离开”时的方向,避免所有元素都往同一个方向挤。
  • Data Validation (数据校验):在 onboardingState 增加之前,利用 guardif 语句拦截不合规的输入,提升 App 的健壮性。
  • Extension 组织代码:通过 extension 将 UI 片段(Section)和业务逻辑(Function)移出主 body。这在 Python 语境下类似于将逻辑封装进类方法,保持主逻辑简洁。
  • 持久化同步:修改 @AppStorage 的值会自动触发全局视图树刷新,这让 UI 跳转变得非常自然,无需手动发送通知。

AsyncImage

AsyncImage 的核心定义

AsyncImage 是一个专门用于异步加载并显示网络图片的视图组件。它内部使用系统共享的 URLSession 实例来处理网络请求,并根据加载状态(加载中、成功、失败)自动更新 UI。

基础用法与默认状态

最简单的实现方式只需要提供图片的 URL。在这种模式下,图片会按原始大小下载,且在下载完成前会显示一个默认的灰色占位矩形。

1
2
3
4
5
6
7
8
struct AsyncImageBootcamp: View {
let url = URL(string: "https://picsum.photos/400")

var body: some View {
// 最简用法:不带任何修饰
AsyncImage(url: url)
}
}

自定义图片样式与占位符

由于 AsyncImage 本身是一个容器视图,你不能直接给它添加 .resizable() 等图片专用修饰符。必须使用带有 contentplaceholder 闭包的构造器。

  • Content 闭包:在这个闭包里,你会得到下载成功的 Image 对象,此时可以进行缩放、裁剪等操作。
  • Placeholder 闭包:在图片加载过程中显示的视图(如 ProgressView)。
1
2
3
4
5
6
7
8
9
AsyncImage(url: url) { image in
image
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
.cornerRadius(20)
} placeholder: {
ProgressView()
}

使用 AsyncImagePhase 进行深度控制

如果你需要根据不同的加载阶段(加载中、成功、失败)展示完全不同的 UI,可以使用 AsyncImagePhase 版本的构造器。这是最灵活、最推荐的进阶方案。

  • .empty:尚未加载或加载中。
  • .success(Image):加载成功,返回图片。
  • .failure(Error):加载失败,可以显示错误图标或默认图。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView() // 加载中
case .success(let returnedImage):
returnedImage
.resizable()
.scaledToFill()
.frame(width: 200, height: 200)
.cornerRadius(20)
case .failure:
Image(system_name: "ant.circle.fill") // 加载失败图标
.font(.headline)
@unknown default:
EmptyView()
}
}

动画与转场 (Transaction)

为了让图片加载成功时的出现效果更丝滑,AsyncImage 支持通过 transaction 参数添加动画。

1
2
3
4
5
6
7
8
9
10
11
12
AsyncImage(
url: url,
transaction: Transaction(animation: .spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0.25))
) { phase in
if let image = phase.image {
image
.resizable()
.transition(.scale) // 添加缩放转场
} else {
ProgressView()
}
}

关键知识点总结

  • 修饰符位置.resizable() 等图片专用修饰符必须写在闭包内的 Image 对象上,不能直接写在 AsyncImage 外部。
  • 比例参数 (Scale)AsyncImage(url: url, scale: 2.0) 中的 scale 值越大,图片在屏幕上显示的像素尺寸越小(适合处理高分辨率图)。
  • 缓存机制:原生的 AsyncImage 会自动利用系统级的 URL 缓存,但并不具备像第三方库那样复杂的磁盘缓存逻辑。
  • iOS 版本要求:仅支持 iOS 15.0+。如果需要兼容旧版本,仍需使用第三方组件。

Materials

材质 (Materials) 的核心定义

材质是一种半透明的模糊效果,它允许背景内容透过来,同时保持前景内容的可读性。在 iOS 15 中,你可以直接通过 .background() 加上材质类型来实现。

系统提供了五种主要的材质强度,它们会根据系统当前的浅色/深色模式自动调整:

  • .ultraThinMaterial:最轻薄,背景透过度最高。
  • .thinMaterial:薄材质。
  • .regularMaterial:标准厚度。
  • .thickMaterial:较厚。
  • .ultraThickMaterial:最厚,几乎接近纯色。

系统背景层级与样式

除了材质,iOS 15 还引入了更智能的背景设置方式。以前我们常用 Color.blue,现在我们可以直接使用 .background(.blue)。这背后的区别在于,新的 API 支持更复杂的 背景样式(Background Styles)

  • 自动适配:使用系统材质作为背景时,前景文字会自动应用“活力(Vibrancy)”效果,使得文字颜色与模糊背景产生更和谐的对比。
  • 分层设计:系统背景现在可以通过 .secondary, .tertiary 等样式进行分层,这在构建复杂的卡片式布局时非常有用。

核心代码实现

以下代码演示了如何在图片上方创建一个具有“毛玻璃”效果的卡片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import SwiftUI

struct BackgroundMaterialsBootcamp: View {
var body: some View {
VStack {
Spacer()

// 模拟一个底部弹窗或卡片
VStack(alignment: .leading, spacing: 10) {
RoundedRectangle(cornerRadius: 4)
.frame(width: 40, height: 4)
.padding(.top)
.frame(maxWidth: .infinity)

Text("材质效果展示")
.font(.headline)

Text("这是一个使用 .ultraThinMaterial 实现的毛玻璃效果卡片。它能根据背景颜色自动调整透明度和模糊感。")
.font(.subheadline)

Spacer()
}
.padding(.horizontal)
.frame(height: 350)
.frame(maxWidth: .infinity)
// MARK: - 核心代码:添加材质背景
.background(.ultraThinMaterial)
.cornerRadius(30)
}
.ignoresSafeArea()
.background(
// 使用网络图片作为背景以观察模糊效果
AsyncImage(url: URL(string: "https://picsum.photos/1000")) { image in
image.resizable().scaledToFill()
} placeholder: {
Color.blue
}
)
}
}

关键知识点总结

  • Vibrancy (活力效果):当你将材质设为背景时,其上方的 TextImage 会自动获得一种类似“透过玻璃看文字”的视觉深度感,这种效果是系统自动处理的。
  • 忽略安全区域:在设置全屏背景材质时,记得配合 .ignoresSafeArea() 使用,以确保模糊效果覆盖整个屏幕。
  • 自适应模式:材质不需要手动适配深色模式,系统会自动在浅色时提供偏白的模糊,在深色时提供偏黑的模糊。
  • 性能优化:材质渲染虽然比纯色消耗资源,但在 iOS 15+ 的真机设备上表现非常流畅,适合作为浮动层(如 TabBar 或 Header)的背景。

textSelection

TextSelection 的核心定义

在 SwiftUI 中,默认情况下 Text 视图是静态的,用户无法选中其中的内容。.textSelection 修饰符允许你开启文字的选择功能,使用户能够通过长按唤起系统标准的“拷贝(Copy)”和“共享(Share)”菜单。

基础用法与代码实现

实现文字选择非常简单,只需要将修饰符附加到 Text 或其父容器上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct TextSelectionBootcamp: View {
var body: some View {
VStack(spacing: 20) {
// 1. 单个 Text 开启选择
Text("长按这段文字可以复制我")
.font(.headline)
.textSelection(.enabled)

// 2. 容器内批量开启
VStack(spacing: 10) {
Text("我是第一行")
Text("我是第二行")
Text("我是第三行")
}
.textSelection(.enabled) // 该容器内所有 Text 都将变为可选择状态
}
.padding()
}
}

关键知识点总结

  • 默认状态:所有 Text 组件的默认状态是 .disabled
  • 层级继承:当你将 .textSelection(.enabled) 应用于 VStackListGroup 等容器时,其内部所有的 Text 都会自动继承这一特性。
  • 系统原生集成:开启选择后,用户不仅可以“拷贝”,还能触发 iOS 系统的“查询(Look Up)”和“翻译(Translate)”等高级功能。
  • 局限性:目前的 API 只能开启或关闭选择功能,开发者还无法像在 UITextView 中那样通过编程方式精确获取用户当前选中的具体字符范围。

iOS 15 按钮的更新

核心按钮样式 (Button Styles)

iOS 15 引入了预定义的按钮样式,它们不仅改变外观,还自带了优雅的交互反馈(如点击时的缩放或颜色变暗)。

  • .bordered:带边框的按钮,背景颜色通常是浅灰色(或根据 tint 自动调整)。
  • .borderedProminent:强调型按钮,背景会填充 tint 定义的主色调,文字通常自动变为对比色(如白色)。
  • .plain & .borderless:经典的纯文本样式。

控制尺寸与重要性 (Control Size & Prominence)

通过这些修饰符,你可以快速调整按钮的物理尺寸和视觉吸引力,而不需要手动计算宽高度。

  • controlSize:提供 .mini.small.regular.large 四种预设。它会同步调整内边距和字体大小,保持比例和谐。
  • controlProminence:设置为 .increased 时,会进一步增强 .borderedProminent 的色彩深度,使其在界面中更加突出。

边框形状 (Button Border Shapes)

不再需要通过 cornerRadius 苦苦计算。系统提供了更具语义化的形状选项:

  • .capsule:胶囊形,两端自动呈半圆。
  • .roundedRectangle:圆角矩形。
  • .roundedRectangle(radius: 20):可自定义圆角半径的矩形。

核心代码实现

下面的代码展示了如何将这些属性组合在一起,创建一个具有工业质感的 UI 控制组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import SwiftUI

struct ButtonStylesBootcamp: View {
var body: some View {
VStack(spacing: 20) {
// 1. 基础强调按钮
Button("开始任务") {
// 点击逻辑
}
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.buttonBorderShape(.capsule)

// 2. 带有色彩倾向的边框按钮
Button {
// 删除操作
} label: {
Label("删除数据", systemImage: "trash")
}
.buttonStyle(.bordered)
.controlSize(.regular)
.tint(.red)

// 3. 不同尺寸对比
HStack {
Button("小") { }.controlSize(.small)
Button("中") { }.controlSize(.regular)
Button("大") { }.controlSize(.large)
}
.buttonStyle(.bordered)

// 4. 组合属性:高重要性
Button("立即订阅") { }
.buttonStyle(.borderedProminent)
.controlProminence(.increased)
.controlSize(.large)
.tint(.orange)
}
.padding()
}
}

关键知识点总结

  • Tint 的魔力tint 颜色会智能地应用于背景(在 borderedProminent 下)或文字与边框(在 bordered 下)。
  • 响应式适配:这些样式会根据当前设备的系统设置(如辅助功能中的大字体)自动进行布局微调。
  • Role 属性:配合 Button(role: .destructive) 使用时,按钮会自动应用红色调,这是 iOS 15 语义化编程的体现。
  • 多按钮布局:在 HStack 中使用多个 bordered 按钮时,系统会自动保持它们的垂直居中对齐,非常整齐。

swipeActions

列表滑动操作的核心定义

swipeActions 是专门用于 List 行视图的修饰符。它允许你通过滑动触发特定操作(如收藏、存档、删除等),并能精准控制按钮的颜色、图标和触发逻辑。

核心代码实现

下面的示例展示了如何为一个水果列表添加左右双向的滑动操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import SwiftUI

struct SwipeActionsBootcamp: View {
@State var fruits: [String] = ["苹果", "香蕉", "桃子", "西瓜"]

var body: some View {
List {
ForEach(fruits, id: \.self) { fruit in
Text(fruit.capitalized)
// MARK: - 右侧滑动操作 (Trailing)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
deleteItem(fruit)
} label: {
Label("删除", systemImage: "trash.fill")
}

Button {
// 存档逻辑
} label: {
Label("存档", systemImage: "archivebox.fill")
}
.tint(.blue)
}
// MARK: - 左侧滑动操作 (Leading)
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
// 收藏逻辑
} label: {
Label("收藏", systemImage: "star.fill")
}
.tint(.yellow)
}
}
}
}

func deleteItem(_ item: String) {
if let index = fruits.firstIndex(of: item) {
fruits.remove(at: index)
}
}
}

关键知识点总结

  • Edge 控制:可以通过 edge 参数决定按钮出现在左侧(.leading)还是右侧(.trailing)。
  • allowsFullSwipe
    • 如果设为 true(默认值),用户长滑到底会直接触发第一个按钮的动作。
    • 如果设为 false,用户必须点击按钮才能触发,防止误操作。
  • Button Role:使用 Button(role: .destructive) 时,系统会自动将按钮设为红色,并适配删除相关的触感反馈。
  • Tint 颜色自定义:使用 .tint() 为不同的操作设置辨识度高的颜色(如黄色对应收藏,蓝色对应存档)。
  • 图标与文字:虽然代码中使用了 Label,但在滑动条较窄时,系统通常只显示图标。建议始终提供简洁的图标。

iOS 15 中的 badge

核心定义与适用场景

badge 修饰符的作用是在特定组件上显示一个轻量级的数值或文本标签。在 iOS 15 中,它主要针对以下两个原生组件进行了优化:

  1. TabView:在底部图标的右上角显示未读消息数(类似微信或邮件)。
  2. List:在列表行的右侧显示状态信息或计数。

在 TabView 中添加 Badge

TabView 中,你只需要将 .badge() 附加在作为 tabItem 的视图上。它支持传入 Int(会自动处理 0 的隐藏)或 Text/String

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TabView {
Color.red
.tabItem {
Image(systemName: "house.fill")
Text("首页")
}
// MARK: - 数值 Badge
.badge(5)

Color.blue
.tabItem {
Image(systemName: "envelope.fill")
Text("消息")
}
// MARK: - 字符串 Badge
.badge("New")
}

在 List 中添加 Badge

List 中使用 badge 时,该标签会出现在行内容的右侧,且其视觉风格会根据列表的样式(如 insetGrouped)自动调整颜色,通常呈现为一种淡雅的灰色(在深色模式下会自动适配)。

1
2
3
4
5
6
7
8
9
10
List {
Text("未读邮件")
.badge(10)

Text("待办事项")
.badge("紧急")

Text("已完成")
.badge(0) // 注意:在 List 中,0 默认也会显示
}

核心代码实现

下面的示例展示了如何结合 @State 动态更新 badge 的数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import SwiftUI

struct BadgeBootcamp: View {
@State private var notificationCount: Int = 3

var body: some View {
TabView {
NavigationView {
List {
Text("系统通知")
.badge(notificationCount)

Button("增加通知数") {
notificationCount += 1
}
}
.navigationTitle("设置")
}
.tabItem {
Image(systemName: "gear")
Text("设置")
}
// TabView 上的 Badge 会随着 state 实时更新
.badge(notificationCount)

Text("个人主页")
.tabItem {
Image(systemName: "person")
Text("我的")
}
.badge("!")
}
}
}

关键知识点总结

  • 类型支持.badge() 可以接收 IntTextStringLocalizedStringKey
  • 0 的处理机制
    • TabView 中:如果传入 Int 且值为 0,Badge 会自动隐藏
    • List 中:如果值为 0,Badge 仍然会显示数字 0
  • 视觉一致性badge 的外观由系统接管。在 TabView 中是经典的红色圆形背景;在 List 中则是靠右对齐的辅助文本样式。
  • 版本限制:此修饰符仅适用于 iOS 15.0+。

next video 61