Swift UI 的架构方式
异步操作的 Action
对于一个异步操作,一般来说我们比较关注两个时间点。首先是异步操作开始的时候,我们可能希望在此时显示像是“正在加载”的界面,让用户知道正在进行一项耗时操作。另一个时间点是操作完成时,这时候我们可以使用异步操作的结果 (比如网络请求返回的数据) 来更新界面。因此一个异步操作一般会对应两个 State:
一个代表操作开始,app 进入等待状态;
另一个代表操作结束,可以按照需要更新 UI。
在我们的架构中我们使用 Command 来代表“在设置状态的同时需要触发一些其他操作”这个语境。Reducer 在返回新的 State 的同时,还返回一个代表需要进行何种副作用的 Command 值 (对应上一段中的第一个时间点)。
Store 在接收到这个 Command 后,开始进行额外操作,并在操作完成后发送一个新的 Action。这个 Action 中带有异步操作所获取到的数据。它将再次触发 Reducer 并返回新的 State,继而完成异步操作结束时的 UI 更新 (对应上一段中的第二个时间点)。
复合状态驱动UI
Redux for Swift 架构的基础,它们的最终目标非常明确,那就是在双向上保证单一数据源的可靠性:即所有影响 UI 的数据都应当来自于 Store,且 UI 的状态应当与 Store 中的状态同步。
对于通过 Action 改变的状态,如果我们想要执行网络请求这样的副作用,可以通过同时返回合适的 AppCommand 完成。但是对于通过绑定来更新的状态,由于不会经过 Store 的 reduce 方法来返回 Command,我们缺少一种有效的手段来在它们改变时执行副作用。
如何通过 UI 绑定触发副作用?Combine
@Published 需要在内部生成并持有存储,因此我们只能针对定义在 class 里的变量添加 @Published。
手势处理和导航
Group
1 | var panel: some View { |
使用 Group
,在内层利用 @ViewBuilder
支持 if...else
语句的特性,可以把不同类型的 View 包装到 Group View 里。另一种方式是使用 AnyView 把它们的具体类型抹消
GestureState
被标记为 @GestureState
的变量,除了具有和普通@State
类似的行为外,还会在 panelDraggingGesture
手势结束后被自动置回初始值 0。所以当下划距离不足以让面板关闭时,手势结束后面板将回到原地 (你也许注意到了,我们设定的弹簧动画依然有效)。当下划距离足够,面板将被正常关闭,通过 onEneded,isPresented 这个 Binding 将溯及到表示面板显示状态的 panelPresented
变量,并将它设为 false。
1 | private var translation = CGPoint.zero |
Cell共享状态
在一般处理像是 cell 这样的会重复多次出现的 View 中的状态时,我们需要牢记,如果使用的状态不属于 cell 本身,那么它们是可能会在多个 cell 之间共享的。
1 | struct ContentView : View { |
当你点击某个 cell 时,根据你使用的 Xcode 版本不同,可能会出现各种奇怪的现象。比如在本书写作时的 Xcode 中,它将会顺次把从 0 到 9 的 Detail Text 都推入一遍,最后再回到初始的列表状态。
原因,是因为 show 是定义在 ContentView 中的变量,它被所有的 cell 共享。当你点击某个 cell 时,show 的值被设置,这导致 body 部分被重新求值。而此时,show 不仅会作用在你点击的 cell 上,也会作用在所有其他 cell 上,也就是同时有多个 NavigationLink 的 isActive 参数接收到了 true,这会带来未定义的行为。
同样的情况也会发生在 List 的 cell 上使用 .sheet 以 modal 方式弹出的界面中。.sheet 最常见的用法是 sheet(isPresented:content:),它需要 Binding 来决定展示与否,在实际 app 中更容易让人犯错。
因此,面对这类 cell 中需要某个状态的时候,我们需要引入这个状态和当前 cell 的某种关联
布局和对齐
布局
fixedSize。这个 modifier 将提示布局系统忽略掉外界条件,让被修饰的 View 使用它在无约束下原本应有的理想尺寸。
frame :它并不是将所作用的 View 的尺寸进行更改,而是新创建一个 View,并强制地用其指定的尺寸,对内容 (其实也就是它的子 View) 进行提案
1 | frame(width:height:alignment:) |
和固定宽高的版本不同,这个方法为尺寸定义了一套约束:如果从父 View 中获得的提案尺寸小于 minXXX 或者大于 maxXXX,这个 frame 将会把这个提案尺寸截取到相应的最小值或者最大值,然后进行提案。
frame 方法的两种版本里,所有的参数都有默认值 nil,如果你使用这个默认值,那么 frame 将不在这个方向上改变原有的尺寸提案,而是将它直接传递给子 View。
frame 方法的最后一个参数表示所使用的对齐方式。不过,很多时候单纯地改变这个对齐方式不会有任何效果:
1 | HStack { |
因为这个 alignment 指定的是 frame View 中的内容在其内部的对齐方式,如果不指定宽度或者高度,那么 frame 的尺寸将完全由它的内容决定。换言之,内容都已经占据了 frame 的全部空间,不论采用哪种方式,内容在 frame 里都是“贴边的”。对齐也就没有任何意义了。想要体现和实验 frame 里的对齐方式,可以为 frame 添加一个多余内容所需空间的尺寸参数:
1 | HStack { |
隐式对齐和显式对齐
alignmentGuide
所做的事情,是负责修改 g (HorizontalAlignment 或者 VerticalAlignment) 的对齐方式,把原来的 defaultValue(in:)
所提供的默认值,用 computeValue 返回的 CGFloat 值进行替代。
1 | HStack { |
代码相当于:
1 | HStack(alignment: .center) { |
注意,只有当
alignmentGuide
的第一个参数VerticalAlignment.center
和外层容器HStack
的alignment
参数一致时,它才会被考虑。因为alignmentGuide
API 的作用就是修改传入的 alignment 的数值。
自定义 Alignment 和跨 View 对齐
新建对齐的最主要目的,还是为了跨越 View 的层级来进行对齐。