SwiftUI 是一个用户界面工具包,可让我们以声明性方式设计应用程序。SwiftUI 可在 iOS 13、macOS 10.15、tvOS 13、watchOS 6、visionOS 1 或这些平台的任何未来更高版本上运行。
声明式 UI 比命令式 UI 好理解。在命令式用户界面中,我们可能会在单击按钮时调用一个函数,并在该函数内读取一个值并显示标签,我们会根据发生的情况定期修改用户界面的外观和工作方式。命令式 UI 会导致各种各样的问题,其中大部分都与状态有关,我们需要跟踪代码所处的状态,并确保我们的用户界面正确反映该状态。
相比之下,声明式 UI 可以立即告诉 iOS 应用程序的所有可能状态。我们可能会说,如果我们已登录,则显示欢迎消息;但如果我们已注销,则显示登录按钮。我们不需要手动编写代码在这两种状态之间移动——这是丑陋的、命令式的工作方式!
所谓声明式:就是我们不会手动让 SwiftUI 组件显示和隐藏,我们只是告诉它希望它遵循的规则,并让 SwiftUI 确保这些规则得到执行。
// 通过 import 导入需要的框架、功能
import SwiftUI
// 创建了一个名为 ContentView 的新结构,并让它符合 View 协议
// View 来自 SwiftUI,是你想在屏幕上绘制的任何内容都必须采用的基本协议
// 所有文本、按钮、图像等都是视图,包括你自己定义的其他布局
struct ContentView: View {
// View 协议只有一个要求,即有一个名为 body 的计算属性,它返回 some View
// 您可以(并且将会)向视图结构添加更多属性和方法,但 body 是唯一需要的
// 这里使用了模糊返回类型:some View 。这意味着它将返回符合 View 协议的内容,这就是我们的布局
// var body: some View { } ,这里 body 是计算型属性,每次调用时系统会跑一遍里面的程序。后面的大括号理解是个闭包
// 既然是计算属性,它里面也可以定义其他变量,例如自定义绑定数据,就是在它里面定义 Binding
var body: some View {
// VStack是个函数,中间()包含的 alignment 是函数的参数
// 其实它还有一个最后的参数 content,因为是最后的参数,所以写成了闭包。把content函数写到()外面。
// 因为最后一个参数是函数,就可以省略该参数,并把函数写到括号外面
VStack() {
// 对象,只有一行代码的时候,其实是省略了 return
// 详细写法应该是 return Text("Welcome to my first App")
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
ZStack(alignment:top) {
Text("emoji")
}
}
//视图修饰符,实际也是执行一个方法,结果是返回一个新的视图
.padding()
}
}
//预览代码,不会包含在最后的编译成App的代码中
#Preview {
ContentView()
}
<aside>
💡 SwiftUI 中的文件名不要起的跟常用的 View 的名字一样,例如一个文件名叫TabView
的文件,里面用了TabView(){ … }
这个视图,会莫名其妙报错。
</aside>
视图为什么是结构体:
在以前的 UIKit 中,视图用的都是类,而在 swiftUI中,视图用的是结构体。
UIView
属性和方法,无论是否需要,所有这些属性和方法都会传递给其子类。如果将类用于视图,经常会发现代码无法编译或在运行时崩溃。所以:使用结构。body 里可以返回多个视图:
如果直接从 body
属性发回多个视图,而不是将它们包装在一个堆栈中,会发生什么情况?如果返回多个视图,Swift 会默默地将一个名为 @ViewBuilder
的特殊标记赋予 body
属性。这样做的效果是以静默方式将多个视图包装在一个叫 TupleView
的容器中,因此,即使看起来我们发回了多个视图,它们也会合并为一个 TupleView
视图。
var body: some View {
Text("haha")
Text("haha")
Text("haha")
...
}
收缩型视图 和 扩张型视图:当涉及视图的大小和布局时,可以观察到两种行为:
ZStack {
//这个色块就会填满屏幕
Color.gray
//这个Vstack只会占用需要的面积
VStack(spacing: 20) {
Text("ZStack")
}
}
SwiftUI 的视图都是可组装的,视图里面可以嵌入另外的视图。这样有利于整理视图的层次结构。
例如当有不少页面的视图模块非常相似的时候,我们就可以抽象出它们的共同结构,简化代码。在 Xcode 里按住 command 键,点击想要抽出 Extracted 的元素, 并给子视图命名即可。
// 1. 提取出相同布局的 View 的模型,抽象成子视图,并定义参数
struct PricingView: View {
var title: String
var price: String
var textColor: Color
var bgColor: Color
var body: some View {
VStack {
Text(title)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(textColor)
Text(price)
.font(.system(size: 40, weight: .heavy, design: .rounded))
.foregroundColor(textColor)
Text("per month")
.font(.headline)
.foregroundColor(textColor)
}
.background(bgColor)
}
}
// 2. 然后在父视图中实作它,同时提供子视图需要的参数值:
PricingView(title: "Basic", price: "$9", textColor: .white, bgColor: .purple)
PricingView(title: "Pro", price: "$19", textColor: .black, bgColor: Color(red: 240/255, green: 240/255, blue: 240/255))
// 3. 如何处理不一定有的元素 Optionals
// 例如 Aview 和 Bview 布局基本相似,但是可能有个元素 X 在 A 里出现,在 B 里是隐藏的
// 这时我们还是可以抽象出子视图的类,将元素 X 声明成 Optionals 类型的变量
var icon: String?
// 4. 然后在使用时需要解包
if icon != nil {
Image(systemName: icon!)
.font(.largeTitle)
.foregroundColor(textColor)
}
// 或者使用 map 解開 optional 也是一种方式:
icon.map({
Image(systemName: $0)
.font(.largeTitle)
.foregroundColor(textColor)
})
<aside>
💡 注意:Swift 通常使用 if let 来检查 optional 是否有值, 并解开它。不过在 SwiftUI 中不能使用 if let,你會得到錯誤。在 SwiftUI 中處理 optional 的方式是檢查 optional 是否存在一個非空值,即 if icon != nil
关于具体 Optional 的解包方式,查看: 可选值 Optional
</aside>
View
协议是 SwiftUI 的核心,任何东西都可以遵循它并开始参与布局,只需几行简单的代码。
遵循 View
协议,唯一要实现的是必须具有 body
计算属性,该属性返回某种符合 View
协议的东西。当返回模糊类型(使用 some
关键字)时,所有可能的返回类型必须全部属于同一类型。
模糊类型(Opaque Types)关键字 some
指定返回模糊类型。代码 some View
指的模糊类型是 View
。“opaque” 意思是“难以理解或不可能理解”,因为模糊类型隐藏了值的类型信息和实现细节,这肯定会使其“难以或不可能理解”,但仍然可用。
当 iOS 使用此视图来绘制屏幕时,它不知道在本例中返回的是 Text 类型还是其他的。只需知道某些 View 正在返回并且可以使用它来绘制屏幕就可以了。
some
拓展body的可能性,意味着 body
可以包含一个 View
或者类似 View
的东西some
意味着该对象并不完全是一个 View
,但它拥有和 View
一样的属性和方法// 事实上明确 body 的视图类型,像下面这样写是允许的
// 但大部分情况,返回的视图是很复杂的组合,很难去写它的类型,所以这时统一用 some View 就省事了
var body: Text { Text("Hello, world!") }
// 像这样的代码时不允许的,因为返回类型不明确(又没有使用 some)
var body: View {
if Bool.random() {
Text("Success!")
} else {
Image(systemName: "x.circle")
}
}
body
属性一般只能精确返回一个 View
;但其实它也能返回多个 View
,Swift会在背后以静默的方式将多个视图包装在 TupleView
的容器中,用户不需要感知。这就得益于 @ViewBuilder
视图构建器。
作为 View
协议的一部分,@ViewBuilder
视图构建器会自动加到 body
属性上,它可以在编译时让视图转化为类型安全的视图。对于上面随机返回 Text 或 Image 的例子,@ViewBuilder
实际上不是发回两个视图,而是发回类似 _ConditionalContent
之类的东西,它能满足 some View
。
由于 @ViewBuilder
构建器是通过 View
协议附加的,因此像这样的代码不起作用:
struct ContentView: View {
var status: some View {
if Bool.random() {
Text("Success!")
} else {
Image(systemName: "x.circle")
}
}
var body: some View {
status
}
}
该 status 属性不会自动应用 @ViewBuilder,因此尝试返回两种不同类型的视图不会以静默方式转换为 _ConditionalContent ,该代码不会编译。如果想修复这个问题,可以参考后面介绍的方法。但应该注意:将逻辑打包到视图中是一个坏主意,而且它不会以某种方式成为一个好主意,因为你已将布局划分为属性。
对视图使用单独的属性,是分解代码的好方法。其具体做法是指:将一个视图创建为自己视图的属性,然后在布局中使用该属性。它不仅有助于避免重复,还可以从 body
属性中编写复杂代码。也让代码更清晰。
<aside> 💡 @ViewBuilder 里面不能放 var 变量,解决办法可以把 var 放到 body 的外面定义。
</aside>
struct ContentView: View {
// 例如,我们可以创建两个这样的文本视图作为属性
let motto1 = Text("Draco dormiens")
let motto2 = Text("nunquam titillandus")
// Swift 不允许创建一个引用其他存储属性的存储属性,因为它会在创建对象时引起问题。但是如果需要,可以创建计算属性,如下所示:
var spells: some View {
VStack {
Text("Lumos")
Text("Obliviate")
}
}
var body: some View {
VStack {
motto1
.foregroundStyle(.red)
motto2
.foregroundStyle(.blue)
}
}
}
<aside>
💡 以上代码有一个要求:即创建的任何自定义属性 motto1
、motto2
,都必须返回相同的视图类型,如果它可能是不同类型(例如有时返回 Text ,有时返回 Image),那就会报错。
</aside>
为了解决 “如何返回不同类型视图” 的这个问题,有以下几种做法:
首先可以将判断逻辑包装在 Group
中,放在 Group
标签中的内容会自动应用 @ViewBuilder
struct ContentView: View {
// 这是一个视图属性,必须确保每次返回的类型一样
var tossResult: some View {
// 将返回的视图包装在 Group 中,它将自动应用 @ViewBuilder 转换,确保一致
Group {
if Bool.random() {
Image("laser-show")
} else {
Text("Better luck next time")
}
}
.frame(width: 400, height: 300)
}
var body: some View {
VStack {
Text("Coin Flip")
.font(.largeTitle)
tossResult
}
}
}
使用 AnyView
可以有效地迫使 Swift 忘记 AnyView
内的具体类型,让它们看起来像是同一件事。不过,这会带来性能成本,因此不要经常使用它。虽然 Group
和 AnyView
都能实现相同的结果,但在两者之间最好使用 Group
。
struct ContentView: View {
var tossResult: some View {
if Bool.random() {
return AnyView(Image("laser-show").resizable().scaledToFit())
} else {
return AnyView(Text("Better luck next time").font(.title))
}
}
var body: some View {
VStack {
Text("Coin Flip")
.font(.largeTitle)
tossResult
.frame(width: 400, height: 300)
}
}
}
@ViewBuilder
你还可以手动应用 @ViewBuilder
转换,只需要在视图属性前加上 @ViewBuilder
关键词即可。
struct ContentView: View {
@ViewBuilder var tossResult: some View {
if Bool.random() {
Image("laser-show")
.resizable()
.scaledToFit()
} else {
Text("Better luck next time")
.font(.title)
}
}
var body: some View {
VStack {
Text("Coin Flip")
.font(.largeTitle)
tossResult
.frame(width: 400, height: 300)
}
}
}
这确实有效。但实际上,当你向一个视图添加了如此多的逻辑,以至于认为 “应该把它分拆成单独的 @ViewBuilder
属性”,那么可能你更应该把代码分解成一个个单独视图。这也是更常用的办法:将视图分解为更小的视图,然后根据需要将它们组合在一起。这有助于分解逻辑和布局,并且还有一个好处是使您的视图在应用程序的其他地方更可重用。大多数情况下这是最有效的解决方案。具体查看:视图可组装性