学计算机的那个

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

0%

Swift花园笔记 - 八个常见的SwiftUI误用及对应的正确打开方式

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

直接把八条误用先简明扼要地罗列如下,然后我们逐条深入展开:

  1. 添加不必要的 View 和 Modifier
  2. 在需要用 @StateObject 的地方用了 @ObservedObject
  3. Modifier 顺序错误
  4. 给属性包装器添加属性观察者
  5. 在需要用描边框的地方使用了描形状
  6. Alert 和 Sheet 与可选状态的使用
  7. 尝试改变 SwiftUI 视图后面的东西
  8. 用错误的范围动态创建视图

添加不必要的 View 和 Modifier

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

Rectangle() .fill(Color.red)

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

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

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

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

在需要用 @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

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

@StateObject model = DataModel()

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

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

Modifier 顺序错误

最经典的例子是 padding 和 background 的使用,如下:

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)

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

某些情况下你可能会为属性包装器添加诸如 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 的扩展来返回新的绑定,其中的 get 和 set 包装的值和之前一样,但是在新值得到时也会调用处理函数:

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)")
}
}

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

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

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

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

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

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)

Alert 和 Sheet 与可选状态的使用

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)"))
}
}
}

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

尝试改变 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()

动态视图的范围参数错误

有多个 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)")
}

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