八个常见的 SwiftUI 误用及对应的正确打开方式

zdenek-machacek-pqYGHW0_od0-unsplash

译自 https://www.hackingwithswift.com/articles/224/common-swiftui-mistakes-and-how-to-fix-them

欢迎关注微信公众号「Swift 花园」

SwiftUI 是一个庞大且复杂的框架。使用这个框架编程无疑是享受的,但犯错的机会也不少见。这篇文章我将带大家速览 SwiftUI 初学者常犯的一些错误,并提供修正方案。

其中的一些错误是由于简单的误解导致。由于 SwiftUI 太大,这种情况其实容易出现。而另一些错误则与深入理解 SwiftUI 的工作方式有关,还有一些是原有的思维方式导致 —— 你可能花了很多时间编写 view 和 modifier,但没有想到用 SwiftUI 的方式简化结果。

开门见山,你不需要猜我会给你准备什么菜,这里我直接把八条误用先简明扼要地罗列如下,然后我们逐条深入展开:

  1. 添加不必要的 View 和 Modifier

  2. 在需要用 @StateObject 的地方用了 @ObservedObject

  3. Modifier 顺序错误

  4. 给属性包装器添加属性观察者

  5. 在需要用描边框的地方使用了描形状

  6. Alert 和 Sheet 与可选状态的使用

  7. 尝试改变 SwiftUI 视图后面的东西

  8. 用错误的范围动态创建视图


1 添加不必要的 View 和 Modifier

让我们从最常见的一个误用开始,它会让我们编写更多的 SwiftUI 代码。这种误用的部分原因通常是我们在解决问题时编写了许多代码,但是最后忘记整理代码。还有的时候,则是旧习惯作祟,尤其是当编写者是从 UIKit 或者其他 UI 框架转到 SwiftUI 上。

比如,你可能希望用一个红色矩形填满屏幕?然后你像下面这样编写代码:

1
2
Rectangle()
.fill (Color.red)

的确,上面的代码可以工作 —— 它能准确地得到你想要的效果。但是其中一半的代码是不必要的,因为你只需要像下面这样写也能实现一样的效果:

1
Color.red

这是因为在 SwiftUI 中,所有的颜色和形状都自动遵循了 View 协议,你可以把它们直接当成视图来使用。

你可能也会经常看见形状裁切,因为为了实现特定形状,应用 clipShape () 是件很自然的事情。例如,可以像下面这样让我们的红色矩形拥有圆角:

1
2
Color.red
.clipShape (RoundedRectangle(cornerRadius: 50))

但这也是不要的 —— 借助 cornerRadius () modifier,代码可以简化如下:

1
2
Color.red
.cornerRadius (50)

移除这类的冗余代码需要时间,因为你需要转变思维习惯,这一点对于 SwiftUI 的初学者来说更加困难。 因此,假如你一开始采用了这些更长版本的代码,不必担忧,多加训练。

2 在需要用 @StateObject 的地方用了 @ObservedObject

SwiftUI 提供了众多属性包装器,帮助我们构建数据响应式的用户界面,其中最重要的当属 @State@StateObject@ObservedObject。掌握它们的使用场景非常重要,因为误用它们会给你的代码带来各种问题。

第一个比较直接:@State 用于值类型属性,并且属性由当前视图拥有。因此,整数,字符串,数组等,都是应用 @State 的绝佳场景。

后两者则有点令人困惑,你可能会经常看到下面这样的代码:

1
2
3
4
5
6
7
8
9
10
11
class DataModel: ObservableObject {
@Published var username = "@twostraws"
}

struct ContentView: View {
@ObservedObject var model = DataModel()

var body: some View {
Text(model.username)
}
}

可以明确的说,这么做是错误的,并且极有可能在你的应用中带来问题。

译者注:基于代码片段说这样写一定是错误的,这个表述是不严谨的。作者应该是隐含假设了 ContentView 是应用的顶级视图(通常来说,如果你不改工程模板的默认输出,ContentView 也确实是顶级视图)。对于顶级视图来说,SwiftUI 2.0 应当使用 @StateObject ,它是为了解决 @ObservedObject 或者 @EnvironmentObject 对象的所有权问题。但是对于附属于顶级视图的视图层级,各子视图的数据源可以是 @ObservedObject 或者 @EnvironmentObject,因为它们的生命周期受顶级视图管理,进而可以由顶级视图统一保证数据的可用性。

正如我前面说到的,@State 表示某个值类型属性由当前视图拥有,这里的 “拥有” 很重要。而 @StateObject 则相当于引用类型版本的 @State

因此,上面的代码应该改成这样:

1
@StateObject model = DataModel()

当你使用 @ObservedObject 来创建某个对象实例时,你的视图并不拥有这个对象实例,也就是说,这个实例可以在任何时候被销毁(译者注:视图无法了解也无法干预这个时机)。狡猾的是,对象在视图还需要用它时被销毁的情况只是偶尔发生,所以你可能认为你的代码很完美。

需要记住的重点是 @State@StateObject 表示 “视图拥有数据”,而 @ObservedObject@EnvironmentObject 则没有。

3 Modifier 顺序错误

Modifier 的顺序在 SwiftUI 中至关重要。顺序错误不仅会导致布局在上视觉上的偏差,也会导致其行为的错误。

解释这个问题最经典的例子是 paddingbackground 的使用,如下:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.background (Color.green)
.padding ()

由于我们在 background 颜色之后应用 padding,颜色只会被直接应用在文本周围,而不是被添加留白之后的文本周围。如果你希望留白和文本背景都是绿色,应该将代码改成下面这样:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.padding ()
.background (Color.green)

当你尝试调整视图位置时,这个原理会让事情变得更有趣。

例如,offset () modifier 会修改一个视图被渲染的位置,但并不实际改变视图的位置。也就是说,应用在 offset 之后的 modifier 表现得就像 offset 从未发生过。

尝试下面的代码:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.offset (x: 15, y: 15)
.background (Color.green)

你会发现文本偏移了,但背景颜色没有偏移。现在,尝试交换 offset ()background () 的位置:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.background (Color.green)
.offset (x: 15, y: 15)

现在你会看到文本和背景都移动了。

另外,position () modifier 会改变一个视图在其父节点中的渲染位置,但这一点是借助它先在视图周围应用一个可伸展尺寸的 frame 来实现的。

尝试下面的代码:

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.background (Color.green)
.position (x: 150, y: 150)

你会发现背景颜色紧贴在文本四周,并且整个视图被放置在左上角。现在,尝试对调 background ()position ()

1
2
3
4
Text("Hello, World!")
.font (.largeTitle)
.position (x: 150, y: 150)
.background (Color.green)

这一回你会发现整个屏幕都变成绿色了。还是因为 position () 要求 SwiftUI 放置一个可伸缩尺寸的 frame 在文本视图周围,这导致视图自动占满了所有的可用空间。然后我们给视图上了绿色,所以整个屏幕呈现绿色。

你所应有的绝大多数 modifier 都创建了新视图 —— 应用一个 position 或者 background 时,你实际上是在将现有的视图包装起来。这个机制对于我们大有用处,我们可以多次应用 modifier,比如添加多层留白和背景:

1
2
3
4
5
6
Text("Hello, World!")
.font (.largeTitle)
.padding ()
.background (Color.green)
.padding ()
.background (Color.blue)

或者应用多个 shadows 以创建很深的阴影效果:

1
2
3
4
5
6
Text("Hello, World!")
.font (.largeTitle)
.foregroundColor (.white)
.shadow (color: .black, radius: 10)
.shadow (color: .black, radius: 10)
.shadow (color: .black, radius: 10)

4 给属性包装器添加属性观察者

某些情况下你可能会为属性包装器添加诸如 didSet 这样的属性观察者,但它不会如你预期的那样工作。

例如,如果你在使用滑块,希望在滑块值改变时执行某种动作,你可能会下面这样编写代码:

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
@State private var rating = 0.0 {
didSet {
print("Rating changed to \(rating)")
}
}

var body: some View {
Slider(value: $rating)
}
}

但是,这个 didSet 属性观察者永远都不会被调用,因为属性的值是由绑定直接修改的,而不是每次创建一个新值。

对此,SwiftUI 原生的方式是使用 onChange () modifier,如下:

1
2
3
4
5
6
7
8
9
10
struct ContentView: View {
@State private var rating = 0.0

var body: some View {
Slider(value: $rating)
.onChange (of: rating) { value in
print("Rating changed to \(value)")
}
}
}

不过,我个人更喜欢一种不同的方案:我使用基于 Binding 的扩展来返回新的绑定,其中的 getset 包装的值和之前一样,但是在新值得到时也会调用处理函数:

1
2
3
4
5
6
7
8
9
10
11
extension Binding {
func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in
self.wrappedValue = newValue
handler (newValue)
}
)
}
}

有了这个扩展,我们就可以把绑定的动作直接附着在滑块视图上:

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
@State private var rating = 0.0

var body: some View {
Slider(value: $rating.onChange (sliderChanged))
}

func sliderChanged(_ value: Double) {
print("Rating changed to \(value)")
}
}

挑选最适合你的方案。

5 在需要用描边框的地方使用了描形状

不理解 stroke ()strokeBorder 的区别是初学者常犯的错误。尝试下面的代码:

1
2
Circle()
.stroke (Color.red, lineWidth: 20)

注意看,你会发现圆的左边缘和右边缘怎么不见了?(译者:这里预设你是竖屏运行程序,高度大于宽高)这是因为 stroke () modifier 会把描边居中对齐在形状的轮廓线上,所以一个 20 个点的红色描边会绘制 10 个点到形状的边缘线外部,10 个点在边缘线内部 —— 这就导致了你看到圆形的左右超出屏幕的现象。

作为对照,strokeBorder () 则是把整个描边都绘制在形状内部,所以它不会放大形状的边框。

1
2
Circle()
.strokeBorder (Color.red, lineWidth: 20)

相比于使用 strokeBorder (),使用 stroke () 有一个好处是它返回的是一个新形状,而不是一个新视图。这使得你可以创建出某些本来难以实现的效果,比如给一个形状描两次边:

1
2
3
4
Circle()
.stroke (style: StrokeStyle(lineWidth: 20, dash: [10]))
.stroke (style: StrokeStyle(lineWidth: 20, dash: [10]))
.frame (width: 280, height: 280)

6 Alert 和 Sheet 与可选状态的使用

当你在学习使用 sheet 和可选型的时候,很容易想到把 sheet 的展示绑定到像下面这样的 Boolean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct User: Identifiable {
let id: String
}

struct ContentView: View {
@State private var selectedUser: User?
@State private var showingAlert = false

var body: some View {
VStack {
Button("Show Alert") {
selectedUser = User(id: "@twostraws")
showingAlert = true
}
}
.alert (isPresented: $showingAlert) {
Alert(title: Text("Hello, \(selectedUser!.id)"))
}
}
}

当然,这可以正确工作 —— 而且这个方案容易理解。但是一旦你越过了初级阶段,你就应当考虑换成可选型的实现方案。这个方案去掉了 Boolean,也不必强制解包。唯一的要求是你所监听的目标需要遵循 Identifiable

举个例子,我们可以在 selectedUser 发生变化的任何时候展示警告弹窗,就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ContentView: View {
@State private var selectedUser: User?

var body: some View {
VStack {
Button("Show Alert") {
selectedUser = User(id: "@twostraws")
}
}
.alert (item: $selectedUser) { user in
Alert(title: Text("Hello, \(user.id)"))
}
}
}

这会使得你的代码更加易于读写,并且避免因为强制解包可能带来的麻烦。

7 尝试改变 SwiftUI 视图后面的东西

SwiftUI 初学者最常犯的一个错误是他们常常试图去改变 SwiftUI 视图的背景。代码通常长下面这个样子:

1
2
3
4
5
6
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.background (Color.red)
}
}

这会展示一个白色的屏幕,中间是红色背景色只匹配文本区域的文本视图。而大多数人的本意其实是想让整个屏幕的背景呈现出红色。这个时候他们会想,SwiftUI 背后究竟是什么样的 UIKit 视图呢?

当然,背后肯定是有一个 UIKit 视图,它由 UIHostingController 管理,角色类似于一个 UIKit 视图控制器。但是假如你通过 SwiftUI 试图去踏足 UIKit 的领地,你的改动很可能会让 SwiftUI 呈现出奇怪的结果,或者你甚至都没法直接改动 UIKit。

实际上,想到达成大多数人想要的效果,SwiftUI 里的实现方式应该是像下面这样的:

1
2
3
4
Text("Hello, World!")
.frame (maxWidth: .infinity, maxHeight: .infinity)
.background (Color.red)
.ignoresSafeArea ()

8 动态视图的范围参数错误

有多个 SwiftUI 视图的构造器允许我们传入范围,这个事实让许多复杂视图的创建过程变得十分简单。

例如,假设我们想要展示一个拥有 4 个项目的列表,我们只需要这样写:

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
@State private var rowCount = 4

var body: some View {
VStack {
List(0..<rowCount) { row in
Text("Row \(row)")
}
}
}
}

这样写本身没问题,不过一旦你需要在运行时改变范围时,问题就来了。你看我已经用 @State 属性包装器把想要改变的行数变成可修改的,所以我们可以用一个按钮来修改它的值:

1
2
3
4
Button("Add Row") {
rowCount += 1
}
.padding (.top)

运行代码,点击按钮,Xcode 调试输出会输出警告,而列表视图纹丝不动 —— 这个方案不管用。

问题出在你既没有为列表的参数提供 Identifiable 协议实现,也没有提供指定的 id 参数,以此告诉 SwiftUI 这个范围会动态变化:(译者:实际上并不是 “告诉 SwiftUI 范围会动态变化”,而是明确范围的项怎样才算变化。Identifiable 或者 id 参数明确了两个项之间是如何区别。能够区别开的项目才能侦测变化)。

1
2
3
List(0..<rowCount, id: \.self) { row in
Text("Row \(row)")
}

代码改成这样就没问题了。


封面来自 Zdeněk Macháček on Unsplash

Linkedin
Plus
Share
Class
Send
Send
Pin