本文是斯坦福大学 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


继续设计卡片小游戏

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

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 会显示自动判定的变量类型。

type-inference

Note: 我们在生产环境几乎都使用类型推论,不手动指定变量类型。

.onTapGesture

单击:

struct CardView: View {
    @State var isFaceUp = true
    var body: some View {
        ZStack {
            // ZStack Code
        }
        .onTapGesture {
            isFaceUp.toggle()
        }
    }
}

双击:

struct CardView: View {
    @State var isFaceUp = true
    var body: some View {
        ZStack {
            // ZStack Code
        }
        .onTapGesture(count: 2) {
            isFaceUp.toggle()
        }
    }
}

@State

通常来说,一个变量在函数被调用后就不可改变。@State关键字允许变量有临时的状态,因为@State会创建一个指针指向堆 (Heap) 中。因此,指针本身没有被改变,改变的是堆里存的数据。

@State var isFaceUp = true

数组

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

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

按钮

文本按钮

语法结构:

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

text-button

图标按钮

语法结构:

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

icon-button

超出索引的问题

如果我们添加了太多的卡片,由于索引超出范围会导致程序崩溃。其中一种避免程序的方法是添加一个 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 中的代码现在看起来非常容易理解。

organized-code

隐式返回值 (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 逻辑,我们程序可能由于数组超出索引范围而崩溃。但是我们在超出索引的问题章节讲了如何解决.

cardCountAdjuster-function

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

我们需要在cardscardCountAdjusters之间添加一个Spacer(),这样它们不会挤到一起去。

var body: some View {
    VStack {
        cards
        Spacer()
        cardCountAdjusters
    }
    .padding()
}

由于LazyVGrid会使用尽可能少的空间,因此,当两张卡片都为背面时会被挤压到一起去。

lazyvgrid-issue

.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()
        }
    }
}

问题解决!

solve-lazyvgrid-issue-use-opacity

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

aspectRatio

ScrollView

var body: some View {
    VStack {
        ScrollView {
            cards
        }
        Spacer()
        cardCountAdjusters
    }
    .padding()
}