本文是斯坦福大学 cs193p 公开课程第02集的相关笔记。
cs193p 课程介绍:
The lectures for the Spring 2023 version of Stanford University’s course CS193p (Developing Applications for iOS using SwiftUI) were given in person but, unfortunately, were not video recorded. However, we did capture the laptop screen of the presentations and demos as well as the associated audio. You can watch these screen captures using the links below. You’ll also find links to supporting material that was distributed to students during the quarter (homework, demo code, etc.).
cs193p 课程网址: https://cs193p.sites.stanford.edu/2023
继续上一节记忆卡片的游戏制作,笔记按照课程项目制作步骤进行整理,在每个步骤依次对涉及的语法糖进行说明。建议在看第二节视频前预览一遍,了解教程框架,便于针对性的学习。
创建一个 CardView struct
SOME VIEW
请看一段演示代码,如下:
struct ContentView: View {
var body: some View {
Text("Hello")
}
}
这段演示代码同样可以这样写:
struct ContentView: View {
var body: Text {
Text("Hello")
}
}
但是如果我们放一些返回值不为 Text
的结构,编译器会报错 (Cannot convert return expression of type 'VStack<TupleView<(Text, Text, Text)>>'
to return type 'Text'
):
struct ContentView: View {
var body: Text {
VStack {
Text("Hello")
Text("Hello")
Text("Hello")
}
}
}
同样,如果我们放入多个 Text
的结构,编译器仍然会报错:
struct ContentView: View {
var body: Text {
Text("Hello")
Text("Hello")
}
}
使用 some View
可以让编译器自动识别不同的返回类型。
尾随闭包 (Trailing closure syntax)
我们如果仔细看看 VStack
, 我们传入了一个名为content
的参数。
struct CardView: View {
var isFaceUp: Bool = false
var body: some View {
ZStack(alignment: .top, content: {
// ZStack Code
})
}
}
如果一个函数的最后一个参数本身是一个函数,此时我们可以使用尾随闭包:
struct CardView: View {
var isFaceUp: Bool = false
var body: some View {
ZStack(alignment: .top) {
// ZStack Code
}
}
}
roundedrectangle
当我们使用 RoundedRectangle 时,如果我们不指定具体的修改器,Swift会默认填充。
RoundedRectangle(cornerRadius: 12)
// These two codes are identical in terms of functionality.
RoundedRectangle(cornerRadius: 12).fill()
局部变量 (Local Variable)
我们可以创建一个局部变量:
struct CardView: View {
var isFaceUp: Bool = false
var body: some View {
ZStack {
if isFaceUp {
RoundedRectangle(cornerRadius: 12).fill(.white)
RoundedRectangle(cornerRadius: 12).strokeBorder(lineWidth: 2)
Text("👻").font(.largeTitle)
} else {
RoundedRectangle(cornerRadius: 12).fill()
}
}
}
}
创建了一个局部变量名为 base
:
struct CardView: View {
var isFaceUp: Bool = false
var body: some View {
ZStack {
let base: RoundedRectangle = RoundedRectangle(cornerRadius: 12)
if isFaceUp {
base.fill(.white)
base.strokeBorder(lineWidth: 2)
Text("👻").font(.largeTitle)
} else {
base.fill()
}
}
}
}
IMPORTANT: 我们使用了关键字 let
而不是 var
,因为这个变量一旦创建就不再能被改变。(let
通常用来创建常量)
类型推断 (Type Inference)
我们可以省略变量类型让 Swift 自动判定。
// Without omit the type
let base: RoundedRectangle = RoundedRectangle(cornerRadius: 12)
// Omited the type (using type inference)
let base = RoundedRectangle(cornerRadius: 12)
我们可以按住 option
键然后点击 base
变量,Swift 会显示自动判定的变量类型。
Note: 我们在生产环境几乎都使用类型推断,不手动指定变量类型。如果需要对返回的类型进行检查,则可指定变量类型。
.onTapGesture
单击:
struct CardView: View {
@State var isFaceUp = true
var body: some View {
ZStack {
// ZStack Code
}
.onTapGesture {
isFaceUp.toggle()
}
}
}
- 尾随闭包:单击的方法为
.onTapGesture(perform:{})
,此处由于最后一个参数本身是一个函数,所以使用尾随闭包.onTapGesture { isFaceUp.toggle() }
双击:
struct CardView: View {
@State var isFaceUp = true
var body: some View {
ZStack {
// ZStack Code
}
.onTapGesture(count: 2) {
isFaceUp.toggle()
}
}
}
@State
这一段代码会报错 Error: Cannot assign to property: 'self' is immutable
。因为结构体的属性默认是不可变的。也就是说,在结构体的实例方法或闭包中不能直接修改它的属性,除非明确标记为 mutating
。因此,当你在 struct 中使用一个属性(比如 isFaceUp),它默认是不可变的,在 .onTapGesture 中试图修改 isFaceUp 则会导致这个错误。
struct CardView: View {
var isFaceUp = true
var body: some View {
ZStack {
// ZStack Code
}
.onTapGesture {
isFaceUp = !isFaceUp
}
}
}
通常来说,一个变量在函数被调用后就不可改变。@State
关键字允许变量有临时的状态,因为 @State
会创建一个指针指向堆 (Heap) 中。因此,指针本身没有被改变,改变的是堆里存的数据。
在 SwiftUI
中, @State
属性包装器用来管理视图中的可变状态的,将 isFaceUp
声明为 @State
,SwiftUI 会知道这个属性的变化会导致视图重新渲染。
struct CardView: View {
@State private var isFaceUp = true
var body: some View {
ZStack {
// ZStack Code
}
.onTapGesture {
isFaceUp.toggle()
}
}
}
这样,isFaceUp
可以在视图中动态可变,且不会触发不可变性错误。
生成一组 Card
数组
Swift接受以下两种方式新建数组,
// A valid array notation
let emojis: Array<String> = ["👻", "🎃", "🕷️", "😈"]
// Alternate array notation
let emojis: [String] = ["👻", "🎃", "🕷️", "😈"]
我们也可以使用类型推论省略类型:
let emojis = ["👻", "🎃", "🕷️", "😈"]
ForEach 循环
ForEach 不包含最后一个数字
// iterate from 0 to 3 (NOT including 4)
ForEach(0..<4, id: \.self) { index in
CardView(content: emojis[index])
}
Note:
id:\.self
会在 cs193p 后续课程中讲解
ForEach 包含最后一个数字
// iterate from 0 to 4 (including 4)
ForEach(0...4, id: \.self) { index in
CardView(content: emojis[index])
}
ForEach (基于数组的长度)循环整个数组
struct ContentView: View {
let emojis = ["👻", "🎃", "🕷️", "😈"]
var body: some View {
HStack {
ForEach(emojis.indices, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundColor(.orange)
.padding()
}
}
通常在数组长度未知时使用该 .indices
方法。
控制 Card 个数
按钮
文本按钮
语法结构:
Button("Remove card") {
// action
}
示例:
struct ContentView: View {
let emojis = ["👻", "🎃", "🕷️", "😈", "💩", "🎉", "😎"]
@State var cardCount = 4
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundColor(.orange)
HStack {
Button("Remove card") {
cardCount -= 1
}
Spacer()
Button("Add card") {
cardCount += 1
}
}
}
.padding()
}
}
图标按钮
语法结构:
Button(action: {
// action
}, label: {
// button icon, images, etc...
})
示例:
struct ContentView: View {
let emojis = ["👻", "🎃", "🕷️", "😈", "💩", "🎉", "😎"]
@State var cardCount = 4
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundColor(.orange)
HStack {
Button(action: {
cardCount -= 1
}, label: {
Image(systemName: "rectangle.stack.badge.minus.fill")
})
Spacer()
Button(action: {
cardCount += 1
}, label: {
Image(systemName: "rectangle.stack.badge.plus.fill")
})
}
.imageScale(.large)
}
.padding()
}
}
超出索引的问题
如果我们添加了太多的卡片,由于索引超出范围会导致程序崩溃。其中一种避免程序的方法是添加一个 if
逻辑。
Button(action: {
if cardCount < emojis.count {
cardCount += 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.plus.fill")
})
另一种方法是使用 .disabled
视图修改器
func cardCountAdjuster(by offset: Int, symbol: String) -> some View {
Button(action: {
cardCount += offset
}, label: {
Image(systemName: symbol)
})
.disabled(cardCount + offset < 1 || cardCount + offset > emojis.count)
}
Note: 这节课的后半部分讲解了 Swift 中的函数。
整理代码
优化代码可读性
我们先看看 body
中包含的代码,
struct ContentView: View {
let emojis = ["👻", "🎃", "🕷️", "😈", "💩", "🎉", "😎"]
@State var cardCount = 4
var body: some View {
VStack {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundColor(.orange)
HStack {
Button(action: {
if cardCount > 1 {
cardCount -= 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.minus.fill")
})
Spacer()
Button(action: {
if cardCount < emojis.count {
cardCount += 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.plus.fill")
})
}
.imageScale(.large)
}
.padding()
}
}
现在看起来十分不整洁。我们可以创建其它视图提高代码的可读性。
struct ContentView: View {
let emojis = ["👻", "🎃", "🕷️", "😈", "💩", "🎉", "😎"]
@State var cardCount = 4
var body: some View {
VStack {
cards
cardCountAdjusters
}
.padding()
}
var cards: some View {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundColor(.orange)
}
var cardCountAdjusters: some View {
HStack {
cardRemover
Spacer()
cardAdder
}
.imageScale(.large)
}
var cardRemover: some View {
Button(action: {
if cardCount > 1 {
cardCount -= 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.minus.fill")
})
}
var cardAdder: some View {
Button(action: {
if cardCount < emojis.count {
cardCount += 1
}
}, label: {
Image(systemName: "rectangle.stack.badge.plus.fill")
})
}
}
在整理后,我们 body
中的代码现在看起来非常容易理解。
隐式返回值 (Implicit return)
如果一个函数只有 1 行代码,我们就可以使用隐式返回。
var cards: some View {
HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundColor(.orange)
}
当然我们也可以使用 return
关键字显式返回。
var cards: some View {
return HStack {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundColor(.orange)
}
函数 (Function)
语法结构:
func <function name>(<para name>: <data type>) -> <return type> {
// function code
}
示例:
func cardCountAdjuster(by offset: Int, symbol: String) -> some View {
Button(action: {
cardCount += offset
}, label: {
Image(systemName: symbol)
})
}
IMPORTANT: by offset: Int
我们有时候会使用 2 个标签代表一个参数,第一个参数 by
在调用函数时使用,而第二个标签在函数内使用。第一个标签被称为 external parameter name,第二个标签被称为internal parameter name。
现在我们的代码看起来更漂亮了,
func cardCountAdjuster(by offset: Int, symbol: String) -> some View {
Button(action: {
cardCount += offset
}, label: {
Image(systemName: symbol)
})
}
var cardRemover: some View {
return cardCountAdjuster(by: -1, symbol: "rectangle.stack.badge.minus.fill")
}
var cardAdder: some View {
return cardCountAdjuster(by: 1, symbol: "rectangle.stack.badge.plus.fill")
}
Note: 由于我们删除了
if
逻辑,我们程序可能由于数组超出索引范围而崩溃。但是我们在超出索引的问题章节讲了如何解决.
优化布局
LazyVGrid
为了让这些卡片看起来比较正常,我们需要用LazyVGrid
替代HStack
。
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
}
}
.foregroundColor(.orange)
}
LazyVGrid
和LazyHGrid
分别是垂直的网格布局、水平网格布局。LazyVGrid
通过制定列的数量然后来填充布局。GridItem
通过设置 Size 来控制格子的宽度:
fixed
通过指定固定大小来确定格子宽度flexible
弹性大小,会和其他flexible的格子分割剩余的空间,可以设置期望的最大和最小值。如果指定的最小值过大可能会超出屏幕adaptive
自适应分布, 这个尺寸会提供一个或者多个格子。需要指定一个最小值, 也可以设置最大值,然后根据最小值在把自身占用的区域平分成若干个满足最小值最大值的的格子。可以理解adaptive为一个flexible大格子,获得空间后再把这个大格子平分成若干个满足设置定值的小格子。
我们需要在cards
和 cardCountAdjusters
之间添加一个Spacer()
,这样它们不会挤到一起去。
var body: some View {
VStack {
cards
Spacer()
cardCountAdjusters
}
.padding()
}
由于LazyVGrid
会使用尽可能少的空间,因此,当两张卡片都为背面时会被挤压到一起去。
.opacity
我们需要修改CardView
的逻辑
struct CardView: View {
let content: String
@State var isFaceUp = true
var body: some View {
ZStack {
let base = RoundedRectangle(cornerRadius: 12)
Group {
base.foregroundColor(.white)
base.strokeBorder(lineWidth: 2)
Text(content).font(.largeTitle)
}
.opacity(isFaceUp ? 1 : 0)
base.fill().opacity(isFaceUp ? 0 : 1)
}
.onTapGesture {
isFaceUp.toggle()
}
}
}
问题解决!
.aspectRatio
使用 aspectRatio
控制视图的宽高比
var cards: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
ForEach(0..<cardCount, id: \.self) { index in
CardView(content: emojis[index])
.aspectRatio(2/3, contentMode: .fit)
}
}
.foregroundColor(.orange)
}
ScrollView
var body: some View {
VStack {
ScrollView {
cards
}
Spacer()
cardCountAdjusters
}
.padding()
}