关于 SwiftUI 的初步认识

Posted by Phillip Song on 2019-06-24

SwiftUI 可以在任何 Apple 的平台上用新颖的方式声明用户界面,更简洁快速的创建应用。通过几天的使用,大概对 SwiftUI 有了简单的认识, 下面将简单介绍下 SwiftUI 的特点与使用实例。

Overview

声明式的语法

声明式编程(Declarative programming)是一种编程范式,与命令式编程相对立。 它描述目标的性质,让计算机明白目标,而非流程。 声明式编程不用告诉计算机问题领域,从而避免随之而来的副作用。

而命令式编程则需要用算法来明确的指出每一步该怎么做。

SwiftUI 使用了声明式的语法,所以开发者能够十分轻易地描述用户界面应该做什么。例如,编写需要包含文本字段的项目列表时,开发者可以用代码描述每个字段的对齐方式、字体和颜色。代码也比以前更简单,更易于阅读。而 SwiftUI 会根据状态的变化来重新渲染相匹配的视图。

1
2
3
4
5
6
7
8
9
10
11
12
List(landmarks) { landmark in
HStack {
Image(landmark.thumbnail)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
}

describe_ui

可重用的组件

复杂的界面是由轻量的单一职责的视图构成的。你可以轻易的创建自定义视图在 Apple 的各个平台中共享使用,达到重用的目的。

1
2
3
4
5
6
7
8
9
10
struct FeatureCard: View {
var landmark: Landmark

var body: some View {
landmark.featureImage
.resizable()
.aspectRatio(3/2, contentMode: .fit)
.overlay(TextOverlay(landmark))
}
}

reusable_component

简洁的动画使用

只需要添加一个简单的方法调用就可以创建平滑的动画。 SwiftUI 会在需要的时候自动的计算并设置过场动画。

1
2
3
4
5
6
7
8
VStack {
Badge()
.frame(width: 300, height: 300)
.animation(.basic())
Text(name)
.font(.title)
.animation(Animation.basic().delay(0.2))
}

simplify_animations

Xcode 中实时预览

不用运行应用就可以设计,构建和测试你的应用,通过互动预览就可以测试控件以及布局。

live_preview

看完了上述 SwiftUI 的特点,现在让我们深入了解下它是如何工作的。

什么是 View

在 SwiftUI 中,View 跟我们之前用到的 UIView 多少有点类似,但有两个大的不同点是:

  1. View 是协议类型,代表了屏幕上元素的描述。
  2. 只能返回一个需要渲染的视图。视图可以内部包含其它多个视图,但是只能返回其父视图。

SwiftUI 使用的具体实例:

1
2
3
4
5
struct ContentView: View {
var body: some View {
Text("Hello World")
}
}

也许你也注意到了例子中 body 的返回值类型 some View, 让我们具体看看 View 协议的定义来理解这么做的原因。

1
2
3
4
public protocol View : _View {
associatedtype Body : View
var body: Self.Body { get }
}

这种带有 Self 或者 associatedtype 的 protocol 不能作为类型来使用,只能作为类型约束来使用。这样的话我们就不能这样写:

1
2
3
4
5
struct ContentView: View {
var body: View {
Text("Hello World")
}
}

而在 Swift 5.1 新增加的特性中,Opaque Result Type 为 protocol 作为返回类型提供以下能力:

  1. 语法上隐藏具体实现
  2. 强类型,类型参数不丢失
  3. 允许带有 Self 或者 associatedtype 的 protocol 作为返回类型

在 Swift 5.1 中,将返回类型改成 some protocol 的形式, 它向编译器做出保证,每次 body 得到的一定是某一个确定的,遵守 View 协议的类型,而编译器可以自己推断出具体类型。 在协议之前加上 some 后,返回值的类型就对编译器就变成透明的了。

而且,编译器会检查返回类型是否确定单一,因此下面这种方式也是不行的:

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
var body: View {
if somecondition {
return Text("Hello World")
} else {
return Button(action: {}) {
Text("Tap me")
}
}
}
}

视图容器

VStack / HStack / ZStack

当需要渲染多个视图时,我们可以将多个视图组合在 HStack, ZStackZStack 或者 Group 这种视图容器中。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct HeaderView: View {
let image: UIImage
let title: String
let subtitle: String

var body: some View {
VStack {
Image(uiImage: image)
Text(title)
Text(subtitle)
}
}
}

这里调用了 VStack 的初始化方法:

1
2
3
4
5
6
7
public struct VStack<Content : View> : View { 
public init(
alignment: HorizontalAlignment = .center,
spacing: Length? = nil,
@ViewBuilder content: () -> Content
)
}

初始化方法接收传入的 alignmentspacing 参数来控制容器内视图的排列和间距,同时接收一个尾随闭包 () -> Content 来控制容器视图的具体内容。

你可能也注意到闭包参数是由 @ViewBuilder 修饰的,这里就需要提下 Swift 5.1 的 function builder 特性。

function builder 允许使用闭包实现 Builder 模式,通过将此闭包中定义的表达式传递给构建器类型,提供类似 DSL 的开发体验。而如果没有 function builder 这个功能,我们必须手动创建构建器,以便构建像 VStack 这样的容器实例,为我们提供看起来像这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct HeaderView: View {
let image: UIImage
let title: String
let subtitle: String

var body: some View {
var builder = VStackBuilder()
builder.add(Image(uiImage: image))
builder.add(Text(title))
builder.add(Text(subtitle))
return builder.build()
}
}

那么 function builder 是如何工作的?这一切都始于新的 @functionBuilder 属性(或当前版本的 Xcode beta 中的 @_functionBuilder,因为此功能仍被视为私有实现) - 它将给定类型标记为构建器。

构建器声明 buildBlock 方法的不同重载,以便为包含各种表达式的闭包提供支持。例如,这里是 SwiftUI 自己的 ViewBuilder 类型的实现,其中有很多接受不同个数参数的 buildBlock 方法,它们将负责将闭包中一一列举的 Text 和其他可能的 View 转换为一个 TupleView 并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@functionBuilder
struct ViewBuilder {
// Build a value from an empty closure, resulting in an
// empty view in this case:
func buildBlock() -> EmptyView {
return EmptyView()
}

// Build a single view from a closure that contains a single
// view expression:
func buildBlock<V: View>(_ view: V) -> some View {
return view
}

// Build a combining TupleView from a closure that contains
// two view expressions:
func buildBlock<A: View, B: View>(_ viewA: A, viewB: B) -> some View {
return TupleView((viewA, viewB))
}

// And so on, and so forth.
...
}

注意上面的构建器如何显式处理每个闭包变体,因为我们可能在同一个闭包中处理不同种类的 View 实现。如果不是这样,ViewBuilder 可能会使用一个可变参数来处理包含多个表达式的闭包:

1
2
3
4
5
6
@functionBuilder
struct ViewBuilder {
func buildBlock(_ views: View...) -> CombinedView {
return CombinedView(views: views)
}
}

上面的代码只是一个例子,它甚至不会编译,因为 View 有一个关联类型。

使用上面的 ViewBuilder 类型,编译器现在将合成一个与其名称匹配的属性(@ViewBuilder) - 然后我们可以使用它来标记我们希望使用新构建器的所有闭包参数:

1
2
3
4
5
6
7
8
9
struct VStack<Content: View>: View {
init(@ViewBuilder builder: () -> Content) {
// A function builder closure can be called just like
// any other, and the resulting expression can then be
// used to, for instance, construct a container view.
let content = builder()
...
}
}

除了按顺序接受和构建 ViewbuildBlock 以外,ViewBuilder 还实现了两个特殊的方法: buildEitherbuildIf 。它们分别对应 block 中的 if...else... 语法和 if 的语法。

1
2
3
4
5
static func buildEither<TrueContent, FalseContent>(first: TrueContent) ->
ConditionalContent<TrueContent, FalseContent>

static func buildEither<TrueContent, FalseContent>(second: FalseContent) ->
ConditionalContent<TrueContent, FalseContent>

也就是说,你可以在 VStack 中这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
var someCondition: Bool

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
.font(.subheadline)
if someCondition {
Text("Condition")
} else {
Text("Not Condition")
}
}

其他的命令式的代码在 VStack 的 content 闭包里是不被接受的,下面这样也不行:

1
2
3
4
5
6
7
8
9
VStack(alignment: .leading) {
// let 语句无法通过 function builder 创建合适的输出
let someCondition = model.condition
if someCondition {
Text("Condition")
} else {
Text("Not Condition")
}
}

到目前为止,只有以下三种写法能被接受:

  • 结果为 View 的语句
  • if 语句
  • if...else... 语句

列表 List

静态列表

1
2
3
4
5
6
var body: some View {
List {
LandmarkRow(landmark: [landmarkData[0]])
LandmarkRow(landmark: [landmarkData[1]])
}
}

这里的 ListHStack 或者 VStack 之类的容器类似,接受一个 view builder 并采用 View DSL 的方式列举了两个 LandmarkRow

动态列表

1
2
3
4
5
var body: some View {
List(landmarkData.identified(by: \.id)) { landmark in
LandmarkRow(landmark: landmark)
}
}

List 同样也可以接受动态方式的输入,这时使用的初始化方法和上面静态的不一样:

1
2
3
4
5
6
7
8
9
10
11
12
public struct List<Selection, Content> where Selection : SelectionManager, Content : View {
public init<Data, RowContent>(
_ data: Data, action: @escaping (Data.Element.IdentifiedValue) -> Void,
rowContent: @escaping (Data.Element.IdentifiedValue) -> RowContent)
where
Content == ForEach<Data, Button<HStack<RowContent>>>,
Data : RandomAccessCollection,
RowContent : View,
Data.Element : Identifiable

//...
}

初始化方法接受三个参数:

  • data 类型要求为:Data : RandomAccessCollection,即要求参数类型为 Array
  • action 类型为接收 IdentifiedValue 类型参数的闭包,用来指定列表项对应的点击时间。
  • rowContent 用来接受控制列表项内容的闭包。rowContent 其实也是被 @ViewBuilder 标记的,因此你也可以把 LandmarkRow 的内容展开写进去。不过一般我们会更希望尽可能拆小 UI 部件,而不是把东西堆在一起。

Data.Element : Identifiable 则是要求 Data.Element(即 Array 中的元素类型) 中存在一个可以辨别出某个实例的且满足 Hashable 的 id。这个 id 将在数据变更时快速定位到变化的数据所对应的 cell,并进行局部的 UI 刷新。

删除列表项

SwiftUI 对于列表滑动删除提供了很简便的接口 onDelete(perform:), 同时需要一个具有能接受要删除多个索引参数的方法:

1
func delete(at offsets: IndexSet)

在这个函数中你可以循环遍历集合中的每个索引,或者只读取第一个。由于 SwiftUI 正在观察列表的状态,因此你所做的任何更改都将自动反映在您的UI中。

以下代码将创建一个包含三个项目列表的 ContentView 结构,然后附加一个 onDelete(perform:) 用于删除列表中的任何项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ContentView : View {
@State var users = ["Paul", "Taylor", "Adele"]

var body: some View {
NavigationView {
List {
ForEach(users.identified(by: \.self)) { user in
Text(user)
}
.onDelete(perform: delete)
}
}
}

func delete(at offsets: IndexSet) {
if let first = offsets.first {
users.remove(at: first)
}
}
}
移动列表项

同样的我们也可以通过 onMove(perform:) 来处理列表项的移动。而移动的操作需要一个能接受源 IndexSet 和 目标 Int 的参数。

1
func move(from source: IndexSet, to destination: Int)

移动多个项目时,最好先移动后面的项目,这样就可以避免移动其他项目且索引混淆。

我们可以创建一个 ContentView 结构来设置一个包含三个用户名字符串的数组,并要求SwiftUI 调用 move() 方法来移动它们。

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
struct ContentView : View {
@State var users = ["Paul", "Taylor", "Adele"]

var body: some View {
NavigationView {
List {
ForEach(users.identified(by: \.self)) { user in
Text(user)
}
.onMove(perform: move)
}
.navigationBarItems(trailing: EditButton())
}
}

func move(from source: IndexSet, to destination: Int) {
// sort the indexes low to high
let reversedSource = source.sorted()

// then loop from the back to avoid reordering problems
for index in reversedSource.reversed() {
// for each item, remove it and insert it at the destination
users.insert(users.remove(at: index), at: destination)
}
}
}
列表分组

SwiftUI 的列表视图内置了 section 和 section header 的支持,就像 UIKit 中的UITableView 一样。要在某些单元格周围添加一个 section,首先在其周围放置一个 Section,也可以添加 header 和 footer。

举个例子,这里有个用于在提醒应用中显示任务数据的 View:

1
2
3
4
5
struct TaskRow: View {
var body: some View {
Text("Task data goes here")
}
}

我们要做的是创建一个包含两个部分的列表视图:一个用于重要任务,一个用于不太重要的任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct ContentView : View {
var body: some View {
List {
Section(header: Text("Important tasks")) {
TaskRow()
TaskRow()
TaskRow()
}

Section(header: Text("Other tasks")) {
TaskRow()
TaskRow()
TaskRow()
}
}
}
}

我们同样也可以像这样给 section 添加 footer:

1
2
3
4
5
Section(header: Text("Other tasks"), footer: Text("End")) {
TaskRow()
TaskRow()
TaskRow()
}

Group 样式

UITableView 一样,SwiftUI的列表支持分组以及简单样式。默认为普通样式,但如果要更改为分组,则应在列表中使用 .listStyle(.grouped) 修饰符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct ExampleRow: View {
var body: some View {
Text("Example Row")
}
}

struct ContentView : View {
var body: some View {
List {
Section(header: Text("Examples")) {
ExampleRow()
ExampleRow()
ExampleRow()
}
}.listStyle(.grouped)
}
}

以上就是对 SwiftUI 简单的认识,还有些动态绑定的总结会放在下一篇中。