使用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>>?
  • is...: Binding<Bool>用来记录yes、no两种情况,他们都有一个共同点,参数以is开始,拼接组件对应的状态,比如Toggle中的isOn: Binding<Bool>
  • 在SwiftUI中没有任何一个组件会有两个Binding的参数

Hashlabe

假如我们需要根据Toggle来实现一个具有互斥功能的开关组,很容易想到的方案是自定义Binding的getset方法,在内部做逻辑上的互斥,然后根据使用@State标记的布尔值更新Toggle。

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
@State private var option_one = true
@State private var option_two = false
@State private var option_three = false

let binging_one = Binding<Bool>(
get: { return option_one },
set: {
self.option_one = $0
if $0 {
self.option_two = false
self.option_three = false
}
}
)
let binging_two = Binding<Bool>(
get: { return option_two },
set: {
self.option_two = $0
if $0 {
self.option_one = false
self.option_three = false
}
}
)
let binging_three = Binding<Bool>(
get: { return option_three },
set: {
self.option_three = $0
if $0 {
self.option_one = false
self.option_two = false
}
}
)

var body: some View {
Toggle("Option one", isOn: binging_one)

Toggle("Option two", isOn: binging_two)

Toggle("Option three", isOn: binging_three)
}

这样确实是实现了具有互斥功能的需求,但却会导致拓展性很差,并且很多重复逻辑也导致代码非常冗余,这时候我们就需要考虑下使用一种更优雅的方法来实现互斥需求。

我们知道,TabView是SwiftUI中具有互斥功能的系统组件,我们可以借鉴它来优化Toggle的互斥方案。

TabView可以使用selection来记录哪个TabItem被选中,通过头文件我们知道,它是Binding<SelectionValue>类型的,而SelectionValue又必须遵守Hashable协议。Hashable就表示具有唯一性,TabView就是根据这个特性来决定选中TabItem的,这个也正好可以应用到我们具有互斥功能的Toggle中。

1
2
3
4
struct TabView<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {

public init(selection: Binding<SelectionValue>?, @ViewBuilder content: () -> Content)
}

一个使用TabView的例子如下,我们为TabView提供了三个界面,并使用一个enum来作为每个界面的可选tag,默认会选中第三个界面(View C):

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
enum TabItemOption: Hashable {
case one, two, three
}

@State private var selectionOption: TabItemOption = .three

var body: some View {

TabView(selection: $selectionOption) {
PageView(text: "View A", color: .orange)
.tabItem {
Text("A")
}
.tag(TabItemOption.one)

PageView(text: "View B", color: .green)
.tabItem {
Text("B")
}
.tag(TabItemOption.two)

PageView(text: "View C", color: .pink)
.tabItem {
Text("C")
}
.tag(TabItemOption.three)
}
}

虽然我们不知道在TabView中是如何根据tag、selection来决定选中哪一个TabItem的,但是我们可以借鉴这种形式来拓展Toggle。类似的,我们期望为每一个Toggle设置一个tag,并且tag是具有Hashable特性的,让具有互斥性的多个Toggle共用同一个isOn(Binding<Bool>类型),至于具体的互斥逻辑还是上面我们一开始实现的逻辑。具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
extension Toggle {
init<T: Hashable>(
isOn: Binding<T?>,
tag: T,
@ViewBuilder label: () -> Label
){

let selectionValue: Binding<Bool> = Binding(
get: {
isOn.wrappedValue == tag
},
set: {
if $0 {
isOn.wrappedValue = tag
} else {
isOn.wrappedValue = nil
}
})

self.init(isOn: selectionValue, label: label)
}
}

接下来我们来使用拓展的Toggle,使用具有Hashable的枚举来区分每个Toggle。

1
2
3
enum ToggleOption: Hashable {
case one, two, three
}

然后使用我们拓展的初始化方法,创建成组的Toggle,这次我们只需要一个使用State标记的变量即可,这极大的简化了我们的业务逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@State private var selectionOption: ToggleOption? = nil

var body: some View {

Toggle(isOn: $selectionOption, tag: ToggleOption.one) {
Text("Option one")
}

Toggle(isOn: $selectionOption, tag: ToggleOption.two) {
Text("Option two")
}

Toggle(isOn: $selectionOption, tag: ToggleOption.three) {
Text("Option three")
}
}

Optional

Binding为我们提供了双向绑定,确保外部、内部数据的状态是一致的,比如Toggle中使用Binding<Bool>来同步开关的状态。但有些时候外部可能并不关心数据的变动,举一个不恰当的例子就是Toggle组件的开关状态不与外部同步,这种情况下,Toggle初始化的时候就不需要isOn参数。在SwiftUI中DisclosureGroup有两个初始化方法,一个需要传递Binding<Bool>将状态与外部同步,一个不需要传递:

1
2
3
4
5
6
struct DisclosureGroup<Label, Content> : View where Label : View, Content : View {

init(@ViewBuilder content: @escaping () -> Content, @ViewBuilder label: () -> Label)

init(isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> Content, @ViewBuilder label: () -> Label)
}

接下来我们借助这种形式实现一个组件,提供需要Binding以及不需要Binding的两种初始化方法。

假如我们有一个为图片增加Blur效果的组件BlurImageView,可以通过点击实现图片在正常与Blur之间切换,我们可以很容易写出如下代码。

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

private var imageName: String
private 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()
}
}
}
}

该组件需要外部提供图片名称,以及当前是否处于Blur效果的Binding<Bool>绑定,因此我们只需要在业务中如下编写代码,使用@State提供需要的Binding即可。

1
2
3
4
5
@State private var isBlur: Bool = false

var body: some View{
BlurImageView(imageName: "gods", isBlur: $isBlur)
}

如我们前面所说,有些情况下,我们使用该组件的时候并不关心它是否处于Blur状态下,换句话说就是,BlurImageView的isBlur状态只需要在内部维护就好。虽然我们可以创建一个@State,放着不用,但是这样并不优雅。我们需要的是一个专门用于这种应用场景的方案,是一个不需要传递Binding的初始化方法,比如:

1
2
3
var body: some View{
BlurImageView(imageName: "gods")
}

但由于我们在BlurImageView内部需要记录是否处于Blur状态,所以必定需要一个source of truth,在前面它是从外部的$isBlur提供的,如果外部不提供,我们就需要自己在内部提供一个,同时还需要创建一个相应的init方法:

1
2
3
4
5
6
7
8
struct 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
42
struct 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() }
}
}
}
}

参考文章

  • 参考文章列表