使用Binding创建符合SwiftUI设计理念的组件
SwiftUI中的Binding
为我们提供了双向绑定的功能,来确保View内部与外部的数据具备一致性,并且在系统组件中也有很多的应用。除了最基本的在TextField
中使用Binding<String>
来传递字符串数据、在Toggle
中使用Binding<Bool>
来记录当前开关状态,还有其他很多使用,比如在Picker
中使用Binding<SelectionValue:Hashable>
来记录当前选中项、在TabView
中使用Binding<SelectionValue:Hashable>?
来决定当前哪个TabItem是在屏幕上活跃的、为拓展Present视图的sheet
方法中的Binding<Item:Identifiable?>
等等。
在最开始使用SwiftUI构建功能组件的时候,往往会沿用在UIKit中的构建思路。编写的组件虽说也是可以使用,但是不论在api的简洁性上,还是在具体实现上都与系统提供的差一些意思。虽然SwiftUI为我们提供的组件有很多,但是细看归类还是可以发现大部分还是有一定设计规范的,比如数据传递中对Binding的运用,从上面列举的一部分就可以发现在某些场景下会使用对应的模式。本文会对系统提供的Binding进行应用场景的归类,接着结合两个具体的开发实例来讨论下如何编写符合SwiftUI设计理念的组件。
Binding
这部分内容是从https://fivestars.blog/swiftui/swiftui-patterns-bindings.html 这篇文章中总结出来的,具体内容可以参考链接文章。
在SwiftUI中,对Binding的应用可以分为如下图中的几种类型,从常用的对String、Bool类型的绑定,到可选绑定等等。这些对Binding的应用中有些还会使用protocol来限制数据类型,使用何种协议一般都是与具体控件的含义有关联的。
借用文章中的话总结一下在SwiftUI中使用Binding的一些规范:
- 使用
value:Binding<V>
来绑定需要持续记录的数据,比如Stepper- 特殊的,还可以为V指定某些protocol来约束使用,比如上面的Slider、TextField
text: Binding<String>
算是一种的特殊情况,限制了需要绑定的类型为String,主要用在具有文字输入的组件中,比如TextField、SecureField、TextEditor
selection: Binding<...>
主要应用在组件有子组件可被选择的时候- 可以使用protocol来约束被绑定的数据类型,并且会提供一个叫做
SelectionValue
的泛型,比如Picker中的Binding<SelectionValue: Hashable>
- 当可被选择的类型可以确定的时候,直接使用类型,其实是上一种情况的特例,比如ColorPicker、DatePicker
- 选择的元素还可以被取消,这时候就需要绑定一个可选类型的数据,比如NavigationLink中的
Binding<V?>
- 甚至有时候都不一定需要绑定,这时候整个Binding都是可选类型的,比如TabView中的
Binding<SelectionValue>?
- 上面说的是只有一个可选项,当有多个可选项的时候,可以使用集合来作为数据类型,比如List中的
Binding<Set<SelectionValue>>
- 甚至多个可选项的时候,整个Binding都是可选类型的,比如List中的
Binding<Set<SelectionValue>>?
- 可以使用protocol来约束被绑定的数据类型,并且会提供一个叫做
is...: Binding<Bool>
用来记录yes、no两种情况,他们都有一个共同点,参数以is
开始,拼接组件对应的状态,比如Toggle中的isOn: Binding<Bool>
- 在SwiftUI中没有任何一个组件会有两个Binding的参数
Hashlabe
假如我们需要根据Toggle来实现一个具有互斥功能的开关组,很容易想到的方案是自定义Binding的get
与set
方法,在内部做逻辑上的互斥,然后根据使用@State标记的布尔值更新Toggle。
1 | @State private var option_one = true |
这样确实是实现了具有互斥功能的需求,但却会导致拓展性很差,并且很多重复逻辑也导致代码非常冗余,这时候我们就需要考虑下使用一种更优雅的方法来实现互斥需求。
我们知道,TabView
是SwiftUI中具有互斥功能的系统组件,我们可以借鉴它来优化Toggle的互斥方案。
TabView可以使用selection
来记录哪个TabItem被选中,通过头文件我们知道,它是Binding<SelectionValue>
类型的,而SelectionValue
又必须遵守Hashable
协议。Hashable就表示具有唯一性,TabView就是根据这个特性来决定选中TabItem的,这个也正好可以应用到我们具有互斥功能的Toggle中。
1 | struct TabView<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View { |
一个使用TabView的例子如下,我们为TabView提供了三个界面,并使用一个enum来作为每个界面的可选tag,默认会选中第三个界面(View C):
1 | enum TabItemOption: Hashable { |
虽然我们不知道在TabView中是如何根据tag、selection来决定选中哪一个TabItem的,但是我们可以借鉴这种形式来拓展Toggle。类似的,我们期望为每一个Toggle设置一个tag,并且tag是具有Hashable特性的,让具有互斥性的多个Toggle共用同一个isOn(Binding<Bool>
类型),至于具体的互斥逻辑还是上面我们一开始实现的逻辑。具体实现如下:
1 | extension Toggle { |
接下来我们来使用拓展的Toggle,使用具有Hashable的枚举来区分每个Toggle。
1 | enum ToggleOption: Hashable { |
然后使用我们拓展的初始化方法,创建成组的Toggle,这次我们只需要一个使用State标记的变量即可,这极大的简化了我们的业务逻辑。
1 | @State private var selectionOption: ToggleOption? = nil |
Optional
Binding为我们提供了双向绑定,确保外部、内部数据的状态是一致的,比如Toggle中使用Binding<Bool>
来同步开关的状态。但有些时候外部可能并不关心数据的变动,举一个不恰当的例子就是Toggle组件的开关状态不与外部同步,这种情况下,Toggle初始化的时候就不需要isOn
参数。在SwiftUI中DisclosureGroup有两个初始化方法,一个需要传递Binding<Bool>
将状态与外部同步,一个不需要传递:
1 | struct DisclosureGroup<Label, Content> : View where Label : View, Content : View { |
接下来我们借助这种形式实现一个组件,提供需要Binding以及不需要Binding的两种初始化方法。
假如我们有一个为图片增加Blur效果的组件BlurImageView,可以通过点击实现图片在正常与Blur之间切换,我们可以很容易写出如下代码。
1 | struct BlurImageView: View { |
该组件需要外部提供图片名称,以及当前是否处于Blur效果的Binding<Bool>
绑定,因此我们只需要在业务中如下编写代码,使用@State提供需要的Binding即可。
1 | @State private var isBlur: Bool = false |
如我们前面所说,有些情况下,我们使用该组件的时候并不关心它是否处于Blur状态下,换句话说就是,BlurImageView的isBlur状态只需要在内部维护就好。虽然我们可以创建一个@State,放着不用,但是这样并不优雅。我们需要的是一个专门用于这种应用场景的方案,是一个不需要传递Binding的初始化方法,比如:
1 | var body: some View{ |
但由于我们在BlurImageView内部需要记录是否处于Blur状态,所以必定需要一个source of truth
,在前面它是从外部的$isBlur
提供的,如果外部不提供,我们就需要自己在内部提供一个,同时还需要创建一个相应的init方法:1
2
3
4
5
6
7
8struct BlurImageView: View {
@State private var innerIsBlur: Bool = true
// ...
init(imageName: String) {
self.imageName = imageName
}
// ...
}
但这有个问题,我们的BlurImageView中的isBlur
变量是必须初始化的,这时候我们可以多添加一层,将接口部分放到容器。我们将核心方法放入私有组件中,提供一个与示例中一样的容器View来承载是否需要外部Binding的功能,并为容器组件提供多个初始化方法: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
42struct BlurImageView: View {
// 内部私有的source of truth
@State private var innerIsBlur: Bool = true
private var imageName: String
private var isBlur: Binding<Bool>?
init(imageName: String) {
self.imageName = imageName
}
init(imageName: String, isBlur: Binding<Bool>) {
self.imageName = imageName
self.isBlur = isBlur
}
var body: some View{
Inner(
imageName: imageName,
isBlur: isBlur ?? $innerIsBlur// 如果外部没有提供Binding,就使用内部的innerIsBlur生成的Binding
)
}
private struct Inner: View {
var imageName: String
var isBlur: Binding<Bool>
init(imageName: String, isBlur: Binding<Bool>) {
self.imageName = imageName
self.isBlur = isBlur
}
var body: some View {
Image(imageName)
// 省略一些修饰代码
.blur(radius: isBlur.wrappedValue ? 10 : 0)
.onTapGesture {
withAnimation { self.isBlur.wrappedValue.toggle() }
}
}
}
}
这样一来,我们就可以灵活的使用我们的BlurImageView了。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18@State private var isBlur: Bool = false
var body: some View{
VStack(spacing: 20){
// 使用内部私有的Binding
BlurImageView(imageName: "gods")
VStack{
// 使用外部提供的Binding
BlurImageView(imageName: "gods", isBlur: $isBlur)
Button("Make Image \(!isBlur ? "Blur" : "Normal")") {
withAnimation { isBlur.toggle() }
}
}
}
}
参考文章
- 参考文章列表