SwiftUI 学习
一、为什么 SwiftUI 中的视图用 struct 而不是 class
- 值类型设计:
SwiftUI采用声明式编程,视图本身被设计成值类型(struct)。每次状态变化时,系统会重新创建视图(“刷新”视图),这种方式可以更高效地进行视图更新和比对,从而实现差异化渲染。 - 不可变性与安全性:
struct是不可变的,减少了多线程和状态管理中的复杂性,使得数据流更清晰。用struct定义视图可以保证不小心修改视图结构带来的副作用更少。 - 轻量与性能优化:视图通常很轻量,
struct类型在内存分配和拷贝上更高效。SwiftUI利用这种值类型的特性,对界面变化做高效计算和diff运算。
因此,在 SwiftUI 中,大部分视图都是以 struct 来定义,而不是使用 class。
二、声明式编程
SwiftUI 就是声明式编程(Declarative Programming)的一种典型体现。先解释概念,再具体说 SwiftUI 的实践方式。
一、什么是声明式编程?
🧠 定义:
声明式编程是一种编程范式,你只需要描述“做什么”而不是“怎么做”。
相比之下,命令式编程(Imperative Programming是逐条告诉计算机“怎么一步步做”。
🔍 举个简单例子(iOS 编程场景):
命令式(UIKit):
1 | let label = UILabel() |
你要一步步地“创建 → 配置 → 加到视图上”。
声明式(SwiftUI):
1 | Text("Hello") |
你只“声明”了这个视图长什么样,系统会根据状态自动渲染出来。
二、声明式编程的核心特点
| 特征 | 解释 |
|---|---|
| 状态驱动 | UI 与数据状态绑定,状态变 → UI自动更新 |
| 不可变结构 | 用值类型(如 struct)描述 UI,不直接修改视图 |
| 关注“结果” | 你描述“要显示的内容”,不关心渲染细节 |
| 更适合组合 | 视图嵌套/组合方式更清晰(如 VStack/HStack) |
三、SwiftUI 是怎么体现声明式编程的?
UI = 状态的函数
1
2
3
4
5
6
7
8var count = 0
var body: some View {
Text("Count: \(count)")
Button("Add") {
count += 1
}
}👉 SwiftUI 监听
@State的变化,自动重建视图。你只描述“UI 在什么状态下应该呈现什么样”。结构即视图
所有View都是struct,没有复杂的生命周期管理(不像UIKit中有viewDidLoad、viewWillAppear等)。自动Diff & 渲染
你不需要手动调用reloadData()或setNeedsLayout(),状态一变UI自动刷新。
四、总结:SwiftUI vs UIKit 编程范式
| 对比点 | UIKit(命令式) | SwiftUI(声明式) |
|---|---|---|
| UI 描述方式 | 一步步设置组件属性 | 直接声明组件内容和样式 |
| 数据绑定 | 手动管理 | 自动状态绑定(@State、@Binding) |
| 响应更新 | 需要手动刷新 | 数据变动 → UI自动刷新 |
| 易错性 | 状态管理繁琐,易出 bug | 更简洁、安全、自动化 |
五、现实启示
SwiftUI 的声明式模式适合构建状态驱动的 UI、响应式交互,逻辑更清晰,特别适合复杂视图组合、动画、实时响应。
三、SwiftUI 状态管理 & 数据绑定:全面解析
SwiftUI 提供了一套响应式数据绑定机制,其中包括 @State、@Binding、@Environment、@ObservableObject 等属性包装器。要正确使用它们,首先要理解它们的用途、联系以及适用场景。
📌 SwiftUI 的状态管理 & 数据绑定关键概念
1. @State
✅ 作用:在 当前视图 (struct View) 内部 管理 私有 状态。
✅ 适用场景:变量仅在当前视图内部使用,不需要跨视图传递。
✅ 存储位置:SwiftUI 自动管理,@State 变量会被 SwiftUI 视为源数据(Source of Truth)。
示例
1 | struct CounterView: View { |
注意事项
@State只能用于struct View内部,不能用于class。- 不能在 不同视图 之间共享
@State,否则数据会不同步。 - 变量变化时,整个视图会重新计算
body,触发 UI 更新。
2. @Binding
✅ 作用:@Binding 允许子视图 访问并修改父视图的 @State 变量,但不会自己存储数据。
✅ 适用场景:子视图需要控制父视图的 @State 数据,而不自己管理状态。
示例
1 | struct ParentView: View { |
注意事项
@Binding不存储数据,它只是一个引用指针,绑定到@State的数据。- 适用于:
- 子视图 只修改数据,不拥有数据 的情况。
- 避免在父子视图之间使用回调,提升代码简洁度。
3. Binding
✅ 作用:Binding<T> 是 @Binding 背后的数据类型,可以手动创建 Binding 以用于更复杂的场景。
示例
1 | struct ToggleSwitch: View { |
区别
@Binding用于参数,而Binding<T>用于手动创建绑定。@Binding不能脱离@State,但Binding<T>可以从State、Environment、甚至Computed Property生成。
4. @ObservableObject
✅ 作用:用于类 (class),让整个类的数据可以被 SwiftUI 监听,适用于多个视图共享数据。
✅ 适用场景:
- 需要跨多个视图共享状态。
- 需要监听
class中属性的变化,并自动刷新 UI。
示例
1 | class CounterModel: ObservableObject { |
注意事项
ObservableObject必须配合@StateObject或@ObservedObject在 SwiftUI 视图中使用。- 需要在属性前使用
@Published,否则SwiftUI不会监听到变化。
5. @Published
✅ 作用:在 ObservableObject 类中使用,确保 SwiftUI 监听数据变化。
✅ 适用场景:
- 让
class内的属性可以被 SwiftUI 监听。 - 只有
ObservableObject内部的@Published变量 才会触发 UI 更新。
示例
1 | class UserSettings: ObservableObject { |
注意事项
@Published只能用于class内部,不能用于struct。- 如果
ObservableObject里的属性不加@Published,数据改变时 UI 不会刷新!
6. @StateObject vs @ObservedObject
| 属性包装器 | 作用 | 生命周期 | 适用场景 |
|---|---|---|---|
@StateObject |
创建 ObservableObject |
随视图创建和销毁 | View 内部管理状态 |
@ObservedObject |
观察 ObservableObject |
由外部传入 | 子视图观察父视图的 ObservableObject |
1 | struct ParentView: View { |
注意
@StateObject只能用于创建ObservableObject,不能用于传递。@ObservedObject适用于从父视图传递ObservableObject给子视图。
7. @Environment
✅ 作用:用于 跨视图层级 共享全局状态。
✅ 适用场景:
- 共享 全局设置,如
ColorScheme、Locale、UserDefaults数据等。
1 | struct ContentView: View { |
🛠 总结
| 属性 | 作用 | 适用场景 | 使用位置 |
|---|---|---|---|
@State |
本地私有状态 | 当前视图 | struct View |
@Binding |
绑定父视图的 @State |
子视图 | struct View |
@ObservedObject |
观察 ObservableObject |
需要共享的 ObservableObject |
struct View |
@StateObject |
创建 ObservableObject |
视图管理 ObservableObject |
struct View |
@Environment |
访问全局环境值 | 访问系统或上层视图 | struct View |
@Published |
触发 ObservableObject 变化 |
class 内部 |
class |
Binding<T> |
手动创建 @Binding |
适用于更复杂绑定 | struct View |
🎯 记住:
@State自己管理数据,@Binding修改外部数据。@StateObject创建ObservableObject,@ObservedObject引用ObservableObject。@Environment访问全局状态。@Published让ObservableObject的属性可观察。
这样,就能正确掌握 SwiftUI 的数据流 🚀!
四、@Observable
@Observable、@Bindable 以及 ObservableObject、@StateObject以及 @Published 之间的联系和区别
1. @Observable vs. ObservableObject + @Published
✅ @Observable(Swift 5.9+ / iOS 17+)
@Observable是 Swift 5.9(iOS 17) 引入的新数据绑定机制,它是ObservableObject的替代品。- 与
@ObservableObject + @Published不同,@Observable可以自动检测属性变化,不需要手动加@Published。 - 优点:
- 不需要
@Published,所有属性默认都可被监听(对class有效)。 - 性能优化:
Swift编译器自动优化依赖跟踪,避免无效UI更新。 - 更简洁,没有
objectWillChange.send()手动触发的麻烦。
- 不需要
❌ ObservableObject + @Published(Swift 5.8 及之前)
- 需要手动加
@Published标记哪些属性需要触发 UI 变化。 ObservableObject使用objectWillChange.send()通知 SwiftUI,但@Observable不需要手动通知,自动追踪变化。
🌟 结论
在我的 ConfigModel 使用了 @Observable,所以即使没有 @Published,它的属性变化仍然会触发 UI 更新。这就是 @Observable 的优势之一。
2. @Bindable vs. @StateObject
@Bindable 和 @StateObject 确实都可以让视图监听 ConfigModel 的数据变化,但它们的作用不同。
✅ @Bindable
- 用于绑定
@Observable类的属性,提供Binding访问,但不负责对象生命周期管理。 - 适用于 子视图接收
@Observable对象的引用,而不是创建/持有它。 - 依赖于
@Observable提供的自动监听功能。
✅ @StateObject
- 管理
ObservableObject实例的生命周期,确保对象在视图生命周期内存活。 - 适用于 视图内部创建
ObservableObject并持有它。 - 如果对象被
@StateObject持有,它的生命周期和视图一致,即使视图body重新计算,也不会重新创建StateObject。
🌟 代码对比@StateObject 的用法
1 | struct ConfigurationSheetView: View { |
特点:
@StateObject管理reportConfig的生命周期。- 如果
ConfigurationSheetView重新创建,reportConfig不会丢失数据。
@Bindable 的用法
1 | struct ConfigurationSheetView: View { |
特点:
@Bindable不会创建或持有reportConfig,只是引用它。@Bindable允许TextField直接绑定到reportName,不需要Binding(get:set:)。
3. @Bindable 和 @StateObject 的核心区别
| 属性包装器 | 作用 | 是否创建对象 | 是否管理生命周期 | 适用场景 |
|---|---|---|---|---|
@StateObject |
创建 & 持有 ObservableObject |
✅ 是 | ✅ 是 | 视图自己管理 ObservableObject |
@Bindable |
绑定 @Observable 数据 |
❌ 否 | ❌ 否 | 视图引用 @Observable 对象,不管理生命周期 |
🌟 何时使用哪种?
| 场景 | 使用 @StateObject? |
使用 @Bindable? |
|---|---|---|
视图创建并持有 ObservableObject |
✅ 是 | ❌ 否 |
父视图创建 ObservableObject,子视图绑定它 |
❌ 否 | ✅ 是 |
不希望 ObservableObject 被销毁 |
✅ 是 | ❌ 否 |
4. @Observable 和 @Binding 结合
有时,可能希望 @Observable 的属性能单独绑定到 UI 控件,比如:
1 | struct SubView: View { |
特点
@Bindable让整个config可绑定。$config.isShowxxxx直接生成Binding<Bool>。
5. @Observable 是否适用于 struct 和 enum?
❌ 不能用于 struct 和 enum
@Observable只能用于class,因为SwiftUI需要监听对象属性变化,而struct是值类型,不适用于观察模式。
✅ @State 是 struct 的替代方案
如果我需要 struct 管理状态,应该使用 @State:
1 | struct MyView: View { |
6. 什么时候用 @Observable,什么时候用 ObservableObject?
| 使用场景 | 推荐使用 @Observable? |
推荐使用 ObservableObject? |
|---|---|---|
| iOS 17+ 项目 | ✅ 是 | ❌ 否 |
| iOS 16 及以下项目 | ❌ 否 | ✅ 是 |
想要简化代码,不用 @Published |
✅ 是 | ❌ 否 |
| 想要手动触发 UI 更新 | ❌ 否 | ✅ 是(objectWillChange.send()) |
📌 结论
@Observable取代了ObservableObject+@Published,不需要@Published也能自动监听变化。@Bindable适用于子视图引用@Observable数据,而@StateObject适用于视图自己管理ObservableObject。@StateObject管理对象生命周期,@Bindable只是数据绑定,不管理生命周期。@Observable只能用于class,不能用于struct或enum。- 如果是 iOS 17+,推荐使用
@Observable,如果要兼容 iOS 16 及以下,仍然需要用ObservableObject+@Published。
所以,在 iOS 17+ 项目中,我可以放心使用 @Observable 和 @Bindable,这会让我的代码更简单、更清晰!🚀
五、@StateObject vs @Bindable
问题 1:@StateObject vs @Bindable 在生命周期管理上的差异
@StateObject 和 @Bindable 都能让 SwiftUI 视图监听 @Observable(或 ObservableObject)的变化,但它们在 生命周期管理 上的行为完全不同。
✅ 示例:@StateObject 负责对象生命周期
在下面的代码中,我们的 CounterModel 是一个 @Observable(或 ObservableObject)的类,视图 ParentView 需要持有它。
1 |
|
🛠 @StateObject` 的关键点
@StateObject确保CounterModel只被创建一次,并随着ParentView的生命周期存在。- 即使
ParentView重新计算body,model依然是同一个实例。 - 当
ParentView被销毁时,model也会被销毁,生命周期由@StateObject绑定。
❌ 如果错误使用 @Bindable 代替 @StateObject
如果我们用 @Bindable 替代 @StateObject,它就不会管理 model 的生命周期,可能会导致错误的对象释放。
1 | struct ParentView: View { |
⚠️ 可能出现的问题
- 每次
ParentView重新计算body,CounterModel()可能会被重新创建,导致数据丢失。 - 子视图
ChildView可能会访问已被销毁的model,导致应用崩溃或状态异常。 - 无法正确管理
CounterModel的生命周期,如果model需要长期存在(比如在SettingsView共享用户设置),可能会意外销毁。
✅ @Bindable 正确的使用方式
@Bindable 只应该用于子视图,而不负责管理 Observable 对象的生命周期。
1 | struct ParentView: View { |
✅ 这样不会重复创建 model,也不会导致 model 意外丢失或被销毁。
🌟 总结 @StateObject vs @Bindable 的生命周期管理
| 属性包装器 | 是否管理对象生命周期? | 适用场景 |
|---|---|---|
@StateObject |
✅ 是,持有 Observable 对象 |
视图创建并管理 Observable 对象 |
@Bindable |
❌ 否,只做数据绑定 | 子视图引用 @Observable 数据,但不持有它 |
🚀 结论
@StateObject适用于创建Observable对象,并管理它的生命周期。@Bindable适用于引用Observable数据,但不会持有它。- 错误使用
@Bindable可能导致Observable被过早释放,或者在body重新计算时不断创建新的实例。
问题 2:ObservableObject + @Published 可以用于 struct 或 enum 吗?
🚫 不能!ObservableObject 只能用于 class,不能用于 struct 或 enum。
原因:
ObservableObject依赖于objectWillChange.send()机制,而struct和enum是值类型,它们无法持有objectWillChange这样的引用对象。@Published只能用于ObservableObject内部的属性,而struct不支持@Published。
✅ struct 该怎么做?
如果需要在 struct 中管理状态,应该使用 @State。
1 | struct CounterView: View { |
❌ 如果错误地尝试用 @Published 处理 struct
1 | struct CounterModel: ObservableObject { // ❌ `struct` 不能 conform `ObservableObject` |
🚨 报错!ObservableObject 只能用于 class,不能用于 struct。
🌟 什么时候用 class(@Observable 或 ObservableObject),什么时候用 struct?
| 情况 | 使用 struct(@State) |
使用 class(@Observable) |
|---|---|---|
| 状态是局部的(仅用于当前视图) | ✅ @State |
❌ 不适用 |
| 多个视图需要共享数据 | ❌ 不适用 | ✅ @Observable |
| 需要监听对象内部属性的变化 | ❌ struct 无法监听单个属性 |
✅ @Observable(iOS 17+)或 ObservableObject + @Published |
| 需要传递给子视图 | ✅ @Binding |
✅ @Bindable |
📌 结论
@StateObject负责管理Observable对象的生命周期,而@Bindable只是数据绑定,不持有对象。错误使用@Bindable可能会导致数据丢失或被过早销毁。ObservableObject只能用于class,不能用于struct或enum。如果需要管理struct的状态,应该使用@State。@Observable(iOS 17+)取代了ObservableObject + @Published,简化了数据监听机制,不需要@Published也能自动触发 UI 更新。@StateObject和@Bindable可以结合使用:@StateObject创建并管理Observable。@Bindable让子视图引用Observable数据,而不持有它。
正确理解这些概念,就可以在 SwiftUI 中更好地管理数据流和状态!🚀
六、SwiftUI 布局相关
.frame(maxWidth: .infinity, maxHeight: .infinity)
在 SwiftUI 中,.frame(maxWidth: .infinity, maxHeight: .infinity) 是一种 布局约束,用于指定视图的最大宽度和最大高度。
.frame(maxWidth: .infinity):表示视图的宽度会尽可能地 扩展,直到 占据所有可用空间。infinity表示无限制,所以它会填满可用的水平空间,通常是父视图所提供的空间。.frame(maxHeight: .infinity):类似的,表示视图的高度会尽可能地 扩展,直到 占据所有可用空间。
示例说明
假设我们在父视图中设置了 .frame(maxWidth: .infinity, maxHeight: .infinity),这意味着 子视图将被拉伸到父视图的最大可用宽度和高度,并且它会占据尽可能多的空间。
示例:
1 | VStack { |
- 父视图(
VStack)是容器,它可能有限制(例如屏幕或视图的大小)。 Text("Hello, World!")会 占据尽可能多的空间,并且背景是 蓝色的。
具体含义和效果
- 宽度:
.infinity表示该视图的宽度会自动适配父视图的宽度,通常会撑满整个父视图的宽度。 - 高度:
.infinity也表示该视图的高度会适应父视图的高度,撑满整个父视图的高度。
常见场景
- 布局组件:比如
VStack、HStack、ZStack和GeometryReader等容器视图经常使用.frame(maxWidth: .infinity, maxHeight: .infinity)来填满父视图的空间。 - 可变高度的内容:如果需要视图根据内容动态调整大小,但不希望它超出父视图的边界,可以使用
.frame(maxWidth: .infinity, maxHeight: .infinity)。
🚀总结.frame(maxWidth: .infinity, maxHeight: .infinity) 使视图的 宽度和高度尽可能扩展,使其 填充父视图的可用空间。
UICollectionView
代码解析:
1 | let groupSize = NSCollectionLayoutSize( |
NSCollectionLayoutSize定义了一个组(group)中元素的尺寸。widthDimension: .estimated(self.cellWidth * CGFloat(self.headerTitles.count)):设置组的宽度为 估算宽度。估算的宽度是cellWidth(每个单元格的宽度)与headerTitles.count(列数)相乘的值。这意味着每行的宽度大约等于cellWidth乘以列数,但由于是估算,最终宽度会自动调整以适应父容器。heightDimension: .absolute(self.cellHeight):设置组的 固定高度,即每一行的高度将为cellHeight的值。
1 | let group = NSCollectionLayoutGroup.horizontal( |
NSCollectionLayoutGroup.horizontal创建了一个水平排列的 组(group),包含多个单元项(subitem)。layoutSize: groupSize:使用上面定义的groupSize来设置组的尺寸,即每行的宽度和高度。subitem: item:表示每个单元格(item)的布局。这是NSCollectionLayoutItem,它定义了每个单元格的尺寸。count: self.headerTitles.count:定义了该组包含的 单元格数,即每行有多少个单元格(列数),通常与headerTitles.count相同,表示每一列一个单元格。
1 | group.interItemSpacing = .fixed(1) |
interItemSpacing设置组内单元格之间的间距,这里使用.fixed(1)来确保每两个单元格之间有 1 点的固定间距。
1 | let section = NSCollectionLayoutSection(group: group) |
NSCollectionLayoutSection是集合视图中的一个部分,它表示一组有共同布局的元素。这里将前面定义的group作为该部分的内容。group: group:设置该部分的内容为先前创建的水平排列的group,即水平排列的一行。
1 | section.interGroupSpacing = 1 |
interGroupSpacing设置不同组之间的间距,这里设置为 1 点。即在每行(group)之间会有 1 点的间隔。
1 | section.contentInsets = NSDirectionalEdgeInsets(top: 1, leading: 1, bottom: 1, trailing: 1) |
contentInsets设置视图的内边距(padding)。这里使用NSDirectionalEdgeInsets来设置四个方向的内边距:top: 1:顶部内边距为 1 点。leading: 1:左侧内边距为 1 点。bottom: 1:底部内边距为 1 点。trailing: 1:右侧内边距为 1 点。
总结:
这段代码配置了一个水平排列的 组(group),每一行包含多个单元格,每个单元格的宽度由列数和每列的宽度决定,且具有固定的高度。每个单元格之间有 1 点的间距,每行之间也有 1 点的间距,并且整个 section 有一定的内边距。
UICollectionView 在拖拽时无法固定在一个水平或垂直的滑动区域
UICollectionView 在拖拽时无法固定在一个水平或垂直的滑动区域,而是上下大幅度拖拽。这种情况通常是由于 UICollectionView 的布局配置 或 contentSize 的设置 不正确导致的,或者是 UICollectionView 的滚动方向和滑动方式没有正确设置。
以下是几种可能的原因和解决方案:
- 检查布局设置
我这里使用的是 UICollectionViewCompositionalLayout 来配置我的 UICollectionView,请确保布局是正确设置的。
解决方案:
确保为 UICollectionViewCompositionalLayout 设置了正确的方向,尤其是在我的布局中设置了 horizontal 和 vertical 方向的滑动。
1 | let layout = UICollectionViewCompositionalLayout { sectionIndex, environment -> NSCollectionLayoutSection? in |
确保布局的 group 是 水平排列,而不是垂直排列。如果布局是水平滚动的,应该设置 NSCollectionLayoutGroup.horizontal。如果希望 水平滑动,确保它是设置为 水平排列的组。
- 滚动方向设置
确认我的 UICollectionView 的滚动方向是否正确设置。如果我的 UICollectionView 应该水平滚动而不是垂直滚动,那么要确保它的 isScrollEnabled 和布局方向是正确的。
1 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) |
在 UICollectionView 的 alwaysBounceHorizontal 设置为 true 后,用户可以水平拖动,而 showsVerticalScrollIndicator 为 false 则隐藏垂直滚动条。
- 确保
contentSize正确
UICollectionView 的 contentSize 控制了它是否允许滚动。如果 contentSize 过大或过小,可能会导致内容在拖拽时无法正常滚动。确保我的内容区有足够的尺寸来支持滚动。
解决方案:
确保我在布局中设置的大小符合实际的需求。如果 UICollectionView 没有正确地计算其内容区域的大小,可能会导致滚动区域不固定。
- 禁用垂直滑动
如果我的 UICollectionView 是 水平滑动 的,但拖动时有大幅度的垂直滑动,可能是因为垂直方向的滑动没有被限制。
解决方案:
如果想禁用垂直滑动,可以设置:
1 | collectionView.alwaysBounceVertical = false // 禁用垂直滑动 |
- 确保没有多余的外部滚动视图
如果我的 UICollectionView 被嵌套在多个滚动视图中,可能会出现滚动冲突,导致它无法固定在水平或垂直的滑动区域。
解决方案:
如果 UICollectionView 在其他 ScrollView 或 GeometryReader 中,确保设置正确的 滚动行为,以防止冲突。确保外部的 ScrollView 不干扰内部的 UICollectionView 滚动。
1 | ScrollView(.horizontal, showsIndicators: false) { |
- 测试修改后的布局
最后,可以逐步测试这些修改,确保 UICollectionView 在目标滚动方向上能保持稳定。
总结:
- 确保布局是正确配置的,特别是
UICollectionViewCompositionalLayout的方向。 - 设置
UICollectionView的滚动方向,确保 水平滑动 或 垂直滑动 正常工作。 - 确保内容区域 (
contentSize) 没有过大或过小,导致无法固定滑动区域。 - 禁用不需要的滚动方向,避免出现意外的滑动效果。
- 如果有外部容器视图,确保不会干扰
UICollectionView的滑动行为。
.transition(.opacity)
.transition(.opacity) 是 SwiftUI 中的一个动画效果,用来指定视图在 进入或离开屏幕时 应该如何过渡(动画效果)。在这个例子中,.transition(.opacity) 指定了一个 透明度变化 的动画效果。
具体含义:
transition是一个修饰符,用于控制视图在状态变化时的 过渡动画。.opacity是一种预定义的过渡类型,表示视图在 进入或离开时 会通过 渐变 的方式改变透明度。
使用场景:
- 当视图 出现(进入屏幕) 时,它从透明到完全可见,形成渐显动画。
- 当视图 消失(离开屏幕) 时,它会从完全可见变为透明,形成渐隐动画。
示例代码:
1 | VStack { |
解释:
Text("Hello, World!")是一个视图,在isVisible为true时显示。.transition(.opacity):当Text视图显示或消失时,透明度会随着视图的出现或离开发生渐变变化。withAnimation:使视图的状态变化(在这里是isVisible)伴随动画效果,进而触发.opacity过渡效果。
效果:
- 当
isVisible变为true时,Text从完全透明(透明度为 0)逐渐变为完全可见(透明度为 1),产生一个淡入(fade in)效果。 - 当
isVisible变为false时,Text会从完全可见逐渐消失,产生一个淡出(fade out)效果。
总结:.transition(.opacity) 用于设置视图的透明度动画,使得视图在出现和消失时有 渐变透明度 的效果,从而让界面切换看起来更加流畅和自然。
padding 和 contentMargins
在 SwiftUI 中,contentMargins 和 padding 是用于控制视图内外间距的常见布局修饰符。
1. padding
padding 是一个非常常用的修饰符,用来设置视图 内容与视图边缘之间的距离。它可以被用来增加内边距,使视图的内容不会直接触碰到视图的边缘。
基本用法:
1 | Text("Hello, World!") |
指定具体方向的内边距:
1 | Text("Hello, World!") |
.padding():在所有方向上都应用默认的内边距。.padding(.horizontal):同时应用左右方向的内边距。.padding(.vertical):同时应用上下方向的内边距。.padding(.top)、.padding(.leading)、.padding(.bottom)、**.padding(.trailing):设置特定方向的内边距。
设置内边距的大小:
1 | Text("Hello, World!") |
- 这里的
30是一个 具体的数值,设置为在所有四个方向上使用 30 点的内边距。
组合多个方向:
1 | Text("Hello, World!") |
[方向]允许同时对多个方向应用内边距。
2. contentMargins
contentMargins 用于设置视图内容的外边距。它并不是 SwiftUI 的一个直接修饰符,而通常与 GeometryReader 配合使用,或者在一些复杂视图中,作为容器设置的一部分。contentMargins 控制视图的整体布局空间,包括 视图的内外边距。
在 SwiftUI 中,contentMargins 并不是常用的标准修饰符。通常更常用的是 padding 来实现类似效果。如果想要调整内外间距,并且有一些特定的布局需求,contentMargins 可能出现在一些容器视图中,比如 List 或 ScrollView 等,它们会根据容器的设置应用一些内外边距。
3. padding 和 contentMargins 设置方式总结:
- 设置内边距:
1 | Text("Hello") |
- 组合外边距和内边距:
1 | VStack { |
4. 内边距与外边距的区别:
padding用于视图 内容与视图边缘之间的间距。可以通过设置padding来增加视图内容的可视空间,通常是 视图内部的间距。contentMargins是用来表示视图的 内外边距空间。在一些容器组件中,它用于设置容器的整体间距,通常会影响视图的 外部布局空间。
5. 示例:
1 | VStack { |
这个例子中:
Text("Welcome!")使用了水平内边距,填充了左右空间。Text("SwiftUI Tutorial")使用了上下内边距。- 整个
VStack被赋予了 30 点的外部边距。
总结:
padding是设置视图内容的内边距的修饰符,允许控制视图与父视图或边缘的间距。contentMargins在 SwiftUI 中并不直接提供作为一个标准修饰符使用,但在一些容器视图中可以作为布局的一部分来调整外边距,通常通过类似于padding的方式设置。
你碰到这个编译错误:
1 | initializer is inaccessible due to 'private' protection level |
是在使用 private var foregroundStyleScale: [Color] = [...] 和 private var symbolScale: [...] 时触发的,而将其改成 var 或 private let 却不会报错。
七、SwiftUI 中是否还需要 MVVM?
SwiftUI 虽然本身支持状态驱动的视图更新,但并不等于不需要 ViewModel。MVVM 在 SwiftUI 中的作用主要是解耦视图和业务逻辑、状态管理和数据处理,它依然能带来以下好处:
- 清晰的职责分层:视图只负责
UI,逻辑放在ViewModel; - 更好的测试性:
ViewModel便于单元测试; - 提升可维护性和可复用性:不同视图之间可以共享同一个
ViewModel; - 避免视图中出现太多逻辑代码,提升代码可读性。
八、SwiftUI 生命周期问题、异步数据加载和状态管理
1 | .task { |
当我在 onAppear 使用 Task,解析数据 ReportManager.shared.processReportData(with: uRL)
我解析完之后,ReportManager.shared.isDataLoaded 的状态是飘忽不定的
当我想使用 ReportManager.shared.isDataLoaded 控制状态的时候,我明明知道ReportManager.shared.isDataLoaded 还没有解析完为 false,但是它的值先为 true,后面才会变化成正确的状态。但是我直接使用
1 | .task { |
就能很好的控制状态
这个问题实际上是与 SwiftUI 的生命周期、异步数据加载和状态管理相关的。具体来说,问题的本质原因在于 onAppear 和 task 以及异步数据加载的执行时机和更新策略之间的交互。
问题分析
异步数据加载和生命周期问题:
- 在
SwiftUI中,onAppear是在视图显示时触发的。然而,onAppear的触发时机并不是非常明确,尤其是当我在视图展示后需要异步加载数据时,task方法提供的异步任务可能会被并行执行。 - 当我在
onAppear中启动Task来处理异步操作时,SwiftUI的视图生命周期仍然在控制,因此在视图加载时,异步任务可能尚未完成,但 UI 可能已经渲染并显示出状态变化,导致isDataLoaded状态不一致或者飘忽不定。
- 在
task与onAppear的区别:task是专门用于启动异步任务的一个修饰符,它能够确保异步操作与当前视图的生命周期同步,并且任务会在视图出现时开始执行。它有明确的控制逻辑,可以保证异步任务的顺序执行和视图更新。onAppear是SwiftUI的视图生命周期钩子,虽然它会在视图出现时触发,但它可能没有完全同步地与视图的渲染周期对接。尤其是在处理异步数据时,onAppear内的异步任务可能会在视图还未完全渲染时就启动,导致状态不一致。
状态变化滞后和同步问题:
isDataLoaded在ReportManager中可能会在异步加载数据后被更新。由于我在onAppear中启动异步任务,SwiftUI可能在异步任务完成之前就渲染了界面,导致状态标志变化滞后,甚至被错误地设为true。
为什么 .task 解决了问题
task修饰符是在SwiftUI视图出现时启动异步任务,并确保视图的生命周期与异步任务的执行顺序保持一致。在我直接使用task时,ReportManager.shared.processReportData(with: uRL)任务完成后,只有当isDataLoaded被正确设置为true并且数据加载完毕时,才会执行接下来的数据处理逻辑。- 使用
.task时,SwiftUI会确保异步任务执行完毕并且状态更新后,才进行界面更新,这样我就能控制状态的变化,并保证数据加载的顺序。
如何避免此类问题
优先使用
task处理异步任务:- 如果我需要在视图出现时处理异步数据加载,优先使用
task修饰符,因为它提供了明确的异步任务执行机制,并且能确保视图的更新与异步操作同步进行。
- 如果我需要在视图出现时处理异步数据加载,优先使用
避免
onAppear中的异步操作:- 尽量避免在
onAppear中启动复杂的异步操作,尤其是在需要确保任务执行顺序和数据完整性的情况下。将异步任务和视图渲染分离可以让我更清晰地控制状态更新。
- 尽量避免在
明确标记和监听数据加载状态:
- 使用
@State或@ObservedObject来监听数据加载的状态,并根据状态显示相关 UI。确保isDataLoaded在正确的时机进行更新,并且在数据加载完成前,UI 显示正确的“加载中”状态。
- 使用
使用
async/await明确控制执行顺序:- 在需要确保执行顺序的情况下,可以使用
async/await来明确控制任务的顺序,例如在task中调用await来确保任务完成后再执行接下来的操作。
- 在需要确保执行顺序的情况下,可以使用
总结
task修饰符能确保异步任务执行顺序和视图更新同步,避免了onAppear中的异步问题。- 尽量避免在
onAppear中直接执行复杂的异步操作,尤其是在需要确保数据加载顺序和视图更新时。 - 使用
async/await明确控制任务执行顺序,确保数据加载完成后再进行界面更新。
九、SwiftUI 编译报错
1.Struct ‘YourViewName’ has no initializers
1 | Struct 'YourViewName' has no initializers |
❗问题发生的根本原因:
在 View 结构体里这样声明:
1 | private var foregroundStyleScale: [Color] = [.red, .orange, .yellow, .mint] |
这会 被 Swift 编译器解释为一个 stored property(存储属性)。
而 SwiftUI 的 View 是一个 值类型的 protocol(struct),不允许普通的存储属性 —— 除非它是:
@State、@Binding、@Environment等修饰的;- 或者是
let常量(编译器允许其当作常量初始化期间编译展开); - 或者是 非私有 的属性,且在初始化时传入(因为
SwiftUI要生成自动合成的初始化器)。
💡 所以这几种写法的行为不同:
| 写法 | SwiftUI 能接受? | 原因 |
|---|---|---|
private var foregroundStyleScale = [...] |
❌ 报错 | SwiftUI 无法在自动合成的 init 中访问 private stored var |
var foregroundStyleScale = [...] |
✅ | 默认是 internal,SwiftUI 生成的 init 可以访问它 |
private let foregroundStyleScale = [...] |
✅ | 常量,允许内联构造,不依赖外部 init |
let foregroundStyleScale = [...] |
✅ | 同上,且允许自动合成 init |
✅ 推荐的安全写法:
- 如果值不需要修改(只是配置常量):
1 | private let foregroundStyleScale: [Color] = [.red, .orange, .yellow, .mint] |
- 如果希望根据其他属性动态返回,可以改为 计算属性:
1 | private var foregroundStyleScale: [Color] { |
🧠 延伸:为什么 private var 不行?
因为 SwiftUI 的 View 是这样设计的:
1 | struct GroupPointMarkChartView: View { |
当添加了 private var foregroundStyleScale 这种 存储变量时,SwiftUI 无法自动生成包含这个私有属性的初始化器,从而导致 View 初始化失败,这就是你看到的编译报错。
✅ 总结
- 使用
private var ... = [...]会创建一个“隐形的存储属性”,SwiftUI 不允许它; - 用
let或改为计算属性是最安全的; - 如果必须是
var(并非let),要保证不是private,否则 SwiftUI 无法合成对应的初始化器; - SwiftUI 是一个声明式 UI 框架,对
View结构体中的属性限制很多,是为了保持可追踪性和稳定的重绘机制。
2.No exact matches in reference to static method ‘buildExpression’
1 | // **Content area (dynamic switching)** |
编译时 Xcode 提示在 MyReportView(csvURL: csvURL) 处报错:No exact matches in reference to static method 'buildExpression'
- 这种报错一般是指:表示在某些地方类型不匹配或者
SwiftUI无法正确推断类型。
根本原因分析:
类型推断问题:
SwiftUI中的Group会将其包含的所有视图推断成同一个类型。如果在Group中有多个View,并且这些视图的类型不一致,SwiftUI就无法推断出它们的共同类型,因此会导致编译报错。这里,MyReportView(csvURL: csvURL)、MyReportPieChartView()和MyReportLineChartView()由于视图结构不完全一致,因此导致了类型推断失败。switch语句和类型推断:
当在switch语句中切换视图时,SwiftUI需要通过类型推断来确定每个case的返回值。然而,MyReportView需要一个csvURL参数,而MyReportPieChartView和MyReportLineChartView没有这个参数。当它们都包含在同一个Group内时,SwiftUI无法保证返回的视图类型一致。buildExpression错误:SwiftUI编译器通过buildExpression方法来处理视图的构建。如果在switch语句中传递不同类型的视图(比如MyReportView和没有csvURL的其他视图),SwiftUI无法通过类型推断确定每个视图的类型,并且无法正确调用buildExpression方法,因此报错:No exact matches in reference to static method 'buildExpression'。
内部原因:
类型不一致:在
switch语句中,MyReportView需要csvURL作为初始化参数,但MyReportPieChartView和MyReportLineChartView并没有这个参数。SwiftUI的Group无法处理不同类型的视图,因为它期望Group中的所有视图具有一致的类型。buildExpression是一个构建 SwiftUI 表达式的机制:SwiftUI依赖buildExpression来解析View类型。如果存在类型不一致的问题,它就无法正确处理这个表达式,导致编译时出错。
解决方案:
1. 确保每个视图都有一致的参数类型:
如果希望 MyReportView、MyReportPieChartView 和 MyReportLineChartView 都能接受 csvURL,那么需要为这些视图都添加 csvURL 属性,并在初始化时传递 csvURL。
修改方式:
- 给
MyReportPieChartView和MyReportLineChartView添加csvURL属性。 - 在
switch语句中,确保每个视图都接受相同的参数。
1 | struct MyReportPieChartView: View { |
2. 或者将不需要参数的视图修改为不接收参数:
如果 MyReportPieChartView 和 MyReportLineChartView 并不需要 csvURL 参数,可以直接去掉 csvURL 的传递,改成无参数的初始化方式。
1 | struct MyReportPieChartView: View { |
总结:
- 编译时错误:
No exact matches in reference to static method 'buildExpression'是由于switch中的MyReportView(csvURL: csvURL)和其他视图(MyReportPieChartView、MyReportLineChartView)之间的类型不一致引起的。 - 解决方案:要么确保所有视图都接受相同的参数(例如
csvURL),要么确保每个视图都不需要参数并进行相应调整。
3.swiftUI 传递参数问题
MyReportView 里面相关的属性如下:
1 | struct MyReportView: View { |
为什么 MyReportView 初始化的时候时候必须带上参数 csvURL,就是 MyReportView(csvURL: csvURL) 这样初始化,而其余的属性值不需要放在初始化里面?
问题分析:
MyReportView(csvURL: csvURL)必须传递csvURL参数,而MyReportPieChartView()和MyReportLineChartView()没有接收csvURL参数,这导致了在switch语句中出现了类型不一致的问题。由于MyReportView需要csvURL这个初始化参数,而其他视图没有这个参数,SwiftUI不能在switch语句中推断出一致的视图类型,因而报错。
为什么 MyReportView 需要 csvURL 参数:
在 SwiftUI 中,每个视图的 init 方法需要参数时,必须明确传递这些参数。在 MyReportView 中,csvURL 是一个 let 常量属性,它在初始化时被传递。这是因为 csvURL 可能是这个视图的核心数据源,用来加载 CSV 数据。因此,csvURL 在视图初始化时必须提供,以便视图能够正确加载并处理数据。
为什么其他属性不需要在初始化时传递:
在 SwiftUI 中,视图的属性通常有两种方式:
let常量属性:let属性在初始化时必须提供。这些属性需要通过初始化方法传递参数。@State、@Binding和其他状态属性: 这些属性是由SwiftUI管理的,并且它们会在视图生命周期中动态更新。所以这些属性不需要在初始化时传递。
@State 和类似的属性是由 SwiftUI 自动管理的,它们是绑定到视图状态的。当视图的状态发生变化时,SwiftUI 会自动重新渲染视图。比如 @State private var isLoading = false 就是一个状态变量,SwiftUI 会在需要时自动更新这个变量的值,而不需要在初始化时传递。
SwiftUI 独有的写法:
在 SwiftUI 中,@State、@Binding 等属性是 SwiftUI 特有的,用于管理视图的状态。它们与普通的 Swift 对象不同,后者通常会依赖于初始化方法来传递所有的属性。
在一般的 Swift 对象中,所有的属性通常都会在初始化时传递或赋值。而在 SwiftUI 中,@State 和 @Binding 等属性则不需要在初始化时传递,因为它们会自动管理视图的状态。
总结:
csvURL参数:MyReportView需要csvURL参数来初始化,因为它是视图的核心数据源,而其他视图可以根据需要选择是否传递该参数。@State属性: 这些属性是由SwiftUI自动管理的,通常不需要在初始化时传递。- 类型一致性:确保在
switch中的每个分支返回的视图类型一致,或确保没有参数时使用默认初始化。
4.No matching navigationDestination declaration`
我先导航进入另外一个页面,退出后点击 ReportListView 中的 item 想进入ReportContentView 的时候。无法导航进入。且报错
1 | A NavigationLink is presenting a value of type “MyReport” but there is no matching navigationDestination declaration visible from the location of the link. The link cannot be activated. |
ReportListView 中进入对应 ReportContentView 的处理是这么写的:
1 | private var reports: [MyReport] = [] |
开始以为是 @State private var reports: [MyReport] = [] 标记的数据为临时数据,后面发现 ReportListView 的父控件 ReportView 里面已经包装了 NavigationStack,好像就是这里导致的冲突,引发的报错
1 | struct ReportView: View { |
这个问题,是在导航层次上存在冲突,导致无法成功导航。这里的关键问题是我在 ReportView 和 ReportListView 中都使用了 NavigationStack,这会导致导航目标不可见,从而引发错误。
根本原因:
重复的
NavigationStack: 当我在ReportView中使用了一个NavigationStack,然后在ReportListView内部又使用了一个NavigationStack,这就导致了嵌套的导航栈。SwiftUI的NavigationStack是一个上下文管理器,它应该只存在一个父NavigationStack来控制整个视图栈。嵌套多个NavigationStack会导致导航目的地无法识别,进而导致无法正确激活链接。NavigationLink和navigationDestination配对的问题:NavigationLink使用NavigationStack来管理其导航目的地。当我为MyReport创建了NavigationLink并且指定了navigationDestination(for: MyReport.self),需要确保这个NavigationStack是唯一的,并且能够正确处理目标视图。
解决方案:
我只需要在 ReportView 中保留一个 NavigationStack,然后在 ReportListView 中移除 NavigationStack,这能确保导航目的地正确识别。具体可以按以下步骤修改:
1. 移除 ReportListView 中的 NavigationStack:
ReportListView 只需要负责展示列表,具体的导航管理交给 ReportView 来处理。所以,在 ReportListView 中删除 NavigationStack,只保留导航目标的 navigationDestination。
1 | struct ReportListView: View { |
2. 确保 ReportView 中唯一的 NavigationStack 负责整个导航栈:
在 ReportView 中,继续使用 NavigationStack 来包裹整个内容,包括 ReportListView。这样,ReportListView 的 NavigationLink 会根据 navigationDestination 进行正常跳转。
1 | struct ReportView: View { |
为什么这么做有效?
单一的
NavigationStack:通过在ReportView中保留唯一的NavigationStack,它会负责整个导航栈的管理,包括ReportListView中的NavigationLink。navigationDestination会在父NavigationStack中生效,从而确保能够正确导航到目标视图。避免重复的导航栈:避免在
ReportListView中再次包裹NavigationStack,这样NavigationLink可以正确识别并跳转到目标视图(ReportContentView)。
总结:
遇到的报错 No matching navigationDestination declaration 是因为在 ReportView 和 ReportListView 中都使用了 NavigationStack,导致了嵌套的导航栈冲突。通过移除 ReportListView 中的 NavigationStack,并确保在 ReportView 中只使用一个 NavigationStack,可以解决这个问题,并使得 NavigationLink 正常工作。