学计算机的那个

不是我觉到、悟到,你给不了我,给了也拿不住;只有我觉到、悟到,才有可能做到,能做到的才是我的.

0%

SwiftUI 与 Combine 下

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var panel: some View {
// 2
Group {
if pokemonList.selectionState.panelPresented {
if selectedPanelIndex != nil && pokemonList.pokemons != nil {
PokemonInfoPanelOverlay(
model: pokemonList.pokemons![selectedPanelIndex!]!)
} else {
EmptyView()
}
} else {
EmptyView()
}
}
}

使用 Group,在内层利用 @ViewBuilder 支持 if...else 语句的特性,可以把不同类型的 View 包装到 Group View 里。另一种方式是使用 AnyView 把它们的具体类型抹消

GestureState

被标记为 @GestureState 的变量,除了具有和普通@State类似的行为外,还会在 panelDraggingGesture 手势结束后被自动置回初始值 0。所以当下划距离不足以让面板关闭时,手势结束后面板将回到原地 (你也许注意到了,我们设定的弹簧动画依然有效)。当下划距离足够,面板将被正常关闭,通过 onEneded,isPresented 这个 Binding 将溯及到表示面板显示状态的 panelPresented 变量,并将它设为 false。

1
2
3
4
5
6
7
8
9
10
11
12
@GestureState private var translation = CGPoint.zero
var panelDraggingGesture: some Gesture {
DragGesture()
.updating($translation) { current, state, _ in
state.y = current.translation.height
}
.onEnded { state in
if state.translation.height > 250 {
self.isPresented.wrappedValue = false
}
}
}

Cell共享状态

在一般处理像是 cell 这样的会重复多次出现的 View 中的状态时,我们需要牢记,如果使用的状态不属于 cell 本身,那么它们是可能会在多个 cell 之间共享的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ContentView : View {
@State var show: Bool = false
var body: some View {
NavigationView {
List(0..<10) { i in
NavigationLink(
destination: Text("Detail \(i)"),
isActive: self.$show)
{
Text("Cell \(i)")
}
}
}
}
}

当你点击某个 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
2
3
4
5
6
7
8
9
10
11
frame(width:height:alignment:)

func frame(
minWidth: CGFloat? = nil,
idealWidth: CGFloat? = nil,
maxWidth: CGFloat? = nil,
minHeight: CGFloat? = nil,
idealHeight: CGFloat? = nil,
maxHeight: CGFloat? = nil,
alignment: Alignment = .center
) -> some View

和固定宽高的版本不同,这个方法为尺寸定义了一套约束:如果从父 View 中获得的提案尺寸小于 minXXX 或者大于 maxXXX,这个 frame 将会把这个提案尺寸截取到相应的最小值或者最大值,然后进行提案。
frame 方法的两种版本里,所有的参数都有默认值 nil,如果你使用这个默认值,那么 frame 将不在这个方向上改变原有的尺寸提案,而是将它直接传递给子 View。

frame 方法的最后一个参数表示所使用的对齐方式。不过,很多时候单纯地改变这个对齐方式不会有任何效果:

1
2
3
4
5
HStack {
//...
}
.frame(alignment: .leading)
.background(Color.purple)

因为这个 alignment 指定的是 frame View 中的内容在其内部的对齐方式,如果不指定宽度或者高度,那么 frame 的尺寸将完全由它的内容决定。换言之,内容都已经占据了 frame 的全部空间,不论采用哪种方式,内容在 frame 里都是“贴边的”。对齐也就没有任何意义了。想要体现和实验 frame 里的对齐方式,可以为 frame 添加一个多余内容所需空间的尺寸参数:

1
2
3
4
5
HStack {
//...
}
.frame(width: 300, alignment: .leading)
.background(Color.purple)

隐式对齐和显式对齐

alignmentGuide 所做的事情,是负责修改 g (HorizontalAlignment 或者 VerticalAlignment) 的对齐方式,把原来的 defaultValue(in:) 所提供的默认值,用 computeValue 返回的 CGFloat 值进行替代。

1
2
3
4
5
6
HStack {
Image(systemName: "person.circle")
Text("User:")
.font(.footnote)
Text("onevcat | Wei Wang")
}

代码相当于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HStack(alignment: .center) {
Image(systemName: "person.circle")
.alignmentGuide(VerticalAlignment.center) { d in
d[VerticalAlignment.center]
}
Text("User:")
.font(.footnote)
.alignmentGuide(VerticalAlignment.center) { d in
d[VerticalAlignment.center]
}
Text("onevcat | Wei Wang")
.alignmentGuide(VerticalAlignment.center) { d in
d[VerticalAlignment.center]
}
}

注意,只有当 alignmentGuide 的第一个参数 VerticalAlignment.center 和外层容器 HStackalignment 参数一致时,它才会被考虑。因为 alignmentGuide API 的作用就是修改传入的 alignment 的数值。

自定义 Alignment 和跨 View 对齐

新建对齐的最主要目的,还是为了跨越 View 的层级来进行对齐。