Stanford-cs193p-07|Shape-ViewModifier-Constants

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

...

另外,对 EmojiMemoryGameMemoryGame<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)
       }
   }

预览效果如下:

cs193p|07|Shape ViewModifier Constants-1

我们发现在有多行文本时,视图文本会靠左对齐,对 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
		}
	}
}

但我们会注意到,还会有一些常量 10 或者颜色常量没有添加到结构体中,因为通常不具有特别的意义,当然根据情况也可以添加到私其中。

另外,还有一些特别的颜色,比如卡片游戏中的颜色背景,没有添加。因为我们希望将其置于视图层级来调整它们,它属于游戏配置。

//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,比如 RoundedRectangleCircleCapsule 等。

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 协议通过对形状应用某些样式将形状转换为视图。这样的例子包括:ColorImagePaintAngularGradientLinearGradient

2.1.2 实现 Shape 协议的方法:

但是要怎么实现自定义 Shape 呢?

实现 shape 协议必须实现以下方法:

  func path(in rect: CGRect) -> Path

该方法返回一个 Path,用于定义图形的路径。

Path 的能力:

  • 支持添加线条、弧线、贝塞尔曲线等。
  • 可以通过组合这些路径创建任意形状。

2.2 Demo示例:绘制一个未动画化的“倒计时饼图”

为了更清晰的观察饼图的样式:

  • 我们先将卡片数量设置为 4 张(EmojiMemoryGame.swift)
  • 使卡片默认朝上(MemoryGame.swift)
  • 在 Card 中添加一个 Circle 并调整 paddingopacity,将 Text 放置在 circleoverlay 中,由于 paddingopacity 的值为常数,我们将其添加到下面的常量中(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 点钟方向的顺时针饼图,但这似乎不太对劲:

cs193p|07|Shape ViewModifier Constants-2

因为这的 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
        )
		...
    }
}

修正后预览效果如下:
cs193p|07|Shape ViewModifier Constants-3

3. 动画(Animation)

3.1 动画的工作原理

动画是提升移动端用户界面交互体验的重要组成部分。

SwiftUI 提供了便捷的动画工具:

  • 通过 Shape 动画化图形。
  • 使用 ViewModifiers 动画化视图。
3.2 示例:动画化 Shape

动画化一个 Shape(例如移动的饼图):

  • 可以通过在 Shape 的属性上添加动画支持。
  • 示例将在下一节演示中补充。

4. 视图修饰符(ViewModifier)

4.1 什么是 ViewModifier?

SwiftUI 中的修饰符(如 foregroundColorfontpadding)是如何工作的?

它们是 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 协议的扩展为视图提供了许多修饰符,例如 foregroundColorfont
      • 通过扩展,开发者可以在协议中添加函数和属性的默认实现。
      • 例如:ObservableObject 协议通过扩展获得了 objectWillChange 的默认实现。
5.1.2 协议扩展的例子
5.1.2.1 示例 1:在协议中添加修饰符功能
extension View {
    func foregroundColor(_ color: Color) -> some View {
        // 实现代码
    }
    func font(_ font: Font?) -> some View {
        // 实现代码
    }
}
  • 这些扩展使视图类型(如 TextImage)自动获得上述修饰符功能。
5.1.2.2 示例 2:filter 的实现

filter 函数通过扩展被添加到 Sequence 协议中:

extension Sequence {
	func filter(_ isIncluded: (Element) -> Bool) -> [Element] {
		// 实现代码
	}
}

这意味着,filter 可以直接用于 ArrayRangeDictionary 等类型,而无需单独实现。

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 }
    }
    
    • 所有的视图(如 TextButton 等)都需要实现 body 属性,并返回一个符合 View 协议的类型。
5.3.2 扩展 View 协议
  • 通过扩展 View 协议,SwiftUI 添加了许多常用功能:
    • 修饰符方法(如 foregroundColorfont):
      extension View {
          func foregroundColor(_ color: Color) -> some View {
              // 实现
          }
          func font(_ font: Font?) -> some View {
              // 实现
          }
      }
      
    • 这些方法为实现了 View 协议的所有类型(如 Text)提供了额外的功能。

5.4 someany 的用法

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
    }
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值