开场白
- 本文目标:开发一个 Room 的小应用。
- 建议学习方法:全程跟完 WWDC #204,亲手用代码实现视频中项目的每一个细节。
- 建议学习时间:1.5 小时,其中视频全长 55 分钟,自己敲代码时间 35 分钟。
视频时间线
- 00 : 00 ~ 06 : 00 了解 Image,Text 控件的基本用法,以及 HStack,VStack 基本布局。
- 06 : 00 ~ 16 : 00 了解 NavigationView,NavigationLink 控件的基本用法,以及 Image 对图片大小的自适应处理。
- 16 : 00 ~ 30 : 00 举点击图片缩放的例子来说明 SwiftUI 数据绑定相对于传统命令式编程进行状态管理的好处。
- 30 : 00 ~ 38 : 00 @State 变量的用法,动手实现上面图片缩放的例子,包括了点击时间处理、动画处理、状态刷新。
- 38 : 00 ~ 47 : 00 ObserverdObject,PassthroughObject 的基本用法,实现了对 cell 的增加、删除、移动功能,ForEach 的使用,List 样式的修改。
- 47 : 00 ~ 54 : 00 在 Preview 中设置 Accessablity 大字体、暗黑模式、本地化的进行 Debug。
正文
App入口变化
首先,确保你新建工程的时候,开启了SwiftUI。完成之后会发现,多出了 SceneDelegate 这个文件。
App的根窗口会去加载 ContentView 作为根视图
声明式语法
在ContenView文件里,所以的 UI 布局代码都写在 body 的 get 方法里,并且布局的实时改变都能反馈到右边的 Canvas 上。启动 Canvas 的快捷键是 Option + Command + p,但是至今为止的 Xcode 遇到这个快捷键的时候会遇到莫名 bug,所以推荐直接用鼠标点击 Resume 启动。
Preview 的入口在下面这个方法里:
布局基础容器
有三种容器:HStack、VStack、ZStack 可以帮助你布局。顾名思义,H 代表水平方向,V 代表垂直方向,Z 代表垂直于屏幕的 “Z” 轴方向。很显然,你已经知道,Text 控件相当于 UIKit 中的 UILabel,我们在 VStack 中声明两个 Text 控件,看看效果。
红色的错误是 XCode 的 bug。如果熟悉 Flex 布局,应该很快能够反应过来,VStack 就相当于主轴是在垂直方向,但是 SwiftUI 默认容器内的布局都是从 center 开始。
我们可以通过修改 VStack 的初始化方法来调整水平方向的对齐方式:
struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("How are you?")
Text("Fine. Thank you")
}
}
}
效果:
Image 控件
下面我们引入 Image 控件:
struct ContentView: View {
var body: some View {
HStack {
Image(systemName: "video.fill")
VStack(alignment: .leading) {
Text("How are you?")
Text("Fine. Thank you")
}
}
}
}
效果:
List 控件
对一个控件点击 Command + 左键可以方便地将其嵌入 List 控件中。
嵌入之后,我们发现 List 的效果相当于 UITableView,List 中内嵌的代码相当于 UITableViewCell,效果如下:
List 接受数组作为参数,来决定 List 中 cell 的个数,尾随闭包中的 item 参数是数组中的每一个元素。
struct ContentView: View {
var body: some View {
List(0 ..< 5) { item in
Image(systemName: "video.fill")
VStack(alignment: .leading) {
Text("How are you?")
Text("Fine. Thank you")
}
}
}
}
NavigationView 控件
下面将页面加上导航栏,我们需要用到 NavigationView 控件
struct ContentView: View {
var body: some View {
NavigationView {
List(/*@START_MENU_TOKEN@*/0 ..< 5/*@END_MENU_TOKEN@*/) { item in
Image(systemName: "video.fill")
VStack(alignment: .leading) {
Text("How are you?")
Text("Fine. Thank you")
}
}
}
}
}
我们可以看到,导航栏部分的控件空出来了(红色部分是我的强调):
下面为导航栏设置标题,我们直接在 List 控件尾部通过点语法进行链式调用,来改变 UI 控件的展示,这种操作在 SwiftUI 叫做 Modifier。(但是这里我比较不理解的是,为什么 Modifier没有跟在 NavigationView 的后面,因为我觉得 title 毕竟是导航栏的属性)
struct ContentView: View {
var body: some View {
NavigationView {
List(0 ..< 5) { item in
Image(systemName: "video.fill")
VStack(alignment: .leading) {
Text("How are you?")
Text("Fine. Thank you")
}
}.navigationBarTitle(Text("My First SwiftUI App"))
}
}
}
效果如下:
我们还可以继续调用 Modifier,将导航栏展示改成我们传统的方式
struct ContentView: View {
var body: some View {
NavigationView {
List(0 ..< 5) { item in
Image(systemName: "video.fill")
VStack(alignment: .leading) {
Text("How are you?")
Text("Fine. Thank you")
}
}.navigationBarTitle(Text("My First SwiftUI App"), displayMode: .inline)
}
}
}
效果如下:
按照视频中的步骤,我们也造一些测试数据,然后为每一个 List 的 cell 增加点击事件,用到的是 NavigationLink 控件(注意原视频中的 NavigationButton 已经弃用),其中 destination 参数可以是 Text 类型,用于显示下一个页面的中间的 text;也可以是一个 View,下面我们跟着视频一起创建 RootDetail 这个类,敲过代码后,你会学习到 Image 控件的缩放适配处理、手势点击处理、动画处理、设置frame的最大值和最小值、@State 变量与 UI 状态的绑定。
struct RoomDetail: View {
let room: Room
@State private var zoomed = false
var body: some View {
ZStack(alignment: .topLeading) {
Image(room.imageName)
.resizable()
.aspectRatio(contentMode:zoomed ? .fill : .fit)
.onTapGesture {
withAnimation(Animation.easeIn(duration: 2)) {
self.zoomed.toggle()
}
}.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity, minHeight: 0, idealHeight: .infinity, maxHeight: .infinity).navigationBarTitle(Text(room.name), displayMode: .inline)
if room.hasVideo && !zoomed {
Image(systemName: "video.fill").font(.title).padding(.all).transition(.move(edge: .leading))
}
}
}
}
效果如下:
ObservedObject 用法
在 ContentView 中,用 @ObservedObject (原视频中的 @BindingObject 已经弃用)声明实例变量 store,意思说是 store 内部发出的通知会刷新 body 内的状态。
struct ContentView: View {
@ObservedObject var store = RoomStore()
var body: some View {
NavigationView {
List() {
Section {
Button(action: addRoom) {
Text("Add Room")
}
}
Section {
ForEach(store.rooms) { room in
RommCell(room: room)
}
.onDelete(perform: delete)
.onMove(perform: move(from:to:))
}
}
.navigationBarTitle(Text("My First SwiftUI App"), displayMode: .inline)
.navigationBarItems(trailing: EditButton())
.listStyle(GroupedListStyle())
}
}
func addRoom() {
store.rooms.append(Room(name: "Hall", capacity: 2000))
}
func delete(from offset: IndexSet) {
store.rooms.remove(atOffsets: offset)
}
func move(from source: IndexSet, to destination: Int) {
store.rooms.move(fromOffsets: source, toOffset: destination)
}
}
RoomStore 必须继承 ObservableObject,在 rooms 发生改变的时候 PassthroughSubject 的实例调用 send 方法对外发出通知。
import SwiftUI
import Combine
class RoomStore: ObservableObject {
var didChange = PassthroughSubject()
var rooms: [Room] {
didSet {
didChange.send()
}
}
init(rooms: [Room] = []) {
self.rooms = rooms
}
}
然鹅实际上,当rooms发生改变的时候,有向外发出通知,UI 并没有更新,不知道具体原因。我改用替代的方法,对 rooms 变量加上 @ Published 后解决。
class RoomStore: ObservableObject {
let didChange = PassthroughSubject()
@Published var rooms: [Room]
init(rooms: [Room] = []) {
self.rooms = rooms
}
}
总结
如果能把视频中的代码准确无误敲出来,那目标就达到了。这些新知识,先不要纠结于底层原理 (Why),能达到熟练使用 How 的级别就可以了。后面的章节,会详细分析原理。