本文是斯坦福大学 cs193p 公开课程第07集的相关笔记。
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
0. 引言
本节概述了与 SwiftUI 和 Swift 编程相关的关键概念和主题。讨论的主题包括:
代码演示插曲
- 将视图分离到各自的文件中。
- 理解 Swift 中的常量。
形状(Shape)
- 绘制自定义形状。
- Demo:在卡片中绘制一个“饼状”的倒计时(未添加动画)。
动画(Animation)
- 如何工作的
视图调整器(ViewModifier)
- 视图调整器的工作机制
2. 演示插曲
2.1 分离视图
新建一个 swiftUI
文件,命名为 CardView
。我们将 EmojiMemoryGameView
中的 CardView
视图分离出来放在单独的 CardView.swift
文件中,以实现模块化。为了便于检查代码,设置卡片的预览视图。
//CardView.swift
import SwiftUI
struct CardView: View {
let card: MemoryGame<String>.Card
init(_ card:MemoryGame<String>.Card){
self.card = card
}
var body: some View {
ZStack {
let base = RoundedRectangle(cornerRadius: 12)
Group {
base.fill(.white)
base.strokeBorder(lineWidth: 2)
Text(card.content)
.font(.system(size: 200))
.minimumScaleFactor(0.01)
.aspectRatio(1, contentMode: .fit)
}
.opacity(card.isFaceUp ? 1 : 0)
base.fill().opacity(card.isFaceUp ? 0 : 1)
}
.opacity(card.isFaceUp || !card.isMatched ? 1 : 0)
}
}
struct CardView_Previews:PreviewProvider {
static var previews: some View{
CardView(MemoryGame<String>.Card(content: "X", id: "test1"))
.padding()
.foregroundColor(.green)
}
}
2.1.1 typealias
需要经常输入 MemoryGame<String>.Card
实在很麻烦,可以使用类型别名 typealias
来简化代码。我们首先在预览中设置类型别名。
typealias
为现有类型提供一个新的别名,使代码更简洁易读。当你需要使用复杂的类型(如嵌套类型或具有多个类型的 tuple)时,使用typealias
可以让代码更清晰。
//CardView.swift
import SwiftUI
struct CardView: View {
let card: Card
init(_ card:MemoryGame<String>.Card){
self.card = card
}
...
}
struct CardView_Previews:PreviewProvider {
typealias Card = MemoryGame<String>.Card
static var previews: some View{
CardView(Card(content: "X", id: "test1"))
.padding()
.foregroundColor(.green)
}
}
但是由于使用类型别名的命名空间不同,在 struct CardView
中无法将 MemoryGame<String>.Card
替换为 Card
,需要在 CardView
结构体中再次使用类型别名 typealias
来简化代码。
//CardView.swift
...
struct CardView: View {
typealias Card = MemoryGame<String>.Card
let card: Card
init(_ card:Card){
self.card = card
}
...
}
...
另外,对 EmojiMemoryGame
的 MemoryGame<String>.Card
做同样的处理。
//EmojiMemoryGame.swift
class EmojiMemoryGame:ObservableObject{
typealias Card = MemoryGame<String>.Card
...
var cards: Array<Card> {
return model.cards
}
func choose(_ card:Card){
model.choose(card)
}
}
由于我们在 CardView
结构体中已经定义了类型别名,我们可以将预览中的类型别名赋值为 CardView.Card
,还可以创建多个 CardView 视图来提供预览效果。
//CardView.swift
...
struct CardView_Previews: PreviewProvider {
typealias Card = CardView.Card
static var previews: some View {
VStack {
HStack {
CardView(Card(isFaceUp: true, content: "X", id: "test1"))
CardView(Card(content: "X", id: "test1"))
}
HStack {
CardView(Card(isFaceUp: true, isMatched: true, content: "long long long string and i hope it fits",id: "test1"))
CardView(Card(content: "X", id: "test1"))
}
}
.padding()
.foregroundColor(.green)
}
}
预览效果如下:
我们发现在有多行文本时,视图文本会靠左对齐,对 CardView
视图进行调整使其在多行文本时居中显示。
//CardView.swift
struct CardView: View {
...
var body: some View {
ZStack {
...
Group {
...
Text(card.content)
.font(.system(size: 200))
.minimumScaleFactor(0.01)
.multilineTextAlignment(.center)
.aspectRatio(1, contentMode: .fit)
.padding()
}
...
}
...
}
}
2.2 处理常量
为了更方便对视图的布局进行统一调整,我们使用私有结构体来为这些视图调整器的常量创建命名空间,并声明类型。这样可以使得代码结构更清晰。
//CardView.swift
struct CardView:View{
...
var body: some View {
ZStack {
let base = RoundedRectangle(cornerRadius: Constants.cornerRadius)
Group {
base.fill(.white)
base.strokeBorder(lineWidth: Constants.lineWidth)
Text(card.content)
.font(.system(size: Constants.FontSize.largest))
.minimumScaleFactor(Constants.FontSize.scaleFactor)
.multilineTextAlignment(.center)
.aspectRatio(1, contentMode: .fit)
.padding(Constants.inset)
}
.opacity(card.isFaceUp ? 1 : 0)
base.fill().opacity(card.isFaceUp ? 0 : 1)
}
.opacity(card.isFaceUp || !card.isMatched ? 1 : 0)
}
private struct Constants {
static let cornerRadius:CGFloat = 12
static let lineWidth:CGFloat = 2
static let inset:CGFloat = 5
struct FontSize {
static let largest:CGFloat = 200
static let smallest:CGFloat = 10
static let scaleFactor = smallest / largest
}
}
}
但我们会注意到,还会有一些常量 1
、0
或者颜色常量没有添加到结构体中,因为通常不具有特别的意义,当然根据情况也可以添加到私其中。
另外,还有一些特别的颜色,比如卡片游戏中的颜色背景,没有添加。因为我们希望将其置于视图层级来调整它们,它属于游戏配置。
//EmojiMemoryGameView.swift
struct EmojiMemoryGameView: View {
...
var body: some View {
VStack{
cards
.foregroundColor(viewModel.color)
.animation(.default, value: viewModel.cards)
.padding()
...
}
}
}
var cards: some View {
AspectVGrid(items:viewModel.cards,aspectRatio:aspectRatio) { card in
CardView(card)
.aspectRatio(aspectRatio, contentMode: .fit)
.padding(8)
.onTapGesture {
viewModel.choose(card)
}
}
}
}
//EmojiMemoryGame.swift
class EmojiMemoryGame:ObservableObject {
...
var color:Color {
return .orange
}
...
}
2. 图形(Shape)
Shape
是一种继承自 View
的协议(protocol),也就是说,所有的 shape
都是 View
,比如 RoundedRectangle
、Circle
、Capsule
等。
2.1 绘制自定义图形
自定义图形的实现基于 SwiftUI 的 Shape
协议:
2.1.1 Shape
协议的默认行为:
图形通过当前的前景色(foregroundColor
)进行填充。也可以使用 .fill()
和 .stroke()
方法自定义绘制方式。
这些修饰符返回一个以指定方式(通过描边或填充)绘制形状的视图。在我们的演示中,fill
的参数看起来是一个颜色(例如,Color.white
)。但情况并非完全如此…
func fill<S>(_ whatToFillWith: S) -> some View where S: ShapeStyle
这是一个泛型函数(类似于但不同于泛型类型)。S
是一个不关心的类型(但由于有 where
语句,它变成了“稍微关心一下”)。S
可以是任何实现了 ShapeStyle
协议的类型。ShapeStyle
协议通过对形状应用某些样式将形状转换为视图。这样的例子包括:Color
、ImagePaint
、AngularGradient
、LinearGradient
。
2.1.2 实现 Shape
协议的方法:
但是要怎么实现自定义 Shape
呢?
实现 shape
协议必须实现以下方法:
func path(in rect: CGRect) -> Path
该方法返回一个 Path
,用于定义图形的路径。
Path 的能力:
- 支持添加线条、弧线、贝塞尔曲线等。
- 可以通过组合这些路径创建任意形状。
2.2 Demo示例:绘制一个未动画化的“倒计时饼图”
为了更清晰的观察饼图的样式:
- 我们先将卡片数量设置为 4 张(EmojiMemoryGame.swift)
- 使卡片默认朝上(MemoryGame.swift)
- 在 Card 中添加一个
Circle
并调整padding
和opacity
,将Text
放置在circle
的overlay
中,由于padding
和opacity
的值为常数,我们将其添加到下面的常量中(CardView.swift)
// EMojiMemoryGame.swift
class EmojiMemoryGame:ObservableObject {
...
private static func createMemoryGame() -> MemoryGame<String> {
return MemoryGame(numberOfPairsOfCards: 2) { Index in
...
}
}
...
//MemoryGame.swift
struct MemoryGame<CardContent> where CardContent: Equatable {
...
struct Card: Equatable,Identifiable,CustomDebugStringConvertible {
...
var isFaceUp = true
...
}
}
...
// CardView.swift
struct CardView: View {
...
var body: some View {
ZStack {
let base = RoundedRectangle(cornerRadius: Constants.cornerRadius)
Group {
base.fill(.white)
base.strokeBorder(lineWidth: Constants.lineWidth)
Circle()
.opacity(Constants.Pie.opacity)
.overlay(
Text(card.content)
.font(.system(size: Constants.FontSize.largest))
.minimumScaleFactor(Constants.FontSize.scaleFactor)
.multilineTextAlignment(.center)
.aspectRatio(1, contentMode: .fit)
.padding(Constants.Pie.inset)
)
.padding(Constants.inset)
}
.opacity(card.isFaceUp ? 1 : 0)
base.fill().opacity(card.isFaceUp ? 0 : 1)
}
.opacity(card.isFaceUp || !card.isMatched ? 1 : 0)
}
private struct Constants {
static let cornerRadius:CGFloat = 12
static let lineWidth:CGFloat = 2
static let inset:CGFloat = 5
struct FontSize {
static let largest:CGFloat = 200
static let smallest:CGFloat = 10
static let scaleFactor = smallest / largest
}
struct Pie {
static let opacity:CGFloat = 0.5
static let inset:CGFloat = 5
}
}
}
...
2.2.1 创建一个 Pie(shape)
虽然 Pie
是一个 View
,但它是一个 Shape
,所以我们创建一个 swift
文档,而非 swiftUI
。
// Pie.swift
import SwiftUI
import CoreGraphics
struct Pie: Shape {
var startAngle: Angle = .zero
let endAngle: Angle
// 顺时针
var clockwise = true
// Shape协议 方法
func path(in rect: CGRect) -> Path {
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
let start = CGPoint(
x: center.x + radius * cos(startAngle.radians),
y: center.y + radius * sin(startAngle.radians)
)
var p = Path()
p.move(to: center)
p.addLine(to: start)
p.addArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: clockwise
)
p.addLine(to: center)
return p
}
}
再将 CardView.swift
中的 circle()
改为 Pie()
。
我们试图创建一个从 12 点钟方向到 8 点钟方向的顺时针饼图,但这似乎不太对劲:
因为这的 0 度和时钟不同,它遵循直角坐标系,是 x 轴正向。
另外,此处的坐标系原点是在左上角,y 轴正向是向下的,所以顺时针方向也和通常的情况相反。接下来进行修正。
// Pie.swift
struct Pie: Shape {
...
func path(in rect: CGRect) -> Path {
let startAngle = startAngle - .degrees(90)
let endAngle = endAngle - .degrees(90)
...
p.addArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: !clockwise
)
...
}
}
修正后预览效果如下:
3. 动画(Animation)
3.1 动画的工作原理
动画是提升移动端用户界面交互体验的重要组成部分。
SwiftUI 提供了便捷的动画工具:
- 通过 Shape 动画化图形。
- 使用 ViewModifiers 动画化视图。
3.2 示例:动画化 Shape
动画化一个 Shape
(例如移动的饼图):
- 可以通过在
Shape
的属性上添加动画支持。 - 示例将在下一节演示中补充。
4. 视图修饰符(ViewModifier)
4.1 什么是 ViewModifier?
SwiftUI 中的修饰符(如 foregroundColor
、font
、padding
)是如何工作的?
它们是 ViewModifier
协议的实现。示例:
.aspectRatio(2/3)
// 实际上等价于:
.modifier(AspectModifier(2/3))
ViewModifier
的核心功能是通过 body(content:)
方法接收一个视图并返回修改后的视图。 ViewModifier
协议只有一个函数。这个函数的唯一任务是根据传入的内容创建一个新的视图。从概念上讲,这个协议大致如下:
protocol ViewModifier {
func body(content: Content) -> some View {
// Content 是一个“稍微关心一下”的视图,即被修饰的视图
return some View,几乎肯定会包含内容
}
}
当我们在一个视图上调用 modifier
时,传递给这个 body
函数的内容就是该视图。例如:
aView.modifier(MyViewModifier(arguments: ...))
MyViewModifier
实现了 ViewModifier
协议,而 aView
将通过 content
传递给它的 body
函数。
4.2 创建自定义 ViewModifier
目标
- 将任意视图转换为“卡片”样式。
使用方法
Text("Hello")
.modifier(Cardify(isFaceUp: true))
.modifier
将返回一个如下的视图
struct Cardify: ViewModifier {
var isFaceUp: Bool
func body(content: Content) -> some View {
ZStack {
if isFaceUp {
RoundedRectangle(cornerRadius: 10).fill(Color.white)
RoundedRectangle(cornerRadius: 10).stroke()
content
} else {
RoundedRectangle(cornerRadius: 10).fill(Color.gray)
}
}
}
}
那我们要如何将其从 .modifier(Cardify(isFaceUp: true))
转变为 .cardify(isFaceUp:true)
呢?通过 extension
来实现
extension View {
func cardify(isFaceUp: Bool) -> some View {
modifier(Cardify(isFaceUp: isFaceUp))
}
}
2.2.3 Demo::实现 Cardify
修饰符
因为不需要预览(preview),所以创建一个 swift 文件,命名为 Cardify.swift
。
我们在 CardView
中截取 Card 部分,并删除会被 Cardify
的部分(即饼图)作为 content,并声明私有结构体 Constants
创建常数的命名空间。
// Cardify.swift
import SwiftUI
struct Cardify: ViewModifier {
let isFaceUp: Bool
func body(content: Content) -> some View {
ZStack {
let base = RoundedRectangle(cornerRadius: Constants.cornerRadius)
Group {
base.fill(.white)
base.strokeBorder(lineWidth: Constants.lineWidth)
content
}
.opacity(isFaceUp ? 1 : 0)
base.fill()
.opacity(isFaceUp ? 0 : 1)
}
}
private struct Constants {
static let cornerRadius: CGFloat = 12
static let lineWidth: CGFloat = 2
}
}
// CardView.swift
struct CardView: View {
...
var body: some View {
Pie(endAngle: .degrees(240))
.opacity(Constants.Pie.opacity)
.overlay(
Text(card.content)
.font(.system(size: Constants.FontSize.largest))
.minimumScaleFactor(Constants.FontSize.scaleFactor)
.multilineTextAlignment(.center)
.aspectRatio(1, contentMode: .fit)
.padding(Constants.Pie.inset)
)
.padding(Constants.inset)
.modifier(Cardify(isFaceUp: card.isFaceUp))
.opacity(card.isFaceUp || !card.isMatched ? 1 : 0)
}
private struct Constants {
static let inset: CGFloat = 5
struct FontSize {
...
}
struct Pie {
...
}
}
}
...
我们想将 .modifier
变为 .cardify
则需要添加一个协议扩展:
//Cardify.swift
struct Cardify: ViewModifier {
...
func body(content: Content) -> some View {
ZStack {
let base = RoundedRectangle(cornerRadius: Constants.cornerRadius)
base.strokeBorder(lineWidth: Constants.lineWidth)
.background(base.fill(.white))
.overlay(content)
.opacity(isFaceUp ? 1 : 0)
base.fill()
.opacity(isFaceUp ? 0 : 1)
}
}
...
}
extension View {
func cardify(isFaceUp: Bool) -> some View {
modifier(Cardify(isFaceUp: isFaceUp))
}
}
// CardView.swift
struct CardView: View {
...
var body: some View {
Pie(endAngle: .degrees(240))
.opacity(Constants.Pie.opacity)
.overlay(
...
)
.padding(Constants.inset)
.Cardify(isFaceUp: card.isFaceUp)
.opacity(card.isFaceUp || !card.isMatched ? 1 : 0)
}
...
}
...
5.1 协议的用途
5.1.1 协议的核心作用
- 协议是 Swift 中用于定义行为和约定的工具。
- 它是代码共享的核心,允许在多个类型之间复用功能。
- 协议可以通过**扩展(
extension
)**来添加实现。- 示例:
View
协议的扩展为视图提供了许多修饰符,例如foregroundColor
和font
。- 通过扩展,开发者可以在协议中添加函数和属性的默认实现。
- 例如:
ObservableObject
协议通过扩展获得了objectWillChange
的默认实现。
- 示例:
5.1.2 协议扩展的例子
5.1.2.1 示例 1:在协议中添加修饰符功能
extension View {
func foregroundColor(_ color: Color) -> some View {
// 实现代码
}
func font(_ font: Font?) -> some View {
// 实现代码
}
}
- 这些扩展使视图类型(如
Text
和Image
)自动获得上述修饰符功能。
5.1.2.2 示例 2:filter
的实现
filter
函数通过扩展被添加到 Sequence
协议中:
extension Sequence {
func filter(_ isIncluded: (Element) -> Bool) -> [Element] {
// 实现代码
}
}
这意味着,filter
可以直接用于 Array
、Range
和 Dictionary
等类型,而无需单独实现。
5.2 泛型与协议
5.2.1 协议中的泛型类型
- 协议支持泛型(“don’t care” 类型),允许它与不同类型结合使用。
associatedtype
关键字:- 用于声明协议中的泛型类型。
- 示例:
Identifiable
协议:protocol Identifiable { associatedtype ID var id: ID { get } }
ID
是一个泛型类型,它可以是任何类型。
5.2.2 使用泛型约束
- 可以通过泛型约束限制泛型类型的行为。
- 示例:让
ID
必须遵循Hashable
协议:protocol Identifiable { associatedtype ID: Hashable var id: ID { get } }
- 解释:
Hashable
是一个协议,保证ID
可以被用于哈希表中(例如字典或集合)。- 这样可以确保
Identifiable
类型的对象是可查找的。
- 示例:让
5.3 View
协议与 SwiftUI 类型系统
5.3.1 View
协议的结构
- SwiftUI 中的
View
协议大致如下:protocol View { var body: some View { get } }
- 所有的视图(如
Text
、Button
等)都需要实现body
属性,并返回一个符合View
协议的类型。
- 所有的视图(如
5.3.2 扩展 View
协议
- 通过扩展
View
协议,SwiftUI 添加了许多常用功能:- 修饰符方法(如
foregroundColor
和font
):extension View { func foregroundColor(_ color: Color) -> some View { // 实现 } func font(_ font: Font?) -> some View { // 实现 } }
- 这些方法为实现了
View
协议的所有类型(如Text
)提供了额外的功能。
- 修饰符方法(如
5.4 some
和 any
的用法
5.4.1 some
关键字
some
用于返回不透明类型。- 返回不透明类型的示例:
var body: some View { RoundedRectangle(cornerRadius: 10) }
body
的返回值是some View
,表示它返回的是一种符合View
协议的具体类型,但具体是什么类型由 Swift 推断。- 注意:
some
类型在所有执行路径中必须一致。
5.4.2 使用 some
创建函数示例
示例:返回不同的 Shape
类型:
func getShape(rounded: Bool) -> some Shape {
if rounded {
return RoundedRectangle(cornerRadius: 12)
} else {
return Rectangle()
}
}
返回值是 some Shape
,保证调用方只需知道结果是某种 Shape
。
5.4.3 any
关键字
-
any
用于创建协议类型的异构集合。 -
示例:协议类型数组
let ids: [any Identifiable] = []
ids
是一个可以存放不同Identifiable
类型实例的数组。
-
限制:
- 要操作
any
协议类型的对象,必须通过接受具体协议的函数。 - 示例:
func printId(of identifiable: some Identifiable) { print(identifiable.id) }
- 要操作
5.6 Identifiable
协议的例子
5.6.1 协议定义
Identifiable
是一个 Swift 标准库协议,定义如下:protocol Identifiable { associatedtype ID: Hashable var id: ID { get } }
- 用途:通过唯一标识符
id
区分对象。
- 用途:通过唯一标识符
5.6.2 示例应用
- 在内存游戏中,为卡片定义标识符:
struct Card: Identifiable { var id: String // `id` 遵循 `Hashable` 协议 var isFaceUp: Bool }