SwiftUI 是 Apple 推出的声明式框架,用于构建用户界面(UI)。 自2019年首次发布以来,SwiftUI 已成为 iOS、macOS、watchOS 和 tvOS 应用开发的重要工具。 本文将深入介绍 SwiftUI 的基本概念,包括视图(View)、修饰符(Modifiers)、内置修饰符、如何自定义视图、 组件化开发以及 MVVM 架构在 SwiftUI 中的应用。无论你是刚开始学习 SwiftUI,还是希望巩固基础,这篇指南都将为你提供有价值的知识。

什么是 SwiftUI

SwiftUI 是 Apple 推出的用于构建用户界面的框架,采用声明式语法,使开发者能够更简洁、高效地创建复杂的界面。与传统的 UIKit 不同,SwiftUI 允许开发者通过声明视图的状态和布局来自动管理 UI 更新,从而减少了大量的样板代码。

SwiftUI 的优势

  • 声明式语法:通过描述界面的状态,SwiftUI 自动处理界面的更新。
  • 跨平台支持:适用于 iOS、macOS、watchOS 和 tvOS。
  • 实时预览:Xcode 提供的 Canvas 允许开发者实时预览和调试 UI。
  • 高效开发:减少样板代码,提高开发效率。
  • 响应式编程:SwiftUI 内置支持响应式编程范式,简化数据与UI的绑定。

什么是视图(View)

在 SwiftUI 中,视图(View) 是构建用户界面的基本单元。每一个视图都代表 UI 的一个部分,例如文本、按钮、图像等。视图可以嵌套和组合,以创建复杂的界面。

常见的 SwiftUI 视图

  • Text:显示文本内容。
  • Image:显示图片。
  • Button:交互式按钮。
  • VStackHStack:垂直和水平堆叠视图。
  • List:显示可滚动的列表。
  • ScrollView:实现可滚动的内容视图。
  • Spacer:在堆叠视图中添加弹性空间。
  • NavigationView:实现导航功能。

示例:创建一个简单的文本视图

1
import SwiftUIstruct ContentView: View {    var body: some View {        Text("Hello, SwiftUI!")    }}

在上述示例中,Text 视图用于显示一段文本。

什么是修饰符(Modifiers)

修饰符(Modifiers) 是 SwiftUI 中用于修改视图属性的方法。通过链式调用修饰符,开发者可以轻松地调整视图的外观和行为,例如颜色、字体、边距等。

使用修饰符的示例

1
Text("Hello, SwiftUI!")    .font(.title)    .foregroundColor(.blue)    .padding()

在这个例子中,Text 视图被应用了三个修饰符:font 修改字体大小,foregroundColor 修改文本颜色,padding 添加内边距。

什么是内置修饰符

SwiftUI 提供了丰富的内置修饰符,开发者可以直接使用这些修饰符来调整视图的各种属性。以下是一些常用的内置修饰符:

  • font(_:):设置字体和字体大小。
  • foregroundColor(_:):设置前景色(如文本颜色)。
  • background(_:):设置视图的背景颜色或视图。
  • padding(_:):设置视图的内边距。
  • frame(width:height:):设置视图的宽度和高度。
  • cornerRadius(_:):设置视图的圆角半径。
  • shadow(color:radius:x:y:):添加阴影效果。
  • opacity(_:):设置视图的不透明度。
  • rotationEffect(_:):旋转视图。
  • scaleEffect(_:):缩放视图。

示例:使用多个内置修饰符

1
Text("Welcome to SwiftUI")    .font(.largeTitle)    .foregroundColor(.white)    .padding()    .background(Color.blue)    .cornerRadius(10)    .shadow(color: .gray, radius: 5, x: 0, y: 5)

在这个例子中,Text 视图应用了多个内置修饰符,改变了字体、颜色、背景、圆角和阴影效果。

如何自定义视图

除了使用内置视图和修饰符,SwiftUI 允许开发者创建自定义视图。通过组合多个视图和修饰符,可以创建可重用且模块化的 UI 组件。

创建自定义视图的步骤

  1. 定义新的视图结构:创建一个遵循 View 协议的结构体。
  2. 实现 body 属性:在 body 中定义视图的内容和布局。
  3. 组合视图和修饰符:使用内置视图和修饰符来设计自定义视图。

示例:创建一个自定义按钮视图

1
import SwiftUIstruct CustomButton: View {    var title: String    var backgroundColor: Color    var body: some View {        Text(title)            .font(.headline)            .foregroundColor(.white)            .padding()            .background(backgroundColor)            .cornerRadius(8)            .shadow(radius: 5)    }}struct ContentView: View {    var body: some View {        CustomButton(title: "Click Me", backgroundColor: .green)    }}

在这个示例中,CustomButton 是一个自定义视图,通过组合 Text 视图和多个修饰符,创建了一个带有背景色、圆角和阴影效果的按钮。

视图与组件化开发

组件化开发 是现代软件开发中的一种最佳实践,通过将 UI 分解为可重用的组件,提升代码的可维护性和可扩展性。在 SwiftUI 中,视图本身就是组件的基本形式,开发者可以通过创建自定义视图来实现组件化开发。

组件化的优势

  • 可重用性:相同的组件可以在不同的地方重复使用,减少代码冗余。
  • 可维护性:组件独立,便于管理和更新。
  • 可测试性:独立的组件更容易进行单元测试。

示例:创建多个自定义组件

1
struct HeaderView: View {    var title: String    var body: some View {        Text(title)            .font(.largeTitle)            .padding()            .background(Color.orange)            .foregroundColor(.white)    }}struct FooterView: View {    var body: some View {        Text("© 2025 Your Company")            .font(.footnote)            .padding()            .background(Color.gray.opacity(0.2))    }}struct ContentView: View {    var body: some View {        VStack {            HeaderView(title: "Welcome to My App")            Spacer()            FooterView()        }    }}

在这个示例中,HeaderViewFooterView 是两个独立的自定义组件,通过组合它们构建了一个完整的界面。

MVVM 架构简介

MVVM(Model-View-ViewModel) 是一种软件架构模式,旨在将应用程序的业务逻辑与用户界面分离。MVVM 提供了一种清晰的方式来组织代码,使其更易于维护、测试和扩展。

MVVM 的组成部分

  • Model(模型):表示应用程序的数据和业务逻辑。通常与数据存储和网络请求相关。
  • View(视图):负责显示数据和处理用户交互。SwiftUI 中的视图如 TextImage 等。
  • ViewModel(视图模型):充当 Model 和 View 之间的中介。它处理业务逻辑,将 Model 中的数据转换为 View 可以直接使用的格式,并响应用户交互。

MVVM 的优势

  • 分离关注点:将 UI 与业务逻辑分离,提升代码的可维护性。
  • 可测试性:ViewModel 可以独立于 View 进行单元测试。
  • 可重用性:ViewModel 可以在多个 View 中复用。

SwiftUI 中的 MVVM 实现

在 SwiftUI 中,MVVM 架构通过结合 SwiftUI 的声明式语法和数据绑定机制,实现了 Model、View 和 ViewModel 的分离与协作。

关键概念

  • @State:用于在 View 中存储和管理局部状态。
  • @ObservableObject:用于声明一个可观察的对象,通常在 ViewModel 中使用。
  • @Published:用于在 ObservableObject 中标记可以被观察的属性。
  • @StateObject@ObservedObject:用于在 View 中引用 ViewModel。

示例:使用 MVVM 架构的简单计数器应用

Model

1
struct CounterModel {    var count: Int = 0}

ViewModel

1
import Combineclass CounterViewModel: ObservableObject {    @Published var counter: CounterModel    init(counter: CounterModel = CounterModel()) {        self.counter = counter    }    func increment() {        counter.count += 1    }    func decrement() {        counter.count -= 1    }}

View

1
import SwiftUIstruct CounterView: View {    @StateObject private var viewModel = CounterViewModel()    var body: some View {        VStack(spacing: 20) {            Text("Count: \(viewModel.counter.count)")                .font(.largeTitle)            HStack(spacing: 40) {                Button(action: {                    viewModel.decrement()                }) {                    Text("-")                        .font(.title)                        .frame(width: 60, height: 60)                        .background(Color.red)                        .foregroundColor(.white)                        .clipShape(Circle())                }                Button(action: {                    viewModel.increment()                }) {                    Text("+")                        .font(.title)                        .frame(width: 60, height: 60)                        .background(Color.green)                        .foregroundColor(.white)                        .clipShape(Circle())                }            }        }        .padding()    }}

在这个示例中:

  • ModelCounterModel 包含一个 count 属性,表示当前计数值。
  • ViewModelCounterViewModel 作为 ObservableObject,管理 CounterModel 的实例,并提供 incrementdecrement 方法来修改计数值。
  • ViewCounterView 使用 @StateObject 引用 CounterViewModel,并通过数据绑定 (viewModel.counter.count) 显示计数值,同时通过按钮调用 ViewModel 的方法来修改计数。

MVVM 架构关系图

以下图示展示了 MVVM 架构在 SwiftUI 中的关系:

1
graph LR    Model --> ViewModel    ViewModel --> View    View --> ViewModel
  • Model:提供数据和业务逻辑。
  • ViewModel:持有 Model 的实例,处理业务逻辑,并通过 @Published 属性将数据暴露给 View。
  • View:通过 @StateObject@ObservedObject 引用 ViewModel,使用数据绑定显示数据,并通过用户交互调用 ViewModel 的方法。

基于SwiftUI的项目如何设置MVVM

在 SwiftUI 项目中应用 MVVM 架构,可以按照以下步骤进行设置:

1. 创建 Model

首先,定义应用程序的数据结构和业务逻辑。例如,一个简单的用户模型:

1
struct User: Identifiable {    let id: UUID    let name: String    let email: String}

2. 创建 ViewModel

创建一个 ObservableObject 类来管理 Model 的数据和业务逻辑:

1
import Combineclass UserViewModel: ObservableObject {    @Published var users: [User] = []    func fetchUsers() {        // 模拟网络请求或数据获取        users = [            User(id: UUID(), name: "Alice", email: "alice@example.com"),            User(id: UUID(), name: "Bob", email: "bob@example.com")        ]    }    func addUser(name: String, email: String) {        let newUser = User(id: UUID(), name: name, email: email)        users.append(newUser)    }    func removeUser(at offsets: IndexSet) {        users.remove(atOffsets: offsets)    }}

3. 创建 View

在 View 中引用 ViewModel 并使用数据绑定展示和操作数据:

1
import SwiftUIstruct UserListView: View {    @StateObject private var viewModel = UserViewModel()    var body: some View {        NavigationView {            List {                ForEach(viewModel.users) { user in                    VStack(alignment: .leading) {                        Text(user.name)                            .font(.headline)                        Text(user.email)                            .font(.subheadline)                            .foregroundColor(.gray)                    }                }                .onDelete(perform: viewModel.removeUser)            }            .navigationTitle("用户列表")            .navigationBarItems(trailing: Button(action: {                viewModel.addUser(name: "新用户", email: "newuser@example.com")            }) {                Image(systemName: "plus")            })            .onAppear {                viewModel.fetchUsers()            }        }    }}

4. 组织项目结构

为了更好地组织代码,建议按照 MVVM 的结构将项目分为不同的文件夹:

1
YourProject/├── Models/│   └── User.swift├── ViewModels/│   └── UserViewModel.swift├── Views/│   └── UserListView.swift└── YourProjectApp.swift

5. 使用依赖注入(可选)

为了提高代码的可测试性和可扩展性,可以使用依赖注入将 ViewModel 注入到 View 中。例如:

1
struct UserListView: View {    @ObservedObject var viewModel: UserViewModel    var body: some View {        // 与之前相同    }}// 在入口处注入 ViewModelstruct YourProjectApp: App {    var body: some Scene {        WindowGroup {            UserListView(viewModel: UserViewModel())        }    }}

6. 添加更多功能

随着项目的发展,可以在 ViewModel 中添加更多的方法和属性,处理更复杂的业务逻辑,同时保持 View 的简洁和专注于展示。

SwiftUI 术语汇总

为了更好地理解 SwiftUI,以下是一些常见的术语及其解释:

  • 声明式编程(Declarative Programming):一种编程范式,开发者描述 UI 应该是什么样子,系统负责管理其状态和更新。
  • 视图(View):SwiftUI 中的基本构建块,用于构建用户界面。
  • 修饰符(Modifier):用于修改视图属性的方法,采用链式调用方式。
  • 布局容器(Layout Containers):用于组织和布局视图的容器,如 VStackHStackZStack
  • 状态(State):视图的数据源,影响视图的显示和行为。
  • 绑定(Binding):一种双向数据流机制,使视图与其数据源保持同步。
  • 环境(Environment):提供全局数据和配置,视图可以从环境中读取或写入数据。
  • 预览(Preview):Xcode 提供的实时预览功能,允许开发者在编写代码时即时查看 UI 的变化。
  • ObservableObject:一种可以被多个视图观察的对象,当其中的 @Published 属性发生变化时,视图会自动更新。
  • @State:用于在视图内部存储和管理局部状态。
  • @ObservedObject:用于在视图中观察外部的 ObservableObject,视图会在对象变化时自动更新。
  • @EnvironmentObject:用于在多个视图中共享一个 ObservableObject,无需手动传递。

总结

SwiftUI 以其声明式语法和强大的功能,正在迅速改变 iOS 和其他 Apple 平台的应用开发方式。理解 SwiftUI 的核心概念,如视图(View)、修饰符(Modifiers)、内置修饰符、如何自定义视图和组件化开发,是掌握这一框架的关键。通过引入 MVVM 架构,开发者可以进一步提升代码的可维护性和可测试性,构建出高效、可扩展的应用。

本文介绍了 SwiftUI 的基本概念和术语,展示了如何创建和使用自定义视图,并深入探讨了 MVVM 架构在 SwiftUI 中的实现方式。掌握这些知识后,你将能够在 SwiftUI 项目中应用最佳实践,打造出优雅且高效的用户界面。

继续深入学习 SwiftUI 的高级功能,如动画、数据绑定和自定义控件,将进一步提升你的开发技能,帮助你打造出更具吸引力和互动性的应用。

留言與分享

SwiftUI文档

分類 编程语言, SwiftUI

SwiftUI简介

SwiftUI是一种更为现代的编码方式,他可以为苹果任意平台声明用户界面。以更为快捷迅速的方式创建漂亮的动态应用程序!

只需描述你的布局

为视图的任何状态声明内容和布局。SwiftUI知道该状态何时更改,并更新视图,以匹配其呈现的状态。

1
2
3
4
5
6
7
8
9
10
11
12
List(landmarks) { landmark in
HStack {
Image(landmark.thumbnail)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
}

构建可重用组件

将小的、单一职责的视图组合成更大、更复杂的界面。在为苹果任意平台设计的应用程序之间共享您的自定义视图。

1
2
3
4
5
6
7
8
9
10
struct FeatureCard: View {
var landmark: Landmark

var body: some View {
landmark.featureImage
.resizable()
.aspectRatio(3/2, contentMode: .fit)
.overlay(TextOverlay(landmark))
}
}

简化动画

创建平滑动画就像添加一个方法调用一样简单。SwiftUI会在需要时自动计算并设置过渡动画。

1
2
3
4
5
6
7
8
VStack {
Badge()
.frame(width: 300, height: 300)
.animation(.easeInOut())
Text(name)
.font(.title)
.animation(.easeInOut())
}

在Xcode中实时预览

在不运行应用程序的情况下设计、生成和测试应用程序的界面。使用交互式预览测试控件和布局。

应用程序设计和布局

组合复杂界面

地标的首页显示一个滚动的分类列表,在每个分类中水平滚动地标。在构建此主导航时,您将探索组合视图如何适应不同的设备大小和方向。

学习时间:20分钟

下载地址:ComposingComplexInterfaces.zip

第一节 添加首页

既然你已经拥有了地标应用程序所需的所有视图,现在是时候给它们一个首页了——一个统一视图页面。主视图不仅包含所有其他视图,还提供导航和显示地标的方法。

步骤1

在名为Home.swift的新文件中创建名为CategoryHome的自定义视图。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct CategoryHome: View {
var body: some View {
Text("Landmarks Content")
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤2

修改scene delegate,使其显示新的CategoryHome视图而不是地标列表。

主视图是地标应用程序的根,因此它需要一种方式来呈现所有其他视图。

SceneDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(
rootView: CategoryHome()
.environmentObject(UserData())
)
self.window = window
window.makeKeyAndVisible()
}
}
}

步骤3

添加NavigationView以在地标中承载不同的视图。

您可以使用NavigationView以及NavigationLink实例和相关修饰符在应用程序中构建导航层级结构。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct CategoryHome: View {
var body: some View {
NavigationView {
Text("Landmarks Content")
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤4

将导航栏的标题设置为“Featured”。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SwiftUI

struct CategoryHome: View {
var body: some View {
NavigationView {
Text("Landmarks Content")
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

第二节 创建分类列表

Landmarks应用程序将所有分类显示在垂直列中的单独行中,以便于浏览。通过组合垂直和水平堆栈,并向列表中添加滚动条,就可以完成此操作。

步骤1

使用Dictionary结构的init(grouping:by:)方法将地标分组到类别中,输入地标的category属性。

初始化项目文件给每个示例地标预设了类别。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var body: some View {
NavigationView {
Text("Landmarks Content")
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤2

使用列表在地标中显示分类。

Landmark.Category会匹配列表中每一项name,这些项在其他类别中必须是唯一的,因为它是枚举。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var body: some View {
NavigationView {
List {
ForEach(categories.keys.sorted(), id: \.self) { key in
Text(key)
}
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

第三节 添加地标行

地标在水平滚动的行中显示每个类别。添加一个新的视图类型来表示行,然后在新视图中显示该类别的所有地标。

步骤1

定义用于保存行内容的新自定义视图。

这个视图需要存储地标的类别信息,以及地标本身。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
Text(self.categoryName)
.font(.headline)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(3))
)
}
}

步骤2

更新CategoryHome的主体以将类别信息传递给新的行类型。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var body: some View {
NavigationView {
List {
ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤3

HStack中显示该类别的地标。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
Text(landmark.name)
}
}
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(3))
)
}
}

步骤4

通过指定一个frame(width:height:) 并在scrollView中包装stack,为行提供空间。

使用长数据样本更新视图预览,以便确保可以滚动。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
Text(landmark.name)
}
}
}
.frame(height: 185)
}
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

第四节 组成主页

地标应用程序的主页需要显示地标的简单信息,然后用户点击其中一个更多进入详情视图。

重新使用在Creating and Combining Views中的视图来创建更简单的视图预览,以显示地标的分类和特征。

步骤1

CategoryRow旁边创建一个名为CategoryItem的新自定义视图,并用新视图替换Text视图。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
CategoryItem(landmark: landmark)
}
}
}
.frame(height: 185)
}
}
}

struct CategoryItem: View {
var landmark: Landmark
var body: some View {
VStack(alignment: .leading) {
landmark.image
.resizable()
.frame(width: 155, height: 155)
.cornerRadius(5)
Text(landmark.name)
.font(.caption)
}
.padding(.leading, 15)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

步骤2

Home.swift中,添加一个名为FeaturedLandmarks的简单视图,该视图仅显示标记为isFeatured的地标。

在后面的教程中,您将把此视图转换为可交互的轮播视图。目前,它显示了一个裁剪和缩放后的预览图像。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤3

在两种地标预览中将edge insets设置为零,以便内容可以延伸到屏幕边缘。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

第五节 在分区之间添加导航

现在,在主页中可以看到所有不同分类的地标,用户需要一种方法来访问应用程序中的每个部分。使用navigationpresentation Apis可以从主页导航到地标详情页,收藏夹和用户主页。

步骤1

CategoryRow.swift中,用NavigationLink包装现有的CategoryItem

类别项本身是按钮的label,其目的地是显示地标详情视图。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
NavigationLink(
destination: LandmarkDetail(
landmark: landmark
)
) {
CategoryItem(landmark: landmark)
}
}
}
}
.frame(height: 185)
}
}
}

struct CategoryItem: View {
var landmark: Landmark
var body: some View {
VStack(alignment: .leading) {
landmark.image
.resizable()
.frame(width: 155, height: 155)
.cornerRadius(5)
Text(landmark.name)
.font(.caption)
}
.padding(.leading, 15)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

注意

Xcode11 beta 6中,如果在列表中嵌套一个ScrollView,并且该ScrollView包含一个NavigationLink,那么当用户点击NavigationLink时,这些NavigationLink不会导航到目标视图。

步骤2

通过应用renderingMode(:)foregroundColor(:)修饰符更改导航外观。

作为NavigationLinklabel传递的文本使用环境的强调色进行渲染,而图像可能被作为template images进行渲染。您可以修改任一行为以最适合您的设计。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
NavigationLink(
destination: LandmarkDetail(
landmark: landmark
)
) {
CategoryItem(landmark: landmark)
}
}
}
}
.frame(height: 185)
}
}
}

struct CategoryItem: View {
var landmark: Landmark
var body: some View {
VStack(alignment: .leading) {
landmark.image
.renderingMode(.original)
.resizable()
.frame(width: 155, height: 155)
.cornerRadius(5)
Text(landmark.name)
.foregroundColor(.primary)
.font(.caption)
}
.padding(.leading, 15)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

步骤3

Home.swift中,点击选项卡栏中的简介图标后,添加一个模态视图以显示用户简介页面。

showProfile状态变量设置为true时,SwiftUI显示用户简介占位符,当用户关闭模态时,将showProfile设置回false

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.navigationBarTitle(Text("Featured"))
.sheet(isPresented: $showingProfile) {
Text("User Profile")
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤4

在导航栏中添加一个按钮,在点击时将showProfilefalse切换为true

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
.init(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
Text("User Profile")
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image(forSize: 250).resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤5

通过添加导航链接完成主页,该链接指向可过滤所有地标的列表。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())

NavigationLink(destination: LandmarkList()) {
Text("See All")
}
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
Text("User Profile")
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤6

LandmarkList.swift中,删除包装地标列表的NavigationView,并将其添加到预览中。

在应用程序的环境中,LandmarkList将始终显示在Home.swift中声明的导航视图中。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {

List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))

}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
LandmarkList()
.environmentObject(UserData())
}
}
}

SwiftUI纲要

创建和组织视图

本教程将指导您构建一个iOS应用程序——Landmarks(地标),用于发现和分享您喜欢的地方。首先,您将构建显示Landmark的详情页面。

为了布局视图,"地标"使用stacks来对image、text等组件进行组织和分层。要将地图添加到视图中,需要包含一个标准的MapKit组件。当您优化视图的设计时,Xcode提供实时反馈,这样当您修改代码时,就可以看到视图状态的改变。

下载项目文件开始构建此项目,并按照以下步骤操作:

学习时长:40分钟

下载示例:CreatingAndCombiningViews.zip

第一节 创建新项目并浏览画布

创建一个使用SwiftUI的新Xcode项目。浏览画布、预览和SwiftUI模板代码。

要在Xcode中预览画布上的视图并与之交互,请确保Mac运行的是macOS Catalina 10.15。

步骤一

打开Xcode并在Xcode的启动窗口中单击Create a new Xcode项目,或者选择File>new>project

步骤二

在模板选择器中,选择iOS平台,选择单视图应用程序模板,然后单击下一步。

步骤三

输入“Landmarks”作为产品名称,选中Use SwiftUI复选框,然后单击Next。选择一个路径来保存你的项目。

步骤四

在项目导航器中,单击以选择ContentView.swift。

默认情况下,SwiftUI视图文件声明两个结构。第一个结构实现了视图的必选协议,并描述了视图的内容和布局。第二个结构声明了该视图的预览。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

//
struct ContentView: View {
var body: some View {
Text("Hello World")
}
}
//

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤五

在画布中,单击Resume以显示预览。

Tips:如果画布不可见,请选择Editor > Editor and Canvas以显示它。

步骤六

body属性中,将“Hello World”更改为自己的问候语。

当您在视图的body属性中更改代码时,预览将更新以反映您的更改。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct ContentView: View {
var body: some View {
Text("Hello SwiftUI!")
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

第二节 自定义文本视图

您可以通过更改代码,或使用检查器查看可用的内容,并帮助您编写代码,来自定义视图的显示。

构建Lanmarks应用程序时,可以使用任意编辑器组合:源码编辑、画布或检查器。无论使用哪种工具,代码都会保持更新。

接下来,您将使用检查器自定义文本视图。

步骤一

在预览中,按住command并单击问候语以打开结构化编辑弹出窗口,然后选择Inspect

弹出窗口显示可自定义的不同属性,具体取决于所检查的视图类型。

步骤二

使用检查器将文本更改为“Turtle Rock”,即您将在应用程序中显示的第一个地标的名称。

步骤三

font更改为Title

这会将系统字体应用于该文本,以便它正确适应用户系统偏好的字体大小和设置。

要自定义SwiftUI视图,可以调用名为修饰符(modifier)的方法。修饰符会包装视图以更改其显示或其他属性。每个修饰符都返回一个新视图,因此通常采用垂直的链式调用多个修饰符。

步骤四

手动编辑代码添加foregroundColor(.green)修饰符;这会将文本的颜色更改为绿色。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct ContentView: View {
var body: some View {
Text("Turtle Rock")
.font(.title)
.foregroundColor(.green)
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

您的代码始终真实反应到视图上。因此当使用检查器更改或移除修饰符时,Xcode会立即更新视图以匹配代码。

步骤五

然后我们在代码编辑区,按住command并单击Text来打开检查器,然后从弹出窗口中选择Inspect。单击Color弹出菜单,然后选择Inherited , 将文本颜色再次更改为黑色。

步骤六

请注意,Xcode会自动更新代码以反映您的更改,并删除foregroundColor(.green)修饰符。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct ContentView: View {
var body: some View {
Text("Turtle Rock")
.font(.title)

}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

第三节

使用堆栈组合视图

除了在上一节中创建的标题视图之外,还将添加文本视图,以包含有关地标的详细信息,例如公园的名称和所在的州。

创建SwiftUI视图时,可以在视图的body属性中描述其内容、布局和行为;但是,body属性只返回单个视图。您可以将多个视图组合并嵌入到Stack中,从而将视图水平、垂直或前后组合在一起。

在本节中,您将使用VStack(垂直堆栈)组合标题信息,使用HStack(水平堆栈)组合公园详情信息。

你可以使用Xcode的结构化编辑功能在容器视图中嵌入视图,也可以打开Inspector 或使用help进行其他更多有用的更改。

步骤一

按住command并单击Text视图,会看到初始值设定项的显示结构化编辑弹出窗口,然后选择Embed in VStack

接下来,通过从组件库中拖动Text视图,将Text视图添加到Stack中。

通过单击Xcode窗口右上角的加号按钮(+)打开组件库,然后将Text视图拖到Turtle Rock文本视图下面的位置。

步骤三

Text视图的默认文本替换为“Joshua Tree National Park”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

自定义位置文本的样式,以满足布局需求。

步骤四

将位置的font设置为subheadline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
.font(.subheadline)
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤五

编辑VStack初始值设定项,以leading方式对齐视图。

默认情况下,VStack会将它们的内容按轴中心对齐,并提供上下文适当的间距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
.font(.subheadline)
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

接下来,您将在位置文本的右侧添加另一个文本视图,该视图用于展示公园所在的州。

步骤六

在画布中,按住command并单击Joshua Tree National Park,然后选择Embed In HStack

步骤七

在地点文本后添加新的文本视图,将默认文本更改为公园所在的州,然后将其font设置为subheadline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Text("California")
.font(.subheadline)
}
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤八

若想使布局自动撑开并充满设备的宽,在HStack中添加Spacer来分隔Joshua Tree National ParkCalifornia两个文本视图。

Spacer将撑开Stack,以使其包含的视图充满其父视图的所有空间,而不是仅由其内容定义其大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤九

最后,使用padding()修饰符方法给整个地标信息区域加一个边距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

第四节

创建自定义图像视图

在名称和地点视图都设置好的情况下,接下来要做的是为地标添加一个图像。

对自定义的图像使用遮罩、边框和阴影,和原来的方法相比,将使用很少的代码。

首先将图片添加到项目的Assets中。

步骤一

在项目Resources文件夹的中找到turtlerock.png,将其拖到Assets中。Xcode为图像创建一个新的图像集。

接下来,您将为您的自定义图像视图创建一个新的SwiftUI视图。

步骤二

选择File > New > File再次打开模板选择器。在User Interface中,单击选择SwiftUI View,然后单击Next。将文件命名为CircleImage.swift,然后单击Create

现在您已准备好图像,以满足设计所需。

步骤三

使用Image(:)Text视图替换为Turtle Rock的图像视图。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤四

添加Image.clipShape(Circle())的修饰符,将图像剪裁为圆形。

Circle()是一个可以用作遮罩的形状,或通过给Circle()设置strokefill来绘制视图。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤五

创建另一个带有灰色strokeCircle(),使用.overlay()修饰符,将其覆盖添加到图像的边框中。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.gray, lineWidth: 4))
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤六

接下来,添加半径为10point的阴影。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.gray, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤七

将边框颜色修改为白色。

到此,我们就完成了图像视图。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

第五节

UIKit和SwiftUI配合使用

现在可以创建地图视图了,您可以使用MapKit中的MKMappView类来绘制地图。

要在SwiftUI中使用UIView子类,可以将另一个视图包装在遵守UIViewRepresentable协议的SwiftUI视图中。SwiftUI包含了WatchKit和AppKit视图类似的协议。

现在开始,您将创建一个新的自定义视图,该视图可以显示MKMapView

选择File > New > File,选择iOS平台,选择SwiftUI View模板,然后单击Next。将新文件命名为MapView.swift,然后单击“Create”。

步骤二

添加import MapKit,并让MappView结构遵守UIViewRepresentable协议。

不要担心Xcode显示的警告;您将在接下来的几个步骤中修复它。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
var body: some View {
Text("Hello World")
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

UIViewRepresentable协议有两个需要实现的方法:使用UIView(context:)方法创建MKMapView,使用updateUIView(u:context:)方法来配置视图并响应视图的任意变化。

步骤三

makeUIView(context:)方法替换body属性。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

步骤四

创建一个updateUIView(u:context:)方法,将地图视图的区域设置为正确的坐标,以便将地图居中放置在Turtle Rock上。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}

func updateUIView(_ view: MKMapView, context: Context) {
let coordinate = CLLocationCoordinate2D(
latitude: 34.011286, longitude: -116.166868)
let span = MKCoordinateSpan(latitudeDelta: 2.0, longitudeDelta: 2.0)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

当预览处于静态模式时,它们仅完全呈现SwiftUI视图。因为MKMapView是一个UIView子类,所以您需要切换到实时预览来查看地图。

步骤五

单击Live Preview按钮将预览切换到实时预览模式。您可能需要单击预览上方的Try AgainResume按钮。

再过一会儿,你会看到一张Joshua Tree National Park的地图,那里是Turtle Rock的故乡。

第六节

组成详情视图

现在您已经拥有了所需的所有组件——名称和位置、圆形图像以及地图。

现在使用目前的工具,组合自定义视图,创建地标详情视图以达到最终设计吧。

步骤一

在项目导航器中,选择ContentView.swift文件。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤二

将刚才的三个文本的VStack嵌入到另一个VStack中。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤三

将自定义MapView添加到Stack顶部。使用frame(width:height:)设置MapView的大小。

仅指定height(高度)参数时,视图会自动调整其内容的宽度。在这种情况下,MapView将展开以填充可用空间。

步骤四

单击Live Preview按钮,以在预览中查看渲染的地图。

您可以在显示实时预览时继续编辑视图。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤五

CircleImage视图添加到Stack中。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤六

若要将CircleImage显示在MapView视图的上方,请使图像垂直偏移-130个点,并从视图底部填充-130个点。

这些调整通过向上移动图像为文本腾出空间。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤七

VStack中底部添加一个Spacer(),将内容推到屏幕的顶部。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤八

最后,要允许地图内容扩展到屏幕的上边缘,请将edgesIgnoringSafeArea(.top)修饰符添加到地图视图中。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.edgesIgnoringSafeArea(.top)
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

绘图和动画

绘制路径和形状

用户每次访问列表中的地标时都会收到徽章。当然,要让用户收到徽章,您需要创建一个徽章。本教程将引导您通过组合路径和形状来创建徽章,然后将其与表示位置的另一个形状重叠。

如果要为不同类型的地标创建多个徽章,请尝试使用覆盖的符号、更改重复次数或更改不同角度和比例。

学习时间:25分钟

下载地址:DrawingPathsAndShapes.zip

第一节 创建徽章视图

要创建徽章,首先要创建一个徽章视图,该视图使用SwiftUI中的矢量绘图api

步骤1

选择File > New > File,然后从“iOS模板”工作表中选择SwiftUI View。单击Next继续,然后在“Save as”字段中输入Badge并单击“Create”。

步骤2

调整Badge视图以显示文本“Badge”,接下来我们开始定义徽章形状。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct Badge: View {
var body: some View {
Text("Badge")
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

第二节 绘制徽章背景

使用SwiftUI中的图形api绘制自定义徽章形状。

步骤1

查看HexagonParameters.swift文件中的代码。

六边形参数结构定义了绘制徽章六边形的详细信息。您不会修改此数据;相反,您将使用它指定用于绘制徽章的线条和曲线的控制点。

HexagonParameters.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import SwiftUI

struct HexagonParameters {
struct Segment {
let useWidth: (CGFloat, CGFloat, CGFloat)
let xFactors: (CGFloat, CGFloat, CGFloat)
let useHeight: (CGFloat, CGFloat, CGFloat)
let yFactors: (CGFloat, CGFloat, CGFloat)
}

static let adjustment: CGFloat = 0.085

static let points = [
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (0.60, 0.40, 0.50),
useHeight: (1.00, 1.00, 0.00),
yFactors: (0.05, 0.05, 0.00)
),
Segment(
useWidth: (1.00, 1.00, 0.00),
xFactors: (0.05, 0.00, 0.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.20 + adjustment, 0.30 + adjustment, 0.25 + adjustment)
),
Segment(
useWidth: (1.00, 1.00, 0.00),
xFactors: (0.00, 0.05, 0.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.70 - adjustment, 0.80 - adjustment, 0.75 - adjustment)
),
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (0.40, 0.60, 0.50),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.95, 0.95, 1.00)
),
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (0.95, 1.00, 1.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.80 - adjustment, 0.70 - adjustment, 0.75 - adjustment)
),
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (1.00, 0.95, 1.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.30 + adjustment, 0.20 + adjustment, 0.25 + adjustment)
)
]
}

步骤2

Badge.swift中,向Badge添加一个Path shape,并应用fill()修饰符将该形状转换为视图。

您可以使用paths来组合线条、曲线和其他绘图,以形成更复杂的形状,如徽章的六边形背景。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in

}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤3

path添加起点。

move(to:)方法在形状的边界内移动绘图光标,就像一支虚构的笔悬停在该区域上,等待开始绘图一样。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in
var width: CGFloat = 100.0
let height = width
path.move(to: CGPoint(x: width * 0.95, y: height * 0.20))
}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤4

为形状数据的每个点绘制线,以创建一个大致的六边形。

addLine(to:)方法接受一个点并绘制它。对addLine(to:)连续调用,会从上一点开始到新点画一条线。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in
var width: CGFloat = 100.0
let height = width
path.move(to: CGPoint(x: width * 0.95, y: height * 0.20))

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)
}
}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

如果你的六边形看起来有点不寻常,不要担心;这符合预期。在接下来的几个步骤中,您将努力使六边形看起来更像本节开头显示的徽章形状。

步骤5

使用addQuadCurve(to:control:)方法为徽章的角绘制Bézier曲线。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in
var width: CGFloat = 100.0
let height = width
path.move(
to: CGPoint(
x: width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤6

将徽章path包装在GeometryReader中,以便徽章可以使用其包含视图的大小,该视图定义大小,而不是硬编码值(100)。

当包含的视图不是正方形时,使用几何体的两个维度中的最小值以保证徽章的宽高比。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
path.move(
to: CGPoint(
x: width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(Color.black)
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤7

使用xScalexOffset调整变量使徽章在其几何体中居中。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(Color.black)
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤8

将徽章的纯黑背景替换为与设计匹配的渐变色。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(LinearGradient(
gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
startPoint: .init(x: 0.5, y: 0),
endPoint: .init(x: 0.5, y: 0.6)
))
}
}
static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤9

对渐变填充应用aspectRatio(u:contentMode:)修饰符。

即使其父视图不是正方形的,可以通过它可以使视图保持1:1的宽高比,并保持其在视图中心的位置。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(LinearGradient(
gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
startPoint: .init(x: 0.5, y: 0),
endPoint: .init(x: 0.5, y: 0.6)
))
.aspectRatio(1, contentMode: .fit)
}
}
static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

第三节 画徽章符号

地标徽章的中心有一个自定义徽章,该徽章基于地标应用程序图标中显示的山。

山体符号由两个形状组成:一个表示山顶的积雪,另一个表示沿引道的植被。您将使用两个由一个小间隙隔开的部分三角形来绘制它们。

步骤1

在名为BadgeBackground.swift的新文件中,将徽章视图的主体放入新的徽章背景视图中,以便为其他视图准备徽章视图。

BadgeBackground.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import SwiftUI

struct BadgeBackground: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(LinearGradient(
gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
startPoint: .init(x: 0.5, y: 0),
endPoint: .init(x: 0.5, y: 0.6)
))
.aspectRatio(1, contentMode: .fit)
}
}
static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct BadgeBackground_Previews: PreviewProvider {
static var previews: some View {
BadgeBackground()
}
}

步骤2

将徽章背景放在徽章主体中以恢复徽章。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct Badge: View {
var body: some View {
BadgeBackground()
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤3

为山形创建一个名为BadgeSymbol的新自定义视图,该山形在徽章设计中以旋转方式绘制。

BadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct BadgeSymbol: View {
var body: some View {
Text("Badge Symbol")
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

步骤4

使用Paths api绘制符号的顶部。

实验

尝试调整与spacing、topWidth和topHeight常量关联的系数,以查看它们如何影响整体形状。

BadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct BadgeSymbol: View {
var body: some View {
GeometryReader { geometry in
Path { path in
let width = min(geometry.size.width, geometry.size.height)
let height = width * 0.75
let spacing = width * 0.030
let middle = width / 2
let topWidth = 0.226 * width
let topHeight = 0.488 * height

path.addLines([
CGPoint(x: middle, y: spacing),
CGPoint(x: middle - topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing),
CGPoint(x: middle + topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: spacing)
])
}
}
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

步骤5

绘制符号的底部。

使用move(to:) 修饰符在同一路径中的多个形状之间插入间隙。

BadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct BadgeSymbol: View {
var body: some View {
GeometryReader { geometry in
Path { path in
let width = min(geometry.size.width, geometry.size.height)
let height = width * 0.75
let spacing = width * 0.030
let middle = width / 2
let topWidth = 0.226 * width
let topHeight = 0.488 * height

path.addLines([
CGPoint(x: middle, y: spacing),
CGPoint(x: middle - topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing),
CGPoint(x: middle + topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: spacing)
])

path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
path.addLines([
CGPoint(x: middle - topWidth, y: topHeight + spacing),
CGPoint(x: spacing, y: height - spacing),
CGPoint(x: width - spacing, y: height - spacing),
CGPoint(x: middle + topWidth, y: topHeight + spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
])
}
}
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

步骤6

用设计中的紫色填充符号。

BadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import SwiftUI

struct BadgeSymbol: View {
static let symbolColor = Color(red: 79.0 / 255, green: 79.0 / 255, blue: 191.0 / 255)

var body: some View {
GeometryReader { geometry in
Path { path in
let width = min(geometry.size.width, geometry.size.height)
let height = width * 0.75
let spacing = width * 0.030
let middle = width / 2
let topWidth = 0.226 * width
let topHeight = 0.488 * height

path.addLines([
CGPoint(x: middle, y: spacing),
CGPoint(x: middle - topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing),
CGPoint(x: middle + topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: spacing)
])

path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
path.addLines([
CGPoint(x: middle - topWidth, y: topHeight + spacing),
CGPoint(x: spacing, y: height - spacing),
CGPoint(x: width - spacing, y: height - spacing),
CGPoint(x: middle + topWidth, y: topHeight + spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
])
}
.fill(Self.symbolColor)
}
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

第四节 结合徽章前景和背景

徽章设计要求在徽章背景上旋转和重复多次山形。

定义一种新的rotation类型,并利用ForEach视图对山形的多个副本使用相同的调整。

步骤1

创建新的RotatedBadgeSymbol视图以封装旋转符号。

实验

调整预览中的角度以测试旋转的效果。

RotatedBadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct RotatedBadgeSymbol: View {
let angle: Angle

var body: some View {
BadgeSymbol()
.padding(-60)
.rotationEffect(angle, anchor: .bottom)
}
}

struct RotatedBadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
RotatedBadgeSymbol(angle: .init(degrees: 5))
}
}

步骤2

Badge.swift中,将徽章的符号放置在ZStack中,将其放置在徽章背景上。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct Badge: View {
var badgeSymbols: some View {
RotatedBadgeSymbol(angle: .init(degrees: 0))
.opacity(0.5)
}

var body: some View {
ZStack {
BadgeBackground()

self.badgeSymbols
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

现在看来,徽章符号与预期的设计和背景的相对大小相比太大了。

步骤3

通过读取周围的几何图形并缩放符号,更正徽章符号的大小。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import SwiftUI

struct Badge: View {
var badgeSymbols: some View {
RotatedBadgeSymbol(angle: .init(degrees: 0))
.opacity(0.5)
}

var body: some View {
ZStack {
BadgeBackground()

GeometryReader { geometry in
self.badgeSymbols
.scaleEffect(1.0 / 4.0, anchor: .top)
.position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height)
}
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤4

使用ForEach创建旋转显示多个徽章符号的副本。

一个完整的360°旋转分成八个部分,通过重复山脉符号创建一个类似太阳的图案。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SwiftUI

struct Badge: View {
static let rotationCount = 8

var badgeSymbols: some View {
ForEach(0..<Badge.rotationCount) { i in
RotatedBadgeSymbol(
angle: .degrees(Double(i) / Double(Badge.rotationCount)) * 360.0
)
}
.opacity(0.5)
}

var body: some View {
ZStack {
BadgeBackground()

GeometryReader { geometry in
self.badgeSymbols
.scaleEffect(1.0 / 4.0, anchor: .top)
.position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height)
}
}
.scaledToFit()
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

框架集成

与UIKit协作

SwiftUI与所有苹果平台上现有的UI框架无缝协作。例如,可以将UIKit视图和视图控制器放置在SwiftUI视图中,反之亦然。

本教程向您展示如何将主页的特色地标转换为包装UIPageViewController和UIPageControl实例。您将使用UIPageViewController来显示SwiftUI视图的轮播,并使用状态变量和绑定来协调整个用户界面中的数据更新。

学习时间:25分钟

下载地址:InterfacingWithUIKit.zip

第一节

创建显示UIPageViewController的视图

要在SwiftUI中显示UIKit视图和视图控制器,可以创建遵守UIViewRepresentableUIViewControllerRepresentable协议的类型,您的自定义类型创建并配置它们所表示的UIKit类型,而SwiftUI管理它们的生命周期,并在需要时更新它们。

步骤1

创建一个名为PageViewController.swift的新SwiftUI视图文件,并声明PageViewController类型遵守UIViewControllerRepresentable

页面视图控制器存储UIViewController实例的数组。这些是在地标之间滚动的页面。

PageViewController.swift

1
2
3
4
5
6
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
}

接下来,添加UIViewControllerRepresentable协议的两个方法。

步骤2

添加makeUIViewController(context:)方法,该方法创建满足需求的UIPageViewController

SwiftUI在准备好显示视图时调用此方法一次,然后管理视图控制器的生命周期。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}
}

步骤3

添加一个updateUIViewController(_:context:)方法,该方法调用setViewControllers(_:direction:animated:)以显示数组中的第一个视图控制器。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}
}

创建另一个SwiftUI视图以显示UIViewControllerRepresentable视图。

步骤4

创建一个名为PageView.swift的新SwiftUI视图文件,并更新PageView类型以将PageViewController声明为子视图。

注意,泛型初始值设定项接受一个视图数组,并将每个视图嵌套在UIHostingController中。UIHostingControllerUIViewController子类,表示UIKit上下文中的SwiftUI视图。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView()
}
}

步骤5

更新PageView_Preview以传递所需的视图数组,此时预览开始工作。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

步骤6

在继续下一步之前,将页面视图预览固定到画布,所有操作变化都会发生在此视图上。

第二节 创建视图控制器的数据源

在几个简单的步骤中,您已经完成了很多工作—PageViewController使用一个UIPageViewController在SwiftUI视图中显示内容。现在是时候让视图轮播滚动了。

代表UIKit视图控制器的SwiftUI视图可以定义一个``类型,SwiftUI将其作为可表示视图上下文的一部分进行提供和管理。

步骤1

PageViewController中声明嵌套的Coordinator类。

SwiftUI管理UIViewControllerRepresentable类型的Coordinator,并在调用上面创建的方法时将其作为上下文的一部分提供。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
}
}

步骤2

PageViewController添加另一个方法以生成Coordinator

SwiftUI在makeUIViewController(context:)之前调用此makeCoordinator()方法,以便在配置视图控制器时可以访问coordinator对象。

Tips 您可以使用这个Coordinator来实现常见的Cocoa模式,例如委托、数据源和通过target-action响应用户事件。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
}
}

步骤3

Coordinator类型添加UIPageViewControllerDataSource一致性,并实现两个必需的方法。

这两个方法建立视图控制器之间的关系,以便您可以在它们之间来回滑动。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
}
}

步骤4

coordinator添加为UIPageViewController的数据源。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
}
}

步骤5

打开实时预览并测试滑动交互。

第三节 在SwiftUI视图状态下跟踪页面

要准备添加自定义UIPageControl,需要一种方法从PageView中跟踪当前页。

为此,您将在PageView中声明@State属性,并将对该属性的绑定传递到PageViewController视图。PageViewController更新绑定以匹配当前可见页。

步骤1

首先添加一个currentPage绑定作为PageViewController的属性。

除了声明@Binding属性外,还要更新对setViewControllers(u:direction:animated:)的调用,传递currentPage绑定的值。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
}
}

步骤2

PageView中声明@State变量,并在创建子PageViewController时向属性传递绑定。

重要

请记住使用$语法创建用状态来存储值的绑定。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

步骤3

通过更改PageViewController的初始值,测试该值是否通过绑定流向PageViewController

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 1

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

实验:向PageView添加一个按钮,使PageViewController跳转到第二个视图。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 1

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

步骤4

添加一个带有currentPage属性的文本视图,这样您就可以监视@State属性的值。

请注意,当您从一页刷到另一页时,值不会改变。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
VStack {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
Text("Current Page: \(currentPage)")
}
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
}
}

步骤5

PageViewController.swift中,将协调器设置为UIPageViewControllerDelegate,并添加pageViewController(_:didFinishAnimating:previousViewControllers:transitionCompleted completed: Bool) 方法。

由于SwiftUI在页面切换动画完成时调用此方法,因此可以找到当前视图控制器的索引并更新绑定。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}

func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController)
{
parent.currentPage = index
}
}
}
}

步骤6

除了dataSource之外,还将coordinator指定为UIPageViewController的委托。

当在两个方向上连接绑定后,文本视图会在每次刷新后更新以显示正确的页码。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}

func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController)
{
parent.currentPage = index
}
}
}
}

第四节 添加自定义分页控件

您已经准备好向视图中添加自定义UIPageControl,该控件包装在SwiftUIUIViewRepresentable视图中。

步骤1

创建一个新的SwiftUI视图文件,名为PageControl.swift。更新PageControl类型以遵守UIViewRepresentable协议。

UIViewRepresentableUIViewControllerRepresentable类型具有相同的生命周期,其方法与其基础的UIKit类型相对应。

PageControl.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI
import UIKit

struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int

func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages

return control
}

func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
}

步骤2

将文本框替换为PageControl,从VStack切换到ZStack进行布局。

因为我们正在讲页数和绑定传递到当前页,所以PageControl已显示正确的值。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
ZStack(alignment: .bottomTrailing) {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
PageControl(numberOfPages: viewControllers.count, currentPage: $currentPage)
.padding(.trailing)
}
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
}
}

下一步,让PageControl交互,这样用户可以点击一边或另一边时,页面可以随之滚动。

步骤3

PageControl中创建嵌套的Coordinator类型,并添加makeCoordinator()方法以创建并返回新的协调器。

由于UIPageControlUIControl子类,故使用target-action模式而不是代理,所以此Coordinator实现@objc方法来更新 current page的绑定。

PageControl.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import SwiftUI
import UIKit

struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages

return control
}

func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}

class Coordinator: NSObject {
var control: PageControl

init(_ control: PageControl) {
self.control = control
}

@objc func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}

步骤4

Coordinator添加为valueChanged事件的目标,指定updateCurrentPage(sender:)方法作为要执行的操作。

PageControl.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI
import UIKit

struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
control.addTarget(
context.coordinator,
action: #selector(Coordinator.updateCurrentPage(sender:)),
for: .valueChanged)

return control
}

func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}

class Coordinator: NSObject {
var control: PageControl

init(_ control: PageControl) {
self.control = control
}

@objc func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}

步骤5

现在尝试所有不同的交互,PageView展示了UIKit和SwiftUI视图以及控制器是如何协同工作的。

绘图和动画

视图动画和转场

使用SwiftUI时,无论效果在哪里,都可以对视图或视图状态的更改单独设置动画。SwiftUI负责处理这些组合、重叠和可中断动画的所有复杂性。

在本教程中,您将设置一个视图的动画,该视图包含一个图形,用于跟踪用户在使用Landmarks应用程序时的行为数据。使用animation(_:)修改器,您将看到设置视图动画是多么容易。

学习时间:25分钟

下载地址:AnimatingViewsAndTransitions.zip

第一节 向各个视图添加动画

在视图上使用animation(_:)修改器时,SwiftUI可以改变视图的任何animatable(可动画)属性。视图的颜色color、不透明度opacity、旋转rotation、大小size和其他属性都是可设置动画的。

步骤1

HikeView.swift中,打开实时预览并尝试显示和隐藏图形。

请确保在本教程中使用实时预览,以便您可以尝试每个步骤的结果。

步骤2

通过添加animation(.easeInOut())为按钮启用旋转动画。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.padding()
.animation(.easeInOut())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤3

当图形可见时,添加一个让按钮变大的动画。

animation(_:)修饰符将应用于其包装的视图中所有可动画的更改。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.easeInOut())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤4

将动画类型从easeInOut()更改为spring()

SwiftUI包括预定义或自定义宽松的基本动画,以及弹簧和流体动画。可以调整动画的速度、在动画开始之前设置延迟或指定动画重复。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤5

尝试通过在scaleEffect修改器的正上方添加另一个动画修改器来关闭旋转的动画。

实验

围绕SwiftUI尝试组合不同的动画效果,看看有什么不同的效果。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤6

在转到下一节之前,请删除两个动画(:)修改器。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))

.scaleEffect(showDetail ? 1.5 : 1)
.padding()

}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

第二节 使状态的改变产生动画效果

既然您已经学习了如何将动画应用于各个视图,现在是时候在更改状态值的位置添加动画了。

在这里,您将对用户点击按钮并切换showDetail属性时发生的所有更改添加动画。

步骤1

withAnimation函数包装showDetail.toggle()的调用。

showDetail属性影响的两个视图(disclosure按钮和HikeDetail视图)现在都具有动画过渡。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

减慢动画的速度,以查看SwiftUI动画是如何中断的。

步骤2

将4秒长的基本动画传递给withAnimation函数。

可以传递相同类型的动画给给animation(:)修饰符的withAnimation函数。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation(.easeInOut(duration: 4)) {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤3

尝试在动画中打开和关闭图形视图。

步骤4

在继续下一节之前,请从withAnimation函数调用中删除慢速动画。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

第三节 自定义视图转场

默认情况下,视图通过淡入淡出在屏幕内外切换。您可以使用transition(:)修饰符自定义此转场。

步骤1

向条件可见的HikeView添加一个transition(_:)修饰符。

现在,图形通过滑动进入和退出视线而出现和消失。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.slide)
}
}
}
}

步骤2

将转场提取为AnyTransition的静态属性。

这将在展开自定义转场时保持代码的干净。对于自定义转换,您可以使用与SwiftUI相同的.语法。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import SwiftUI

extension AnyTransition {
static var moveAndFade: AnyTransition {
AnyTransition.slide
}
}

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.moveAndFade)
}
}
}
}

步骤3

切换到使用move(edge:) 转场,以便图形从同一侧滑入和滑出。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import SwiftUI

extension AnyTransition {
static var moveAndFade: AnyTransition {
AnyTransition.move(edge: .trailing)
}
}

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.moveAndFade)
}
}
}
}

步骤4

使用asymmetric(insertion:removal:)(不对称)修饰符在视图出现和消失时提供不同的过渡。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import SwiftUI

extension AnyTransition {
static var moveAndFade: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.moveAndFade)
}
}
}
}

第四节 为复杂效果撰写动画

单击栏下方的按钮时,图形将在三组不同的数据之间切换。在本节中,将使用组合动画为构成图形的胶囊提供动态、波动的转场动画。

步骤1

showDetail的默认值更改为true,并将HikeView预览锁定到画布。

这使您在另一个文件中处理动画时依然可以在上下文中看到图表。

步骤2

GraphCapsule.swift中,添加新的计算动画属性,并将其应用于胶囊形状。

GraphCapsule.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.default
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤3

将动画切换为弹簧动画spring,减少阻尼部分dampingFraction以使条形可以跳跃。

GraphCapsule.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.spring(dampingFraction: 0.5)
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤4

稍微加快动画的速度,以缩短每个条形移动到新位置所需的时间。

GraphCapsule.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤5

根据胶囊在图形上的位置为每个动画添加延迟delay

GraphCapsule.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
.delay(0.03 * Double(index))
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤6

观察自定义动画在图形之间转换时如何提供波动效果。

构建列表和导航

设置了基本地标详情视图后,需要为用户提供查看地标所有列表和查看每个地标详情的方法。

您将创建显示所有地标信息的视图,并动态生成一个滚动列表,用户可以点击该列表查看地标的详情视图。要微调UI,您将使用Xcode的画布以不同的设备大小呈现多个预览。

下载项目文件以开始构建此项目,并执行以下步骤。

学习时间:35分钟

下载示例:BuildingListsAndNavigation.zip

第一节 了解示例数据。

在第一个教程中,我们将数据硬编码到所有自定义视图中。在这里,您将学习如何将数据传递到自定义视图中以动态显示。

首先下载示例项目并熟悉示例数据。

步骤1

在项目导航器中,选择Models>Landmark.swift

Landmark.swift声明了一个Landmark结构,该结构存储应用程序需要显示的所有Landmark信息,并从landmarkData.json导入一组Landmark数据。

Landmark.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var state: String
var park: String
var category: Category

var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}

enum Category: String, CaseIterable, Codable, Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}

extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}

struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}

步骤2

在项目导航器中,选择Resources>landmarkData.json

您将在本教程的其余部分以及随后的所有内容中使用此示例数据。

landmarkData.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[
{
"name": "Turtle Rock",
"category": "Featured",
"city": "Twentynine Palms",
"state": "California",
"id": 1001,
"park": "Joshua Tree National Park",
"coordinates": {
"longitude": -116.166868,
"latitude": 34.011286
},
"imageName": "turtlerock"
},
{
"name": "Silver Salmon Creek",
"category": "Lakes",
"city": "Port Alsworth",
"state": "Alaska",
"id": 1002,
"park": "Lake Clark National Park and Preserve",
"coordinates": {
"longitude": -152.665167,
"latitude": 59.980167
},
"imageName": "silversalmoncreek"
},
...
]

步骤3

请注意,我们将ContentView类型重命名为LandmarkDetail

在本教程和以下每个教程中,您将创建多个视图类型。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct LandmarkDetail: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)

HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

第二节 创建行视图

本节中构建的第一个视图是行视图,用于显示每个地标的详细信息。此行视图声明了一个属性即landmark ,其用于存储地标信息,以便行视图可以显示任何地标。稍后,您将把多个行视图合并成一个地标列表。

步骤1

创建一个新的SwiftUI视图,名为LandmarkRow.swift

步骤2

如果预览尚未显示,请通过选择Editor > Editor and Canvas,来显示画布,然后单击“Resume”。

步骤3

添加landmark作为LandmarkRow的存储属性。

添加landmark属性时,预览将停止工作,因为LandmarkRow类型在初始化期间需要landmark实例。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
Text("Hello World")
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow()
}
}

想要修复预览,需要修改PreviewProvider

步骤4

LandmarkRow_Previewsstatic previews属性中,将landmarkData数组的第一个元素作为LandmarkRow参数添加到LandmarkRow初始值设定项中。

预览可以正常显示文本Hello World了。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
Text("Hello World")
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

修复后,可以为行视图生成布局。

步骤5

将文本视图嵌入到HStack中。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
Text("Hello World")
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

步骤6

修改文本视图以使用landmarkname属性。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

步骤7

Text视图之前添加Image视图来完成行视图。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

第三节 自定义行预览

Xcode的画布自动识别并显示当前编辑器中遵守PreviewProvider协议的任何类型。PreviewProvider返回一个或多个视图,并提供配置大小和设备的选项。

您可以自定义PreviewProvider返回的内容,以准确呈现对您最有帮助的预览。

步骤1

LandmarkRow_Previews中,将landmark参数更新为landmarkData数组中的第二个元素。

预览立即显示第二个示例地标,而不是第一个。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
}
}

步骤2

使用previewLayout(_:) 设置一个与列表中的行近似的大小。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}

可以使用GroupPreviewProvider返回多个视图预览。

步骤3

将返回的行包装在一个Group中,然后再次添加第一行。

Group是用于对视图内容进行分组的容器。Xcode将组的子视图呈现为画布中的单独预览。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
.previewLayout(.fixed(width: 300, height: 70))
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}
}

步骤4

要简化代码,请将previewLayout(:)调用移到Group的外部。

视图的子级视图继承视图的上下文设置,如预览配置。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

Tips:在PreviewProvider中编写的代码只会改变Xcode在画布中显示的内容。

第四节 创建地标列表

使用SwiftUI的List类型时,可以显示特定于平台的视图列表。列表的元素可以是静态的,比如您目前创建的堆栈的子视图,也可以是动态生成的。您甚至可以混合静态和动态生成的视图。

步骤1

创建一个新的SwiftUI View,名为LandmarkList.swift

步骤2

将默认Text视图替换为List,并提供前两个Landmark作为列表子级的LandmarkRow实例。

预览显示了两个地标,它们以适合iOS的列表样式呈现。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SwiftUI

struct LandmarkList: View {
var body: some View {
List {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第五节 动态化列表

您可以直接从集合中生成行,而不是单独的指定列表中的元素。

通过传递数据集合并为集合中的每个元素提供视图的闭包,可以创建显示集合元素的列表。列表使用提供的闭包将集合中的每个元素转换为子视图。

步骤1

删除两个静态LandmarkRow,并将landmarkData 传递给Lisst初始值设定项。

List使用identifiable数据。您可以通过以下两种方式之一生产identifiable数据:1、使用key path属性标识每个元素;2、使数据遵守Identifiable协议。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in

}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

通过从闭包返回LandmarkRow来动态生成列表。

这将为landmarkData数组中的每个元素创建一个LandmarkRow

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in
LandmarkRow(landmark: landmark)
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

接下来,您将通过使Landmark遵守Identifiable来简化代码。

步骤3

切换到Landmark.swift并声明Identifiable协议。

由于Landmark类型已经具有Identifiable所需的id属性,因此没有其他工作要做。

Landmark.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var state: String
var park: String
var category: Category

var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}

enum Category: String, CaseIterable, Codable, Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}

extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}

struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}

步骤4

切换回LandmarkList.swift并删除id参数。

从现在起,您将能够直接使用Landmark的集合。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkList: View {
var body: some View {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第六节 在列表和详情之间设置导航

列表呈现正确,但您还不能轻触每个LandmarkRow来查看该地标的详情页面。

通过把List嵌入到NavigationView中使其具有导航功能,然后在NavigationLink中嵌入每一行LandmarkRow,并设置要跳转的目标视图。

步骤1

NavigationView中嵌入动态生成的LandmarkList

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

在显示列表时,调用navigationBarTitle(:)修饰符方法设置导航栏的标题。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤3

List的闭包中,将返回的Row包装在NavigationLink 中,指定LandmarkDetail视图作为跳转目标。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail()) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤4

通过切换到实时预览模式,您可以在预览中直接尝试导航功能。单击Live Preview按钮并点击地标行访问详情页。

第7节 将数据传递到子视图

LandmarkDetail视图仍然使用硬编码方式来显示地标。与LandmarkRow一样,LandmarkDetail类型及其包含的视图需要使用landmark属性作为其数据源。

从子视图开始,您将转换CircleImageMapViewLandmarkDetail以显示传入的数据,而不是对每行进行硬编码。

步骤1

CircleImage.swift中,将存储属性image添加到CircleImage

这是使用SwiftUI构建视图时的常见模式。您的自定义视图通常会包装一系列修饰符。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct CircleImage: View {
var image: Image

var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤2

更新CircleImage_Preview 以传递名为Turtle RockImage

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct CircleImage: View {
var image: Image

var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage(image: Image("turtlerock"))
}
}

步骤3

MapView.swift中,向MapView添加一个coordinate属性,并将代码转换为使用该属性,而不是硬编码纬度和经度。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D

func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}

func updateUIView(_ view: MKMapView, context: Context) {

let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

步骤4

更新MapView_Preview以传递数据数组中第一个地标元素的坐标。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D

func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}

func updateUIView(_ view: MKMapView, context: Context) {
let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView(coordinate: landmarkData[0].locationCoordinate)
}
}

步骤5

LandmarkDetail.swift中,将Landmark属性添加到LandmarkDetail类中。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)

HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail()
}
}

步骤6

更新LandmarkDetail_Preview以使用landmarkData数组中的第一个地标元素。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)

HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}

步骤7

将所需的数据下传给自定义类型。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)

HStack(alignment: .top) {
Text(landmark.park)
.font(.subheadline)
Spacer()
Text(landmark.state)
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}

步骤8

最后,调用navigationBarTitle(_:displayMode:)修饰符,在显示详情视图时为导航栏提供标题。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)

HStack(alignment: .top) {
Text(landmark.park)
.font(.subheadline)
Spacer()
Text(landmark.state)
.font(.subheadline)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}

步骤9

SceneDelegate.swift中,将应用程序的根视图切换为LandmarkList

当在模拟器中独立运行(不是预览模式)时,您的应用程序将从SceneDelegate中定义的根视图开始。

SceneDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: LandmarkList())
self.window = window
window.makeKeyAndVisible()
}
}

// ...
}

步骤10

LandmarkList.swift中,将当前Landmark传递到目标详情页。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤11

切换到实时预览以查看从列表导航到详情视图时,是否显示正确的地标。

第8节 动态生成预览

接下来,将向LandmarkList_Previews添加代码,以呈现不同设备大小的列表视图预览。默认情况下,预览以激活状态设备的大小呈现。可以通过调用previewDevice(:)修饰符方法来更改预览设备。

步骤1

首先,将当前列表预览更改为iPhone SE大小的渲染器。

您可以提供Xcodescheme菜单中显示的任何设备的名称。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.previewDevice(PreviewDevice(rawValue: "iPhone SE"))
}
}

步骤2

LandmarkList_Previews中,使用设备名称数组作为数据,将LandmarkList嵌入ForEach实例中。

ForEach对集合的操作方式与list相同,这意味着您可以在任何可以使用子视图的地方使用它,例如在stackslistgroup等中。当数据元素是简单的值类型(如您在这里使用的字符串)时,可以使用.self作为identifierkey path

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
}
}
}

步骤3

使用previewDisplayName(_:)修饰符将设备名称添加为预览的标签。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
}
}

步骤4

您可以尝试使用不同的设备来比较视图的渲染,所有这些都来自画布。

框架集成

创建watchOS应用程序

本教程为您提供了一个机会,让您可以应用您已经了解的有关SwiftUI的大部分内容,并且只需很少的努力将Landmarks应用程序迁移到watchOS

在复制为iOS应用程序创建的共享数据和视图之前,您将首先向项目中添加watchOS target。所有资源就绪后,您将自定义SwiftUI视图,以便在watchOS上显示详情视图和列表视图。

学习时间:25分钟

下载地址:CreatingAwatchOSApp.zip

第一节 添加watchOS Target

要创建watchOS应用程序,请首先将watchOS Target添加到项目中。Xcode将watchOS应用程序的文件夹和文件,以及构建和运行该应用程序所需的方案添加到项目中。

步骤1

选择File > New > Target。当工作模板表出现时,选择watchOS选项卡,选择Watch App for iOS应用程序模板,然后单击Next

此模板将新的watchOS应用程序添加到您的项目中,并与iOS应用程序配对。

步骤2

在表单中,输入WatchLandmarks作为产品名称。将语言设置为Swift,将用户界面设置为SwiftUI。选中Include Notification Scene复选框,然后单击“Finish”。

步骤3

如果Xcode提示,请单击“Activate”。

这将选择WatchLandmarks方案,以便您可以构建和运行watchOS应用程序。

步骤4

WatchLandmarks扩展的“General”选项卡中,选中Supports Running Without iOS App Installation(支持在不安装iOS应用程序的情况下运行)复选框。

尽可能创建一个独立的watchOS应用程序。独立的watchOS应用不需要iOS配套应用。

第二节 在目标之间共享文件

设置了watchOS目标后,需要共享iOS目标中的一些资源。您将重用Landmark应用程序的数据模型、一些资源文件以及两个平台都可以显示而无需修改的任何视图。

步骤1

在项目导航器中,单击命令以选择以下文件:LandmarkRow.swift、Landmark.swift、UserData.swift、Data.swift、Profile.swift、Hike.swift和CircleImage.swift。

Landmark.swift、UserData.swift、Data.swift、Profile.swift和Hike.swift定义了应用程序的数据模型。您不会使用模型的所有方面,但需要所有文件才能成功编译应用程序。LandmarkRow.swift和CircleImage.swift都是应用程序可以在watchOS上显示的视图,无需任何更改。

步骤2

在“文件检查器”中,选中“Target Membership”部分中的“WatchLandmarks Extension”复选框。

这使您在上一步中选择的文件可用于watchOS应用程序。

步骤3

在项目导航器中,选择Landmark组中的Assets.xclassets文件,并将其添加到File检查器的Target Membership部分中的WatchLandmarks Target中。

这与您在上一步中选择的Target不同。WatchLandmarks Extension Target包含你的应用程序的代码,而WatchLandmarks Target管理脚本、图标和相关资源。

步骤4

在项目导航器中,选择Resources文件夹中的所有文件,然后在File检查器的Target Membership中将它们添加到WatchLandmarks Extension target中。

第三节 创建详情视图

现在iOS目标资源已经准备好用于watch应用程序,您需要创建一个watch的视图来显示地标详情。为了测试详情视图,您将为最大和最小的手表尺寸创建自定义预览,并对圆形视图进行一些更改,以便所有内容都适合手表界面。

步骤1

在项目导航器中,单击WatchLandmarks Extension文件夹旁边的三角形以显示其内容,然后添加名为WatchLandmarkDetail的新SwiftUI视图。

步骤2

userData、landmark、landmarkIndex属性添加到WatchLandmarkDetail结构中。

这些属性与您在Handling User Input时添加到LandmarkDetail结构中的属性相同。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
Text("Hello World!")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
WatchLandmarkDetail()
}
}

在上一步中添加属性后,Xcode中将出现参数丢失错误。要修复错误,您需要执行以下两项操作之一:为属性提供默认值,或传递参数设置视图的属性。

步骤3

在预览中,创建用户数据的实例,并使用它将landmark对象传递给WatchLandmarkView结构的初始值。还需要将用户数据设置为视图的环境对象。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
Text("Hello World!")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return WatchLandmarkDetail(landmark: userData.landmarks[0])
.environmentObject(userData)
}
}

步骤4

WatchLandmarkDetail.swift中,从body()方法返回CircleImage视图。

在这里,您可以重用iOS项目中的CircleImage视图。因为您创建了一个可调整大小的图像,所以调用.scaledToFill()将调整圆的大小,使其自动适配显示。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return WatchLandmarkDetail(landmark: userData.landmarks[0])
.environmentObject(userData)
}
}

步骤5

为最大(44毫米)和最小(38毫米)的表盘创建预览。

通过对最大和最小的手表表面进行测试,你可以看到你的应用程序在屏幕上的缩放效果。一如既往,您应该在所有支持的设备大小上测试用户界面。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

圆形图像将调整大小以适配显示的高度。不幸的是,这也限制了圆的宽度。要解决裁减问题,您需要将图像嵌入VStack中,并进行一些额外的布局更改,以便圆形图像适合任何手表的宽度。

步骤6

将圆图像嵌入到VStack中。在图像下方显示地标名称及其信息。

如您所见,该信息不太适合在表盘屏幕上显示,但您可以通过将VStack放在滚动视图中来解决这个问题。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤7

在滚动视图中包装VStack

这会打开视图滚动,但会产生另一个问题:圆形图像现在会扩展到原大小,并且会调整其他UI元素的大小以匹配图像大小。您需要调整圆图像的大小,以便屏幕上只显示圆和地标名称。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤8

scaleToFill()更改为scaleToFit()

这将缩放圆图像以匹配显示器的宽度。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤9

添加padding,使地标名称在圆图像下方可见。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
.padding(16)
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤10

后退按钮上添加标题。

这会将后退按钮的文本设置为“地标”。但是,在添加“地标列表”视图之前,您不会看到“后退”按钮。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
.padding(16)
}
.navigationBarTitle("Landmarks")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

第四节 添加watchOS地图视图

现在您已经创建了基本详情视图,现在是时候添加一个地图来显示地标的位置了。与CircleImage不同,你不能重用iOS应用程序的MapView。相反,您需要创建一个WKInterfaceObjectRepresentable结构来包装WatchKit的地图视图。

步骤1

WatchKit extension添加名为WatchMapView的自定义视图。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct WatchMapView: View {
var body: some View {
Text("Hello World!")
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView()
}
}

步骤2

WatchMapView结构中,将View更改为WKInterfaceObjectRepresentable

要查看区别,请在步骤1和2所示的代码之间来回滚动。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var body: some View {
Text("Hello World!")
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView()
}
}

Xcode显示编译错误,因为WatchMapView尚未实现WKInterfaceObjectRepresentable协议属性。

步骤3

删除body()方法并将其替换为landmark属性。

无论何时创建地图视图,都需要传递此属性的值。例如,可以将landmark的实例传递给预览。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var landmark: Landmark
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView(landmark: UserData().landmarks[0])
}
}

步骤4

实现WKInterfaceObjectRepresentablemakeWKInterfaceObject(context:) 方法。

此方法用来创建WatchMapView显示的WatchKit地图。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var landmark: Landmark

func makeWKInterfaceObject(context: WKInterfaceObjectRepresentableContext<WatchMapView>) -> WKInterfaceMap {
return WKInterfaceMap()
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView(landmark: UserData().landmarks[0])
}
}

步骤5

通过实现WKInterfaceObjectRepresentableupdateWKInterfaceObject(_:,context:)方法,根据地标的坐标设置地图的区域。

现在,编译成功了。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var landmark: Landmark

func makeWKInterfaceObject(context: WKInterfaceObjectRepresentableContext<WatchMapView>) -> WKInterfaceMap {
return WKInterfaceMap()
}

func updateWKInterfaceObject(_ map: WKInterfaceMap, context: WKInterfaceObjectRepresentableContext<WatchMapView>) {

let span = MKCoordinateSpan(latitudeDelta: 0.02,
longitudeDelta: 0.02)

let region = MKCoordinateRegion(
center: landmark.locationCoordinate,
span: span)

map.setRegion(region)
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView(landmark: UserData().landmarks[0])
}
}

步骤6

选择WatchLandmarkView.swift文件并将地图视图添加到VStack的底部。

代码添加了一个分隔符,后跟地图视图。.scaledToFit().padding()修饰符将地图以合适的大小适配屏幕。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)

Divider()

WatchMapView(landmark: self.landmark)
.scaledToFit()
.padding()
}
.padding(16)
}
.navigationBarTitle("Landmarks")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

第五节 创建跨平台列表视图

对于地标列表,您可以重用iOS应用程序中的行Row,但每个平台都需要呈现自己的详情视图。为了支持这一点,您将把LandmarkList视图转换为泛型列表类型,在这里实例化代码定义了详情视图。

步骤1 在工具栏中,选择Landmarks scheme

Xcode现在编译并运行应用程序的iOS版本。在将列表移动到watchOS应用程序之前,您要确保对LandmarkList视图的任何修改在iOS应用程序中仍然有效。

步骤2

选择LandmarkList.swift并更改类型声明,使其成为泛型类型。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
.environmentObject(UserData())
}
}

在创建LandmarkList结构的实例时,添加泛型声明会导致Generic parameter could not be inferred(无法推断泛型参数)错误。以下步骤会修复这些错误。

步骤3

为创建局部视图的闭包添加属性。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
.environmentObject(UserData())
}
}

步骤4

使用detailViewProducer属性为地标创建详情视图。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
.environmentObject(UserData())
}
}

创建LandmarkList实例时,还需要提供一个闭包,用于创建landmark的详情视图。

步骤5

选择Home.swift。在CategoryHome结构的body()方法中,添加闭包以创建LandmarkDetail视图。

Xcode根据闭包的返回值推断LandmarkList结构的泛型类型。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: CGFloat(200))
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())

NavigationLink(destination: LandmarkList { LandmarkDetail(landmark: $0) }) {
Text("See All")
}
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
ProfileHost()
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

// swiftlint:disable type_name
struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
.environmentObject(UserData())
}
}

步骤6

在LandmarkList.swift中,向预览添加类似的代码。

在这里,您需要使用条件判断,根据Xcode编译的当前scheme定义详情视图。现在地标应用程序可以按照预期在iOS上构建和运行了。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

#if os(watchOS)
typealias PreviewDetailView = WatchLandmarkDetail
#else
typealias PreviewDetailView = LandmarkDetail
#endif

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList { PreviewDetailView(landmark: $0) }
.environmentObject(UserData())
}
}

第六节 添加地标列表

现在您已经更新了LandmarksList视图,以便它在两个平台上都能工作,您可以将其添加到watchOS应用程序中。

步骤1

在文件检查器中,将LandmarksList.swift添加到WatchLandmarks ExtensionTarget。

现在可以在watchOS应用程序的代码中使用LandmarkList视图。

步骤2

在工具栏中,将scheme更改为WatchLandmarks

步骤3

打开LandmarkList.swift,继续预览。

预览现在显示watchOS列表视图。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

#if os(watchOS)
typealias PreviewDetailView = WatchLandmarkDetail
#else
typealias PreviewDetailView = LandmarkDetail
#endif

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList { PreviewDetailView(landmark: $0) }
.environmentObject(UserData())
}
}

watchOS应用程序的根视图是ContentView,它显示默认文本Hello World!

步骤4

修改ContentView,使其显示列表视图。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct ContentView: View {
var body: some View {
LandmarkList { WatchLandmarkDetail(landmark: $0) }
.environmentObject(UserData())
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
LandmarkList { WatchLandmarkDetail(landmark: $0) }
.environmentObject(UserData())
}
}

步骤5

在模拟器中编译运行watchOS应用程序。

通过滚动列表中的地标来测试watchOS应用程序的行为,点击以查看地标的详细信息,并将其标记为收藏夹。单击back按钮返回列表,然后打开Favorite开关,这样您只能看到收藏的地标。

第7节 创建自定义通知界面

你的watchOS版地标应用程序几乎完成了。在这最后一部分中,您将创建一个通知界面,每当您离最喜欢的某一位置很近时,会收到地标信息的通知信息。

注意:本节仅介绍如何在收到通知后显示通知。它不描述如何设置或发送通知。

步骤1

打开NotificationView.swift并创建一个显示有关地标、标题和消息的信息的视图。

因为任何通知值都可以为nil,所以预览将显示通知视图的两个版本。第一个仅在未提供数据时显示默认值,第二个显示您提供的标题、消息和位置。

NotificationView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import SwiftUI

struct NotificationView: View {

let title: String?
let message: String?
let landmark: Landmark?

init(title: String? = nil,
message: String? = nil,
landmark: Landmark? = nil) {
self.title = title
self.message = message
self.landmark = landmark
}

var body: some View {
VStack {

if landmark != nil {
CircleImage(image: landmark!.image.resizable())
.scaledToFit()
}

Text(title ?? "Unknown Landmark")
.font(.headline)
.lineLimit(0)

Divider()

Text(message ?? "You are within 5 miles of one of your favorite landmarks.")
.font(.caption)
.lineLimit(0)
}
}
}

struct NotificationView_Previews: PreviewProvider {

static var previews: some View {
Group {
NotificationView()

NotificationView(title: "Turtle Rock",
message: "You are within 5 miles of Turtle Rock.",
landmark: UserData().landmarks[0])
}
.previewLayout(.sizeThatFits)
}
}

步骤2

打开NotificationController并添加landmarktitlemessage属性。

这些数据存储了有关通知传入信息。

NotificationController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

override var body: NotificationView {
NotificationView()
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.
}
}

步骤3

更新body()方法以使用这些属性。

此方法实例化您先前创建的通知视图。

NotificationController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

override var body: NotificationView {
NotificationView(title: title,
message: message,
landmark: landmark)
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.
}
}

步骤4

定义LandmarkIndexKey

使用此键可从通知中提取地标索引。

NotificationController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

let landmarkIndexKey = "landmarkIndex"

override var body: NotificationView {
NotificationView(title: title,
message: message,
landmark: landmark)
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.
}
}

步骤5

更新didReceive(:)方法以解析通知中的数据。

此方法更新控制器的属性。调用此方法后,系统将使控制器的body属性无效,该属性将更新导航视图。然后系统在Apple Watch上显示通知。

NotificationController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

let landmarkIndexKey = "landmarkIndex"

override var body: NotificationView {
NotificationView(title: title,
message: message,
landmark: landmark)
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
let userData = UserData()

let notificationData =
notification.request.content.userInfo as? [String: Any]

let aps = notificationData?["aps"] as? [String: Any]
let alert = aps?["alert"] as? [String: Any]

title = alert?["title"] as? String
message = alert?["body"] as? String

if let index = notificationData?[landmarkIndexKey] as? Int {
landmark = userData.landmarks[index]
}
}
}

当Apple Watch收到通知时,它会创建与通知类别关联的通知控制器。若要设置通知控制器的类别,必须打开并编辑应用程序的情节提要。

步骤6

在项目导航栏中,选择“Watch Landmarks”文件夹并打开界面storyboard。选择指向静态通知界面控制器的箭头。

步骤7

在属性检查器中,将Notification Category的名称设置为LandmarkNear

配置测试负载来使用LandmarkNear类别,并传递通知控制器期望的数据。

步骤8

选择PushNotificationPayload.apns文件,并更新title、body、category和landmarkIndex属性。请务必将类别设置为LandmarkNear。您还可以删除本教程中未使用的任何键,如subtitleWatchKit Simulator ActionscustomKey

PushNotificationPayload.apns

1
2
3
4
5
6
7
8
9
10
11
12
{
"aps": {
"alert": {
"body": "You are within 5 miles of Silver Salmon Creek."
"title": "Silver Salmon Creek",
},
"category": "LandmarkNear",
"thread-id": "5280"
},

"landmarkIndex": 1
}

负载文件模拟远程通知中从服务器发送的数据。

步骤9

选择“Landmarks-Watch (Notification)” scheme,并编译和运行您的应用程序。

第一次运行通知Scheme时,系统将请求发送通知的权限。选择Allow(允许)。模拟器随后显示一个可滚动的通知,其中包括:一个用于将地标应用程序标记为发送者的框、通知视图和通知操作的按钮。

应用程序设计和布局

使用UI控件

在地标应用程序中,用户可以创建一个个人资料页来表达他们的个性。为了让用户能够更改他们的个人简介,您将添加一个编辑模式,并设计一个偏好设置页面。

您将使用各种用于数据输入的通用用户界面控件,并在用户保存更改时更新地标数据模型。

学习时间:25分钟

下载地址:WorkingWithUIControls.zip

第一节 显示用户简介

Landmarks应用程序在本地存储一些详情配置和偏好设置。在用户编辑其详情之前,它们将显示在没有任何编辑控件的摘要视图中。

步骤1

要开始,请在Landmarks目录下创建一个名为Profile的新目录,然后将名为ProfileHost的视图添加到该目录中。

ProfileHost视图将同时承载用户信息的静态摘要视图和编辑模式。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI

struct ProfileHost: View {
@State var draftProfile = Profile.default
var body: some View {
Text("Profile for: \(draftProfile.username)")
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤2

Home.swift中的静态文本替换为上一步中创建的ProfileHost

现在,主屏幕上的profile按钮将以模态方式展现用户简介。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false
@EnvironmentObject var userData: UserData

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())

NavigationLink(destination: LandmarkList()) {
Text("See All")
}
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
ProfileHost()
.environmentObject(self.userData)
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤3

创建一个名为ProfileSummary的新视图,该视图接受一个Profile实例并显示一些基本的用户信息。

ProfileSummary持有一个Profile,比个人简介持有它好,因为父视图ProfileHost管理此视图的State

ProfileSummary.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import SwiftUI

struct ProfileSummary: View {
var profile: Profile

static let goalFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()

var body: some View {
List {
Text(profile.username)
.bold()
.font(.title)

Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")

Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")

Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")
}
}
}

struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}

步骤4

更新ProfileHost以显示新的摘要视图。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct ProfileHost: View {
@State var draftProfile = Profile.default
var body: some View {
VStack(alignment: .leading, spacing: 20) {
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤5

创建一个名为HikeBadge 的新视图,该视图由绘制路径和形状中制作的徽章以及徒步旅行的一些数据文本组成。

徽章只是一个图形,因此HikeBadge中的文本和accessibility(label:) 修饰符使徽章的含义对其他用户更清晰。

注意:

两次调用frame(width:height:)修饰符,使徽章以其设计时的尺寸300×300点进行缩放渲染。

HikeBadge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct HikeBadge: View {
var name: String
var body: some View {
VStack(alignment: .center) {
Badge()
.frame(width: 300, height: 300)
.scaleEffect(1.0 / 3.0)
.frame(width: 100, height: 100)
Text(name)
.font(.caption)
.accessibility(label: Text("Badge for \(name)."))
}
}
}

struct HikeBadge_Previews: PreviewProvider {
static var previews: some View {
HikeBadge(name: "Preview Testing")
}
}

步骤6

更新ProfileSummary以添加不同颜色的徽章以及获得徽章的原因文字。

ProfileSummary.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import SwiftUI

struct ProfileSummary: View {
var profile: Profile

static let goalFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()

var body: some View {
List {
Text(profile.username)
.bold()
.font(.title)

Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")

Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")

Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")

VStack(alignment: .leading) {
Text("Completed Badges")
.font(.headline)
ScrollView {
HStack {
HikeBadge(name: "First Hike")

HikeBadge(name: "Earth Day")
.hueRotation(Angle(degrees: 90))


HikeBadge(name: "Tenth Hike")
.grayscale(0.5)
.hueRotation(Angle(degrees: 45))
}
}
.frame(height: 140)
}
}
}
}

struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}

步骤7

通过引入视图动画与转场HikeView来完成ProfileSummary

ProfileSummary.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import SwiftUI

struct ProfileSummary: View {
var profile: Profile

static let goalFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()

var body: some View {
List {
Text(profile.username)
.bold()
.font(.title)

Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")

Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")

Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")

VStack(alignment: .leading) {
Text("Completed Badges")
.font(.headline)
ScrollView {
HStack {
HikeBadge(name: "First Hike")

HikeBadge(name: "Earth Day")
.hueRotation(Angle(degrees: 90))


HikeBadge(name: "Tenth Hike")
.grayscale(0.5)
.hueRotation(Angle(degrees: 45))
}
}
.frame(height: 140)
}

VStack(alignment: .leading) {
Text("Recent Hikes")
.font(.headline)

HikeView(hike: hikeData[0])
}
}
}
}

struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}

第二节 添加编辑模式

用户需要在查看或编辑其简介详情之间切换。您将添加一个编辑模式,通过向现有的ProfileHost添加一个EditButton,然后创建一个带有控件的视图,用于编辑单个数据。

步骤1

添加一个Environment属性,并设置\.editMode

可以使用此属性读取和写入当前编辑范围。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@State var draftProfile = Profile.default
var body: some View {
VStack(alignment: .leading, spacing: 20) {
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤2

创建一个编辑按钮,用于打开和关闭环境的编辑模式。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@State var draftProfile = Profile.default
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤3

更新UserData类以包含用户简介的实例,该实例在用户关闭简介视图后仍然存在。

UserData.swift

1
2
3
4
5
6
7
8
import Combine
import SwiftUI

final class UserData: ObservableObject {
@Published var showFavoritesOnly = false
@Published var landmarks = landmarkData
@Published var profile = Profile.default
}

步骤4

Environment中读取Profile数据,将数据的控制权传递给ProfileHost

为了避免在确认编辑之前更新全局应用程序状态(例如当用户输入其名称时),编辑视图将对其自身的副本进行操作。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤5

添加条件视图,显示静态简介视图或编辑模式视图。

注意

目前,编辑模式只是一个静态文本字段。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
Text("Profile Editor")
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

第三节 定义用户简介编辑器

用户简介编辑器主要由不同的控件组成,这些控件更改用户简介中的各个详情信息。配置文件中的某些项目(如徽章)不可由用户编辑,因此它们不会显示在编辑器中。

为了与信息摘要保持一致,您将在编辑器中按相同的顺序添加概要文件详情信息。

步骤1

创建一个名为ProfileEditor的新视图,并包含对用户简介副本的绑定。

视图中的第一个控件是一个TextField,它控制并更新一个字符串的绑定,是用户选择的显示名称。当创建TextField时,您需要提供标签和字符串的绑定。

ProfileEditor.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

步骤2

更新ProfileHost中的条件内容,使其包含Profile Editor,并传递简介信息的绑定。

现在,单击“Edit”时将显示“简介编辑视图”。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
ProfileEditor(profile: $draftProfile)
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤3

添加是否接收地标相关事件通知的开关。

Toggles是只有打开或关闭的控件,因此它们非常适合布尔值Boolean,如“yes”或“no”的设置。

ProfileEditor.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}

Toggle(isOn: $profile.prefersNotifications) {
Text("Enable Notifications")
}
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

步骤4

Picker控件及其标签放置在VStack中,使地标照片具有可选择的季节。

ProfileEditor.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}

Toggle(isOn: $profile.prefersNotifications) {
Text("Enable Notifications")
}

VStack(alignment: .leading, spacing: 20) {
Text("Seasonal Photo").bold()

Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
ForEach(Profile.Season.allCases, id: \.self) { season in
Text(season.rawValue).tag(season)
}
}
.pickerStyle(SegmentedPickerStyle())
}
.padding(.top)
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

步骤5

最后,在季节选择器下面添加一个DatePicker,修改到达地标的日期。

ProfileEditor.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var dateRange: ClosedRange<Date> {
let min = Calendar.current.date(byAdding: .year, value: -1, to: profile.goalDate)!
let max = Calendar.current.date(byAdding: .year, value: 1, to: profile.goalDate)!
return min...max
}

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}

Toggle(isOn: $profile.prefersNotifications) {
Text("Enable Notifications")
}

VStack(alignment: .leading, spacing: 20) {
Text("Seasonal Photo").bold()

Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
ForEach(Profile.Season.allCases, id: \.self) { season in
Text(season.rawValue).tag(season)
}
}
.pickerStyle(SegmentedPickerStyle())
}
.padding(.top)

VStack(alignment: .leading, spacing: 20) {
Text("Goal Date").bold()
DatePicker(
"Goal Date",
selection: $profile.goalDate,
in: dateRange,
displayedComponents: .date)
}
.padding(.top)
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

第四节 延迟编辑传递

要使其编辑,直到用户退出编辑模式后才生效,在编辑过程中使用其Profile的草稿副本,然后仅当用户确认编辑时才将草稿副本分配给真实副本。

步骤1

ProfileHost添加取消按钮。

EditButton提供的Done按钮不同,Cancel按钮不会将编辑应用于其闭包中的真实Profile数据。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
if self.mode?.wrappedValue == .active {
Button("Cancel") {
self.draftProfile = self.userData.profile
self.mode?.animation().wrappedValue = .inactive
}
}

Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
ProfileEditor(profile: $draftProfile)
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤2

应用onAppear(perform:)onDisappear(perform:)修饰符,将正确的用户简介数据填充给编辑器,并在用户点击Done按钮时更新简介数据。

否则,在下次激活编辑模式时显示旧值。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
if self.mode?.wrappedValue == .active {
Button("Cancel") {
self.draftProfile = self.userData.profile
self.mode?.animation().wrappedValue = .inactive
}
}

Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
ProfileEditor(profile: $draftProfile)
.onAppear {
self.draftProfile = self.userData.profile
}
.onDisappear {
self.userData.profile = self.draftProfile
}
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

处理用户输入

Landmarks应用程序中,用户可以标记他们最喜欢的位置,并筛选列表来仅仅显示他们最喜欢的位置。要创建此功能,首先要向列表中添加一个开关,以便用户只关注他们的收藏夹,然后添加一个星形按钮,用户点击该按钮可将地标标记到收藏夹。

学习时间:20分钟

下载示例:HandlingUserInput.zip

第一节 标记用户最喜欢的地标

从优化列表开始,让用户一目了然地看到他们的最爱。为每个显示最喜欢的地标行添加一个星。

步骤1

打开Xcode项目,然后在项目导航器中选择LandmarkRow.swift

步骤2

Spacer()之后,在if语句中添加一个星星Image,用来测试当前地标是否被收藏。

SwiftUI语句块中,使用if语句有条件地包含视图。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.imageScale(.medium)
}
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

步骤3

由于系统图像是基于矢量的,因此可以使用foregroundColor(_:)修改器更改其颜色。

当地标的isFavorite属性为true时,星星就出现了。您将在本教程后面看到如何修改该属性。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.imageScale(.medium)
.foregroundColor(.yellow)
}
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

第二节 筛选列表视图

您可以自定义列表视图,使其显示所有地标,或仅显示用户的收藏夹。为此,需要向LandmarkList类型添加@State

@State是一个值或一组值,可以随时间变化,并影响视图的行为、内容或布局。使用带有@State的属性将其添加到视图中。

步骤1

在项目导航器中选择LandmarkList.swift。将名为showFavoritesOnly@State属性添加到LandmarkList,其初始值设置为false

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = false

var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

单击“Resume”按钮刷新画布。

当您对视图的结构进行更改(如添加或修改属性)时,需要手动刷新画布。

步骤3

通过检查showFavoritesOnly属性和每个landmark.isFavorite值筛选地标列表。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = false

var body: some View {
NavigationView {
List(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第三节 添加控件以切换状态

要让用户控制列表的筛选器,需要添加一个控件,该控件可以单独更改showFavoritesOnly的值。通过绑定toggle控件来完成此操作。

绑定是对可变状态的引用。当用户从关闭切换到打开,然后再次关闭时,控件使用绑定相应地更新视图的状态。

步骤1

将行嵌套到ForEach中。

若要在列表中组合静态视图和动态视图,或组合两个或多个不同的动态视图组,请使用ForEach类型,而不是将数据集合传递给列表。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = true

var body: some View {
NavigationView {
List {
ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

添加一个Toggle视图作为列表视图的第一个子视图,给showFavoritesOnly做一个绑定。

您可以使用$来访问状态变量或其绑定的属性。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = true

var body: some View {
NavigationView {
List {
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}

ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤3

使用实时预览并通过点击切换来尝试此新功能。

第四节 使用Observable Object进行存储

为了让用户控制哪些特定的地标是最喜欢的,您首先要将地标数据存储在一个Observable Object中。

Observable Object的自定义对象,可以从SwiftUI环境中的存储绑定到视图。SwiftUI监视可观察对象的任何可能影响视图的更改,并在更改后显示正确的视图。

步骤1

创建一个名为UserData.Swift的新Swift文件。

UserData.swift

步骤2

从组合框架中声明遵守ObservableObject协议的新模型类型。

SwiftUI订阅您的ObservableObject,并在数据更改时更新任何需要刷新的视图。

UserData.swift

1
2
3
4
5
6
import SwiftUI
import Combine

final class UserData: ObservableObject {

}

步骤3

添加showFavoritesOnly和地标的存储属性及其初始值。

UserData.swift

1
2
3
4
5
6
7
import SwiftUI
import Combine

final class UserData: ObservableObject {
var showFavoritesOnly = false
var landmarks = landmarkData
}

ObservableObject需要发布对其数据的任何更改,以便其订阅者可以获取更改。

步骤4

@Published属性添加到模型中的每个属性

UserData.swift

1
2
3
4
5
6
7
import SwiftUI
import Combine

final class UserData: ObservableObject {
@Published var showFavoritesOnly = false
@Published var landmarks = landmarkData
}

第五节 在视图中采用你的模型对象

现在您已经创建了UserData对象,您需要更新视图以将其作为应用程序的数据存储。

步骤1

LandmarkList.swift中,用@EnvironmentObject属性替换showFavoritesOnly声明,并向预览添加environmentObject(:)修饰符。

只要environmentObject(:)修饰符已应用于父对象,此userData属性就会自动获取其值。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
List {
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}

ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}

步骤2

通过访问userData上的相同属性来替换showFavoritesOnly的使用。

@State属性一样,您可以使用$访问到userData对象成员的绑定。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

ForEach(landmarkData) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}

步骤3

创建ForEach实例时使用userData.landmarks作为数据。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}

步骤4

SceneDelegate.swift中,将environmentObject(:)修饰符添加到LandmarkList

如果您在模拟器或设备上构建并运行地标,而不是使用预览,则此更新将确保地标列表在环境中有一个UserData对象。

SceneDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(
rootView: LandmarkList()
.environmentObject(UserData())
)
self.window = window
window.makeKeyAndVisible()
}
}

// ...
}

步骤5

更新LandmarkDetail视图以在环境中使用UserData对象。

在访问或更新地标的收藏状态时,您将使用地标索引,以便始终访问该数据的正确版本。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import SwiftUI

struct LandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
MapView(landmark: landmark)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)
HStack(alignment: .top) {
Text(landmark.park)
.font(caption)
Spacer()
Text(landmark.state)
.font(.caption)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}

步骤6

切换回LandmarkList.swift并打开实时预览,以验证一切正常工作。

第六节 为每个地标创建收藏夹按钮

地标应用程序现在可以在过滤和未经过滤的地标视图之间切换,但最喜欢的地标列表仍然是硬编码的。要允许用户添加和删除收藏,需要将收藏按钮添加到地标详情视图。

步骤1

LandmarkDetail.swift中,将地标的名称嵌入HStack

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import SwiftUI

struct LandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
MapView(landmark: landmark)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
}

HStack(alignment: .top) {
Text(landmark.park)
.font(caption)
Spacer()
Text(landmark.state)
.font(.caption)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}

步骤2

在地标名称旁边创建一个新按钮。使用if-else条件语句提供不同的图像,以指示地标是否收藏。

在按钮的action闭包中,代码使用带有userData对象的landmarkIndex来更新地标。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import SwiftUI

struct LandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
MapView(landmark: landmark)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)

Button(action: {
self.userData.landmarks[self.landmarkIndex].isFavorite.toggle()
}) {
if self.userData.landmarks[self.landmarkIndex].isFavorite {
Image(systemName: "star.fill")
.foregroundColor(Color.yellow)
} else {
Image(systemName: "star")
.foregroundColor(Color.gray)
}
}
}

HStack(alignment: .top) {
Text(landmark.park)
.font(caption)
Spacer()
Text(landmark.state)
.font(.caption)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}

步骤3

切换回LandmarkList.swift,并打开实时预览。

当您从列表导航到详情信息并点击按钮时,这些更改将在您返回列表时保持不变。因为两个视图都在访问环境中的同一个模型对象,所以两个视图保持一致性。

User Interface

视图和控件

在屏幕上显示内容并处理用户交互。

概述

视图和控件是应用程序用户界面的可视化构建区块。使用它们在屏幕上显示应用程序的内容。视图可以描述文本、图像、形状、自定义绘图以及所有这些内容的组合。控件允许用户使用一致的API与其相应的平台和上下文进行交互。

使用指定其视觉关系和层次结构的容器合并视图。使用名为修饰符modifiers的方法自定义内置视图和为应用程序创建的视图的显示、行为和交互。

将修饰符modifiers应用于视图和控件:

  • 控制视图的大小、位置和外观属性。
  • 响应轻触、手势和其他用户交互。
  • 支持拖拽操作。
  • 自定义动画和转场。
  • 设置样式首选项和其他环境数据。

有关如何使用视图和控件的其他信息,请参见人机界面指南。

话题

摘要

protocol View

视图:用来描述SwiftUI的视图类型。

Creating and Combining Views

创建并组合视图:本教程将指导您构建地标,这是一个iOS应用程序,用于发现和共享您喜欢的地方。您将首先构建显示地标的详情视图。

Working with UI Controls

使用UI控件:在地标应用程序中,用户可以创建个人简介来表达他们的个性。为了让用户能够更改他们的个人简介,您将添加一个编辑模式并设计首选项页面。

文本 Text

struct Text

文本:显示一行或多行只读文本的视图。

struct TextField

文本输入框:显示可编辑文本的控件。

struct SecureField

密文输入框:用户安全输入私密文本的控件。

struct Font

字体:依赖于环境的字体。

图像 Images

struct Image

图像:显示依赖于环境的图像视图。

按钮 Buttons

struct Button

按钮:触控时执行操作的控件。

struct NavigationLink

导航链接:按下时触发导航显示的按钮。

struct MenuButton

菜单按钮:当按下时显示包含选项列表的菜单的按钮。

struct EditButton

编辑按钮:切换当前编辑范围的编辑模式的按钮。

struct PasteButton

粘贴按钮:触发从粘贴板读取数据的系统按钮。

值选择器 Value Selectors

struct Toggle

开关:在打开和关闭状态之间切换的控件。

struct Picker

选择器:从一组互斥值中进行选择的控件。

struct DatePicker

日期选择器:用于选择绝对日期的控件。

struct Slider

滑块:从有界线性值范围中选择值的控件。

struct Stepper

步进器:用于执行递增和递减操作的控件。

支持类型 Supporting Types

struct ViewBuilder

视图构建器:从闭包构造视图的自定义参数属性。

protocol ViewModifier

视图修饰器:应用于视图或其他视图的修饰器,生成原始值的不同版本。

protocol

View

一个表示SwiftUI视图的类型。

代码释义

声明

protocol View

概述

通过声明遵守View protocol的类型来创建自定义视图。实现所需的body计算属性,以提供自定义视图的内容和行为。

主题

1 - Implementing a Custom View - 实现自定义视图

1.1 视图的内容和行为。必需的,默认实现。

demo

1
2
3
4
5
6
7
8
9
10
struct ContentView: View {
/// 1 - Implementing a Custom View - 实现自定义视图
/// 视图的内容和行为。
/// 必需的。默认实现。
var body: some View {
Text("Hello, World!")
.background(Color.yellow)
.border(Color.red, width: 2)
}
}

2 - Sizing a Views - 制定视图的大小

2.1 将视图定位在具有指定大小的不可见框架中

1
func frame(width: CGFloat?, height: CGFloat?, alignment: Alignment) -> View

2.2 将视图定位在具有指定宽度和高度的不可见框架中。

1
func frame(minWidth: CGFloat?, idealWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, idealHeight: CGFloat?, maxHeight: CGFloat?, alignment: Alignment) -> View

2.3 将视图修复为理想大小。

2.4 将视图修复为理想大小,可指定垂直或水平方向的修复。

1
func fixedSize(horizontal: Bool, vertical: Bool) -> View

2.5 设置父布局应将空间分配给子布局的优先级,默认为0。

1
func layoutPriority(Double) -> View

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct SizingInViews: View {
/// 2 - Sizing a Views - 制定视图大小
var body: some View {
VStack {
/// 2.1 将视图定位在具有指定大小的不可见框架中。
Text("Hello, World!")
.background(Color.yellow)
.border(Color.red, width: 2)
.frame(width: 200, height: 50, alignment: .topLeading)

/// 2.2 将视图定位在具有指定宽度和高度的不可见框架中。
Text("Hello, World 2Hello, World 2Hello, World 2Hello, World 2Hello, World 2Hello, World 2Hello")
.background(Color.yellow)
.border(Color.red, width: 2)
.frame(minWidth: 10, idealWidth: 60, maxWidth: 300, minHeight: 10, idealHeight: 60, maxHeight: 300, alignment: .topTrailing)

/// 2.3 将视图固定在其理想大小。
Text("Hello, World3")
.background(Color.yellow)
.border(Color.red, width: 2)
.fixedSize()

/// 2.4 将视图修复为理想大小,可指定垂直或水平方向的修复。
Text("Hello, World4Hello, World4HelloHello, World4Hello, World4HelloHello")
.background(Color.yellow)
.border(Color.red, width: 2)
.fixedSize(horizontal: true, vertical: false)
.frame(width: 200, height: 200)

/// 2.5 指定布局优先级 默认为0
Text("Hello, World5")
.background(Color.yellow)
.border(Color.red, width: 2)
.layoutPriority(1)

}
}
}

3 - Positioning a View - 定位视图

3.1 将视图的中心固定在其父坐标空间的指定点上。

1
func position(CGPoint) -> View

3.2 将视图的中心固定在其父坐标空间中指定的坐标上。

1
func position(x: CGFloat, y: CGFloat) -> View

3.3 通过给定Size中的widthheight偏移视图。

1
func offset(CGSize) -> View

3.4 通过指定的xy值偏移视图。

1
func offset(x: CGFloat, y: CGFloat) -> View

3.5 将视图延展到指定边缘的安全区域之外。

1
func edgesIgnoringSafeArea(Edge.Set) -> View

3.6 将名称分配给此视图的坐标空间,此视图的后代可以将其引用。

1
func coordinateSpace<T>(name: T) -> View

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct PositioningAViews: View {
/// 3 - Positioning a View - 定位视图
var body: some View {
ZStack {
/// 3.1 将视图的中心固定在其父坐标空间的指定点上。
Text("Hello, World!")
.background(Color.red)
.position(CGPoint(x: 100, y: 0))

/// 3.2 将视图的中心固定在其父坐标空间中指定的坐标上。
Text("Hello, World2")
.background(Color.green)
.position(x: 100, y: 30)

/// 3.3 通过给定`Size`中的`width`和`height`偏移视图。
Text("Hello, World3")
.background(Color.red)
.position(x: 100, y: 60)
.offset(CGSize(width: 30, height: 0))

/// 3.4 通过指定的`x`和`y`值偏移视图。
Text("Hello, World4")
.background(Color.green)
.position(x: 100, y: 90)
.offset(x: -30, y: 0)

}
.edgesIgnoringSafeArea(.top)/// 3.5 将视图延展到指定边缘的安全区域之外。
.coordinateSpace(name: "test")/// 3.6 将名称分配给此视图的坐标空间,此视图的后代可以将其引用。
}
}

4 - Aligning Views - 设置视图的对其方式

4.1 设置视图的水平对齐方式。

1
func alignmentGuide(HorizontalAlignment, computeValue: (ViewDimensions) -> CGFloat) -> View

4.2 设置视图的垂直

对齐方式。

1
func alignmentGuide(VerticalAlignment, computeValue: (ViewDimensions) -> CGFloat) -> View

4.3 视图在其自身的坐标空间中的大小和对齐方式的规则。

4.4 用于标识对齐规则的类型。

5 - Adjusting the Padding of a View - 调整视图边距

5.1 将视图沿所有边缘内嵌填充指定的大小。

1
func padding(CGFloat) -> View

5.2 使用指定的EdgeInsets内嵌填充视图。

1
func padding(EdgeInsets) -> View

5.3 使用指定的Edge集合内嵌填充指定大小。

1
func padding(Edge.Set, CGFloat?) -> View

5.4 定义了矩形各边的内嵌距离的结构体。

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct AdjustingPaddingView: View {
/// 5 - Adjusting the Padding of a View - 调整视图边距
var body: some View {
ZStack {

Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
.background(Color.yellow)
.border(Color.red, width: 2)
.position(x: 100, y: 30)
.padding()

}
.background(Color.gray)
// .padding()/// 默认边距
// .padding(30) /// 5.1 将视图沿所有边缘内嵌填充指定的大小。
// .padding(EdgeInsets(top: 20, leading: 10, bottom: 60, trailing: 50)) /// 5.2 使用指定的`EdgeInsets`内嵌填充视图。
.padding([.leading,.trailing], 20) /// 5.3 使用指定的`Edge`集合内嵌填充指定大小。

}
}

留言與分享

在统计学里,长期以来,有频率学派和贝叶斯学派两大学派,他们互相鄙视对方,就像华山派的气宗与剑宗之争。

这两大学派最根本的观点在于看待世界的方式不同:

  • 频率学派认为世界是客观的,必须通过大量独立采样来获得统计均值,不能先给出一个主观的先验概率(假设);
  • 贝叶斯学派则认为概率是一种信念度,可以有非常主观的先验概率,然后,通过一次次采样结果修正先验概率,使之逼近客观事实。

这两大学派哪个才是正确的?其实都对,只是看待世界的角度不同。但是在现实世界中,除了抛硬币、掷骰子、玩老虎机等少数符合理想数学模型的场景,频率学派才能发挥作用。大多数需要我们估算概率的现实场景,只能用贝叶斯理论来指导实践。

举个例子,假设我住在市区,希望赶上飞机的概率不低于90%,那么我应该提前多久出发呢?我必须试验至少100次,看看样本空间,才能获得一个比较准确的统计均值。然而这是不现实的,因为我一年可能就坐几次飞机。我只能拍脑袋先估一个提前30分钟就够了,结果第三次就没赶上,这说明我必须修正我的先验概率,后续改为提前45分钟,才能提升赶上飞机的概率。

我们再以《狼来了》的故事为例,当小孩第一次喊狼来了,村民听到后可以根据先验概率,比如P(小孩是诚实的)=90%判断赶紧去帮忙,结果发现被骗了,于是大家根据“被骗了”这一证据把后验概率P(小孩是诚实的)调整为60%,第二次又被骗了,于是再次把后验概率调整为20%,等到第三次听见小孩求救时,大家根据P(小孩是诚实的)=20%判断,他大概率还是在说谎,于是没有人去帮忙了。

有的同学会问,你说的这些,都是定性分析,没有定量计算啊!

要把贝叶斯定理用到定量计算,必须得借助计算机。

以吴军老师在中文分词领域举的一个例子来说,对于一个句子:南京市长江大桥,可以有两种划分:

  • 南京市 / 长江大桥
  • 南京市长 / 江大桥

到底哪一种更合理?我们可以计算条件概率:

  • P(长江大桥|南京市) = 出现“南京市”时,出现“长江大桥”的概率;
  • P(江大桥|南京市长) = 出现“南京市长”时,出现“江大桥”的概率。

提前准备好大量的中文语料,计算出任意两个词的条件概率,我们就可以得出哪种分词更合理。

在互联网领域,凡是遇到“当出现xyz时应该推荐什么”这样的条件概率时,也总是能应用贝叶斯理论。

例如,我们在搜索引擎中输入elon这个单词后,搜索框自动给出了联想补全:

elon

怎么实现这个功能?把用户最近搜索的所有可能的单词列出来,然后计算条件概率:

  • P(mask|elon)=0.5
  • P(jerk|elon)=0.1
  • P(university|elon)=0.2

把它们排个序,选出条件概率最大的几个,就是搜索建议。

诸如反垃圾邮件、电商推荐系统等,都是贝叶斯理论在机器学习中的应用。由于需要大量的计算,贝叶斯理论也只有在计算机时代才能广泛应用。

关于信念

我们再回顾一下贝叶斯定理:

稍微改一下,变为:

P(H)是先验概率,P(H|E)是后验概率,P(E|H)/P(E)被称为调整因子,先验概率乘以调整因子就得到后验概率。

我们发现,如果P(H)=0,则P(H|E)=0;如果P(H)=1,则P(E|H)=P(E),P(H|E)=1。

也就是说,如果先验概率为0%或100%,那么,无论出现任何证据E,都无法改变后验概率P(H|E)。这对我们看待世界的认知有重大指导意义,因为贝叶斯概率的本质是信念,通过一次次事件,我们可能加强某种信念,也可能减弱某种信念,但如果信念保持100%或0%,则可以做到对外界输入完全“免疫”。

举个例子,十年前许多人都认为比特币是庞氏骗局,如果100%坚定地持有这种信念,那么他将无视用户越来越多、价格上涨、交易量扩大、机构入市等诸多证据,至今仍然会坚信比特币是骗局而错过无数次机会。(注:此处示例不构成任何投资建议)

对于新生事物,每个人都可以有非常主观的先验概率,但只要我们不把先验概率定死为0或100%,就有机会改变自己的信念,从而更有可能接近客观事实,这也是贝叶斯定理的精髓:

你相信什么并不重要,重要的是你别完全相信它。

留言與分享

托马斯·贝叶斯(Thomas Bayes)是18世纪的英国数学家,也是一位虔诚的牧师。据说他为了反驳对上帝的质疑而推导出贝叶斯定理。贝叶斯定理是一个由结果倒推原因的概率算法,在贝叶斯提出这个条件概率公式后,很长一段时间,大家并没有觉得它有什么作用,并一直受到主流统计学派的排斥。直到计算机诞生后,人们发现,贝叶斯定理可以广泛应用在数据分析、模式识别、统计决策,以及最火的人工智能中,结果,贝叶斯定理是如此有用,以至于不仅应用在计算机上,还广泛应用在经济学、心理学、博弈论等各种领域,可以说,掌握并应用贝叶斯定理,是每个人必备的技能。

这里推荐两个视频,深入浅出地解释了贝叶斯定理:

Bayes’ Theorem 贝叶斯定理

Bayes theorem, the geometry of changing beliefs

如果你不想花太多时间看视频,可以继续阅读,我把视频内容编译成文字,以便快速学习贝叶斯定理。

为了搞明白贝叶斯定理究竟要解决什么问题,我们先看一个现实生活的例子:

已知有一种疾病,发病率是0.1%。针对这种疾病的测试非常准确:

  • 如果有病,则准确率是99%(即有1%未检出阳性);
  • 如果没有病,则误报率是2%(即有2%误报为阳性)。

现在,如果一个人测试显示阳性,请问他患病的概率是多少?

如果我们从大街上随便找一个人,那么他患病的概率就是0.1%,因为这个概率是基于历史统计数据的先验概率。

现在,他做了一次测试,结果为阳性,我们要计算他患病的概率,就是计算条件概率,即:在测试为阳性这一条件下,患病的概率是多少。

从直觉上这个人患病的概率大于0.1%,但也肯定小于99%。究竟是多少,怎么计算,我们先放一放。

为了理解条件概率,我们换一个更简单的例子:掷两次骰子,一共可能出现的结果有6x6=36种:

sample space

这就是所谓的样本空间,每个样本的概率均为1/36,这个很好理解。

如果我们定义事件A为:至少有一个骰子是2,那么事件A的样本空间如下图红色部分所示:

Event A

事件A一共有11种情况,我们计算事件A的概率P(A):

P(A)

我们再定义事件B:两个骰子之和为7,那么事件B的样本空间如下图绿色部分所示:

Event B

事件B一共有6种情况,我们计算事件B的概率P(B):

P(B)

接下来我们用P(A∩B)表示A和B同时发生的概率,A∩B就是A和B的交集,如下图蓝色部分所示:

P(A∩B)

显然A∩B只有两种情况,因此,计算P(A∩B):

P(A∩B)

接下来我们就可以讨论条件概率了。我们用P(A|B)表示在B发生的条件下,A发生的概率。由于B已经发生,所以,样本空间就是B的样本数量6,而要发生A则只能是A、B同时发生,即A∩B,有两种情况。

因此,计算P(A|B)如下:

P(A|B)

同理,我们用P(B|A)表示在A发生的条件下,B发生的概率。此时,分子仍然是A∩B的样本数量,但分母变成A的样本数量:

P(B|A)

可见,条件概率P(A|B)和P(B|A)是不同的。

我们再回到A、B同时发生的概率,观察P(A∩B)可以改写为:

P(B|A)xP(A)

同理,P(A∩B)还可以改写为:

P(A|B)xP(B)

因此,根据上述两个等式,我们推导出下面的等式:

把左边的P(A∩B)去掉,我们得到等式:

最后,整理一下等式,我们推导出贝叶斯定理如下:

这就是著名的贝叶斯定理,它表示,当出现B时,如何计算A的概率。

很多时候,我们把A改写为H,把B改写为E

H表示Hypothesis(假设),E表示Evidence(证据),贝叶斯定理的意义就在于,给定一个先验概率P(H),在出现了证据E的情况下,计算后验概率P(H|E)。

计算

有了贝叶斯定理,我们就可以回到开头的问题:

已知有一种疾病,发病率是0.1%。针对这种疾病的测试非常准确:

  • 如果有病,则准确率是99%(即有1%未检出阳性);
  • 如果没有病,则误报率是2%(即有2%误报为阳性)。

现在,如果一个人测试显示阳性,请问他患病的概率是多少?

用H表示患病,E表示测试为阳性,那么,我们要计算在测试为阳性的条件下,一个人患病的概率,就是计算P(H|E)。根据贝叶斯定理,计算如下:

P(H)表示患病的概率,根据发病率可知,P(H)=0.1%;

P(E|H)表示在患病的情况下,测试为阳性的概率,根据“如果有病,则准确率是99%”可知,P(E|H)=99%;

P(E)表示测试为阳性的概率。这个概率就稍微复杂点,因为它是指对所有人(包含病人和健康人)进行测试,结果阳性的概率。

我们可以把检测人数放大,例如放大到10万人,对10万人进行检测,根据发病率可知:

  • 有100人是病人,另外99900是健康人;
  • 对100个病人进行测试,有99人显示阳性,另有1人未检出(阴性);
  • 对99900个健康人进行测试,有2%=1998人显示阳性(误报),另有98%=97902人为阴性。

下图显示了检测为阳性的结果的分布:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
           ┌───────┐
│100000 │
└───────┘

┌───────┴───────┐
▼ ▼
┌───────┐ ┌───────┐
│ 100 │ │ 99900 │
└───────┘ └───────┘
│ │
┌───┴───┐ ┌───┴───┐
▼ ▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ 99 │ │ 1 │ │1998 │ │97902│
└─────┘ └─────┘ └─────┘ └─────┘
│ │
▼ ▼
+ +

所以,对于10万人的样本空间来说,事件E=显示阳性的概率为(99+1998)/100000=2.097%。

带入贝叶斯定理,计算P(H|E):

计算结果为患病的概率为4.721%,这个概率远小于99%,且与大多数人的直觉不同,原因在于庞大的健康人群导致的误报数量远多于病人,当出现“检测阳性”的证据时,患病的概率从先验概率0.1%提升到4.721%,还远不足以确诊。

贝叶斯定理的另一种表示

在上述计算中,我们发现计算P(E)是比较困难的,很多时候,甚至无法知道P(E)。此时,我们需要贝叶斯定理的另一种表示形式。

我们用P(H)表示H发生的概率,用H表示H不发生,P(H)表示H不发生的概率。显然P(H)=1-P(H)。

下图红色部分表示H,红色部分以外则表示H:

P(H)

事件E用绿色表示:

P(E)

可见,P(E)可以分为两部分,一部分是E和H的交集,另一部分是E和H的交集:

根据上文的公式P(A∩B)=P(A|B)xP(B),代入可得:

把P(E)替换掉,我们得到贝叶斯定理的另一种写法:

用这个公式来计算,我们就不必计算P(E)了。再次回到开头的问题:

已知有一种疾病,发病率是0.1%。针对这种疾病的测试非常准确:

  • 如果有病,则准确率是99%(即有1%未检出阳性);
  • 如果没有病,则误报率是2%(即有2%误报为阳性)。

现在,如果一个人测试显示阳性,请问他患病的概率是多少?

  • P(E|H)表示患病时检测阳性的概率=99%;
  • P(H)表示患病的概率=0.1%;
  • P(E|H)表示没有患病但检测阳性的概率=2%;
  • P(H)表示没有患病的概率=1-P(H)=99.9%。

代入公式,计算:

检测为阳性这一证据使得患病的概率从0.1%提升到4.721%。假设这个人又做了一次检测,结果仍然是阳性,那么他患病的概率是多少?

我们仍然使用贝叶斯定理计算,只不过现在先验概率P(H)不再是0.1%,而是4.721%,P(E|H)和P(E|H)仍保持不变,计算新的P(H|E):

结果为71%,两次检测为阳性的结果使得先验概率从0.1%提升到4.721%再提升到71%,继续第三次检测如果为阳性则概率将提升至99.18%。

可见,贝叶斯定理的核心思想就是不断根据新的证据,将先验概率调整为后验概率,使之更接近客观事实。

留言與分享

swift高级运算符

分類 编程语言, swift

高级运算符

除了之前介绍过的 基本运算符,Swift 还提供了数种可以对数值进行复杂运算的高级运算符。它们包含了在 C 和 Objective-C 中已经被大家所熟知的位运算符和移位运算符。

与 C 语言中的算术运算符不同,Swift 中的算术运算符默认是不会溢出的。所有溢出行为都会被捕获并报告为错误。如果想让系统允许溢出行为,可以选择使用 Swift 中另一套默认支持溢出的运算符,比如溢出加法运算符(&+)。所有的这些溢出运算符都是以 & 开头的。

自定义结构体、类和枚举时,如果也为它们提供标准 Swift 运算符的实现,将会非常有用。在 Swift 中为这些运算符提供自定义的实现非常简单,运算符也会针对不同类型使用对应实现。

我们不用被预定义的运算符所限制。在 Swift 中可以自由地定义中缀、前缀、后缀和赋值运算符,它们具有自定义的优先级与关联值。这些运算符在代码中可以像预定义的运算符一样使用,你甚至可以扩展已有的类型以支持自定义运算符。

位运算符

位运算符可以操作数据结构中每个独立的比特位。它们通常被用在底层开发中,比如图形编程和创建设备驱动。位运算符在处理外部资源的原始数据时也十分有用,比如对自定义通信协议传输的数据进行编码和解码。

Swift 支持 C 语言中的全部位运算符,接下来会一一介绍。

Bitwise NOT Operator(按位取反运算符)

*按位取反运算符(~)*对一个数值的全部比特位进行取反:

Art/bitwiseNOT_2x.png

按位取反运算符是一个前缀运算符,直接放在运算数之前,并且它们之间不能添加任何空格:

1
2
let initialBits: UInt8 = 0b00001111
let invertedBits = ~initialBits // 等于 0b11110000

UInt8 类型的整数有 8 个比特位,可以存储 0 ~ 255 之间的任意整数。这个例子初始化了一个 UInt8 类型的整数,并赋值为二进制的 00001111,它的前 4 位为 0,后 4 位为 1。这个值等价于十进制的 15

接着使用按位取反运算符创建了一个名为 invertedBits 的常量,这个常量的值与全部位取反后的 initialBits 相等。即所有的 0 都变成了 1,同时所有的 1 都变成 0invertedBits 的二进制值为 11110000,等价于无符号十进制数的 240

Bitwise AND Operator(按位与运算符)

按位与运算符(& 对两个数的比特位进行合并。它返回一个新的数,只有当两个数的对应位1 的时候,新数的对应位才为 1

Art/bitwiseAND_2x.png

在下面的示例当中,firstSixBitslastSixBits 中间 4 个位的值都为 1。使用按位与运算符之后,得到二进制数值 00111100,等价于无符号十进制数的 60

1
2
3
let firstSixBits: UInt8 = 0b11111100
let lastSixBits: UInt8 = 0b00111111
let middleFourBits = firstSixBits & lastSixBits // 等于 00111100

Bitwise OR Operator(按位或运算符)

按位或运算符(|可以对两个数的比特位进行比较。它返回一个新的数,只要两个数的对应位中有任意一个1 时,新数的对应位就为 1

Art/bitwiseOR_2x.png

在下面的示例中,someBitsmoreBits 存在不同的位被设置为 1。使用按位或运算符之后,得到二进制数值 11111110,等价于无符号十进制数的 254

1
2
3
let someBits: UInt8 = 0b10110010
let moreBits: UInt8 = 0b01011110
let combinedbits = someBits | moreBits // 等于 11111110

Bitwise XOR Operator(按位异或运算符)

按位异或运算符,或称“排外的或运算符”(^),可以对两个数的比特位进行比较。它返回一个新的数,当两个数的对应位不相同时,新数的对应位就为 1,并且对应位相同时则为 0

Art/bitwiseXOR_2x.png

在下面的示例当中,firstBitsotherBits 都有一个自己为 1,而对方为 0 的位。按位异或运算符将新数的这两个位都设置为 1。在其余的位上 firstBitsotherBits 是相同的,所以设置为 0

1
2
3
let firstBits: UInt8 = 0b00010100
let otherBits: UInt8 = 0b00000101
let outputBits = firstBits ^ otherBits // 等于 00010001

Bitwise Left and Right Shift Operators(按位左移、右移运算符)

按位左移运算符(<< 和 *按位右移运算符(>>)*可以对一个数的所有位进行指定位数的左移和右移,但是需要遵守下面定义的规则。

对一个数进行按位左移或按位右移,相当于对这个数进行乘以 2 或除以 2 的运算。将一个整数左移一位,等价于将这个数乘以 2,同样地,将一个整数右移一位,等价于将这个数除以 2。

无符号整数的移位运算

对无符号整数进行移位的规则如下:

  1. 已存在的位按指定的位数进行左移和右移。
  2. 任何因移动而超出整型存储范围的位都会被丢弃。
  3. 0 来填充移位后产生的空白位。

这种方法称为逻辑移位

以下这张图展示了 11111111 << 1(即把 11111111 向左移动 1 位),和 11111111 >> 1(即把 11111111 向右移动 1 位)的结果。蓝色的数字是被移位的,灰色的数字是被抛弃的,橙色的 0 则是被填充进来的:

Art/bitshiftUnsigned_2x.png

下面的代码演示了 Swift 中的移位运算:

1
2
3
4
5
6
let shiftBits: UInt8 = 4 // 即二进制的 00000100
shiftBits << 1 // 00001000
shiftBits << 2 // 00010000
shiftBits << 5 // 10000000
shiftBits << 6 // 00000000
shiftBits >> 2 // 00000001

可以使用移位运算对其他的数据类型进行编码和解码:

1
2
3
4
let pink: UInt32 = 0xCC6699
let redComponent = (pink & 0xFF0000) >> 16 // redComponent 是 0xCC,即 204
let greenComponent = (pink & 0x00FF00) >> 8 // greenComponent 是 0x66, 即 102
let blueComponent = pink & 0x0000FF // blueComponent 是 0x99,即 153

这个示例使用了一个命名为 pinkUInt32 型常量来存储 Cascading Style Sheets(CSS)中粉色的颜色值。该 CSS 的颜色值 #CC6699,在 Swift 中表示为十六进制的 0xCC6699。然后利用按位与运算符(&)和按位右移运算符(>>)从这个颜色值中分解出红(CC)、绿(66)以及蓝(99)三个部分。

红色部分是通过对 0xCC66990xFF0000 进行按位与运算后得到的。0xFF0000 中的 0 部分“掩盖”了 OxCC6699 中的第二、第三个字节,使得数值中的 6699 被忽略,只留下 0xCC0000

然后,将这个数向右移动 16 位(>> 16)。十六进制中每两个字符占用 8 个比特位,所以移动 16 位后 0xCC0000 就变为 0x0000CC。这个数和 0xCC 是等同的,也就是十进制数值的 204

同样的,绿色部分通过对 0xCC66990x00FF00 进行按位与运算得到 0x006600。然后将这个数向右移动 8 位,得到 0x66,也就是十进制数值的 102

最后,蓝色部分通过对 0xCC66990x0000FF 进行按位与运算得到 0x000099。这里不需要再向右移位,而 0x000099 也就是 0x99 ,也就是十进制数值的 153

有符号整数的移位运算

对比无符号整数,有符号整数的移位运算相对复杂得多,这种复杂性源于有符号整数的二进制表现形式。(为了简单起见,以下的示例都是基于 8 比特的有符号整数,但是其中的原理对任何位数的有符号整数都是通用的。)

有符号整数使用第 1 个比特位(通常被称为符号位)来表示这个数的正负。符号位为 0 代表正数,为 1 代表负数。

其余的比特位(通常被称为数值位)存储了实际的值。有符号正整数和无符号数的存储方式是一样的,都是从 0 开始算起。这是值为 4Int8 型整数的二进制位表现形式:

Art/bitshiftSignedFour_2x.png

符号位为 0(代表这是一个“正数”),另外 7 位则代表了十进制数值 4 的二进制表示。

负数的存储方式略有不同。它存储 2n 次方减去其实际值的绝对值,这里的 n 是数值位的位数。一个 8 比特位的数有 7 个比特位是数值位,所以是 27 次方,即 128

这是值为 -4Int8 型整数的二进制表现形式:

Art/bitshiftSignedMinusFour_2x.png

这次的符号位为 1,说明这是一个负数,另外 7 个位则代表了数值 124(即 128 - 4)的二进制表示:

Art/bitshiftSignedMinusFourValue_2x.png

负数的表示通常被称为二进制补码。用这种方法来表示负数乍看起来有点奇怪,但它有几个优点。

首先,如果想对 -1-4 进行加法运算,我们只需要对这两个数的全部 8 个比特位执行标准的二进制相加(包括符号位),并且将计算结果中超出 8 位的数值丢弃:

Art/bitshiftSignedAddition_2x.png

其次,使用二进制补码可以使负数的按位左移和右移运算得到跟正数同样的效果,即每向左移一位就将自身的数值乘以 2,每向右一位就将自身的数值除以 2。要达到此目的,对有符号整数的右移有一个额外的规则:当对有符号整数进行按位右移运算时,遵循与无符号整数相同的规则,但是对于移位产生的空白位使用符号位进行填充,而不是用 0

Art/bitshiftSigned_2x.png

这个行为可以确保有符号整数的符号位不会因为右移运算而改变,这通常被称为算术移位

由于正数和负数的特殊存储方式,在对它们进行右移的时候,会使它们越来越接近 0。在移位的过程中保持符号位不变,意味着负整数在接近 0 的过程中会一直保持为负。

溢出运算符

当向一个整数类型的常量或者变量赋予超过它容量的值时,Swift 默认会报错,而不是允许生成一个无效的数。这个行为为我们在运算过大或者过小的数时提供了额外的安全性。

例如,Int16 型整数能容纳的有符号整数范围是 -3276832767。当为一个 Int16 类型的变量或常量赋予的值超过这个范围时,系统就会报错:

1
2
3
4
var potentialOverflow = Int16.max
// potentialOverflow 的值是 32767,这是 Int16 能容纳的最大整数
potentialOverflow += 1
// 这里会报错

在赋值时为过大或者过小的情况提供错误处理,能让我们在处理边界值时更加灵活。

然而,当你希望的时候也可以选择让系统在数值溢出的时候采取截断处理,而非报错。Swift 提供的三个溢出运算符来让系统支持整数溢出运算。这些运算符都是以 & 开头的:

  • 溢出加法 &+
  • 溢出减法 &-
  • 溢出乘法 &*

数值溢出

数值有可能出现上溢或者下溢。

这个示例演示了当我们对一个无符号整数使用溢出加法(&+)进行上溢运算时会发生什么:

1
2
3
4
var unsignedOverflow = UInt8.max
// unsignedOverflow 等于 UInt8 所能容纳的最大整数 255
unsignedOverflow = unsignedOverflow &+ 1
// 此时 unsignedOverflow 等于 0

unsignedOverflow 被初始化为 UInt8 所能容纳的最大整数(255,以二进制表示即 11111111)。然后使用溢出加法运算符(&+)对其进行加 1 运算。这使得它的二进制表示正好超出 UInt8 所能容纳的位数,也就导致了数值的溢出,如下图所示。数值溢出后,仍然留在 UInt8 边界内的值是 00000000,也就是十进制数值的 0

Art/overflowAddition_2x.png

当允许对一个无符号整数进行下溢运算时也会产生类似的情况。这里有一个使用溢出减法运算符(&-)的例子:

1
2
3
4
var unsignedOverflow = UInt8.min
// unsignedOverflow 等于 UInt8 所能容纳的最小整数 0
unsignedOverflow = unsignedOverflow &- 1
// 此时 unsignedOverflow 等于 255

UInt8 型整数能容纳的最小值是 0,以二进制表示即 00000000。当使用溢出减法运算符对其进行减 1 运算时,数值会产生下溢并被截断为 11111111, 也就是十进制数值的 255

Art/overflowUnsignedSubtraction_2x.png

溢出也会发生在有符号整型上。针对有符号整型的所有溢出加法或者减法运算都是按位运算的方式执行的,符号位也需要参与计算,正如 按位左移、右移运算符 所描述的。

1
2
3
4
var signedOverflow = Int8.min
// signedOverflow 等于 Int8 所能容纳的最小整数 -128
signedOverflow = signedOverflow &- 1
// 此时 signedOverflow 等于 127

Int8 型整数能容纳的最小值是 -128,以二进制表示即 10000000。当使用溢出减法运算符对其进行减 1 运算时,符号位被翻转,得到二进制数值 01111111,也就是十进制数值的 127,这个值也是 Int8 型整所能容纳的最大值。

Art/overflowSignedSubtraction_2x.png

对于无符号与有符号整型数值来说,当出现上溢时,它们会从数值所能容纳的最大数变成最小数。同样地,当发生下溢时,它们会从所能容纳的最小数变成最大数。

优先级和结合性

运算符的优先级使得一些运算符优先于其他运算符;它们会先被执行。

结合性定义了相同优先级的运算符是如何结合的,也就是说,是与左边结合为一组,还是与右边结合为一组。可以将其理解为“它们是与左边的表达式结合的”,或者“它们是与右边的表达式结合的”。

当考虑一个复合表达式的计算顺序时,运算符的优先级和结合性是非常重要的。举例来说,运算符优先级解释了为什么下面这个表达式的运算结果会是 17

1
2
2 + 3 % 4 * 5
// 结果是 17

如果你直接从左到右进行运算,你可能认为运算的过程是这样的:

  • 2 + 3 = 5
  • 5 % 4 = 1
  • 1 * 5 = 5

但是正确答案是 17 而不是 5。优先级高的运算符要先于优先级低的运算符进行计算。与 C 语言类似,在 Swift 中,乘法运算符(*)与取余运算符(%)的优先级高于加法运算符(+)。因此,它们的计算顺序要先于加法运算。

而乘法运算与取余运算的优先级相同。这时为了得到正确的运算顺序,还需要考虑结合性。乘法运算与取余运算都是左结合的。可以将这考虑成,从它们的左边开始为这两部分表达式都隐式地加上括号:

1
2 + ((3 % 4) * 5)

(3 % 4) 等于 3,所以表达式相当于:

1
2 + (3 * 5)

3 * 5 等于 15,所以表达式相当于:

1
2 + 15

因此计算结果为 17

有关 Swift 标准库提供的操作符信息,包括操作符优先级组和结核性设置的完整列表,请参见 操作符声明

注意

相对 C 语言和 Objective-C 来说,Swift 的运算符优先级和结合性规则更加简洁和可预测。但是,这也意味着它们相较于 C 语言及其衍生语言并不是完全一致。在对现有的代码进行移植的时候,要注意确保运算符的行为仍然符合你的预期。

运算符函数

类和结构体可以为现有的运算符提供自定义的实现。这通常被称为运算符重载

下面的例子展示了如何让自定义的结构体支持加法运算符(+)。算术加法运算符是一个二元运算符,因为它是对两个值进行运算,同时它还可以称为中缀运算符,因为它出现在两个值中间。

例子中定义了一个名为 Vector2D 的结构体用来表示二维坐标向量 (x, y),紧接着定义了一个可以将两个 Vector2D 结构体实例进行相加的运算符函数

1
2
3
4
5
6
7
8
9
struct Vector2D {
var x = 0.0, y = 0.0
}

extension Vector2D {
static func + (left: Vector2D, right: Vector2D) -> Vector2D {
return Vector2D(x: left.x + right.x, y: left.y + right.y)
}
}

该运算符函数被定义为 Vector2D 上的一个类方法,并且函数的名字与它要进行重载的 + 名字一致。因为加法运算并不是一个向量必需的功能,所以这个类方法被定义在 Vector2D 的一个扩展中,而不是 Vector2D 结构体声明内。而算术加法运算符是二元运算符,所以这个运算符函数接收两个类型为 Vector2D 的参数,同时有一个 Vector2D 类型的返回值。

在这个实现中,输入参数分别被命名为 leftright,代表在 + 运算符左边和右边的两个 Vector2D 实例。函数返回了一个新的 Vector2D 实例,这个实例的 xy 分别等于作为参数的两个实例的 xy 的值之和。

这个类方法可以在任意两个 Vector2D 实例中间作为中缀运算符来使用:

1
2
3
4
let vector = Vector2D(x: 3.0, y: 1.0)
let anotherVector = Vector2D(x: 2.0, y: 4.0)
let combinedVector = vector + anotherVector
// combinedVector 是一个新的 Vector2D 实例,值为 (5.0, 5.0)

这个例子实现两个向量 (3.0,1.0)(2.0,4.0) 的相加,并得到新的向量 (5.0,5.0)。这个过程如下图示:

Art/vectorAddition_2x.png

前缀和后缀运算符

上个例子演示了一个二元中缀运算符的自定义实现。类与结构体也能提供标准一元运算符的实现。一元运算符只运算一个值。当运算符出现在值之前时,它就是前缀的(例如 -a),而当它出现在值之后时,它就是后缀的(例如 b!)。

要实现前缀或者后缀运算符,需要在声明运算符函数的时候在 func 关键字之前指定 prefix 或者 postfix 修饰符:

1
2
3
4
5
extension Vector2D {
static prefix func - (vector: Vector2D) -> Vector2D {
return Vector2D(x: -vector.x, y: -vector.y)
}
}

这段代码为 Vector2D 类型实现了一元运算符(-a)。由于该运算符是前缀运算符,所以这个函数需要加上 prefix 修饰符。

对于简单数值,一元负号运算符可以对它们的正负性进行改变。对于 Vector2D 来说,该运算将其 xy 属性的正负性都进行了改变:

1
2
3
4
5
let positive = Vector2D(x: 3.0, y: 4.0)
let negative = -positive
// negative 是一个值为 (-3.0, -4.0) 的 Vector2D 实例
let alsoPositive = -negative
// alsoPositive 是一个值为 (3.0, 4.0) 的 Vector2D 实例

复合赋值运算符

复合赋值运算符将赋值运算符(=)与其它运算符进行结合。例如,将加法与赋值结合成加法赋值运算符(+=)。在实现的时候,需要把运算符的左参数设置成 inout 类型,因为这个参数的值会在运算符函数内直接被修改。

在下面的例子中,对 Vector2D 实例实现了一个加法赋值运算符函数:

1
2
3
4
5
extension Vector2D {
static func += (left: inout Vector2D, right: Vector2D) {
left = left + right
}
}

因为加法运算在之前已经定义过了,所以在这里无需重新定义。在这里可以直接利用现有的加法运算符函数,用它来对左值和右值进行相加,并再次赋值给左值:

1
2
3
4
var original = Vector2D(x: 1.0, y: 2.0)
let vectorToAdd = Vector2D(x: 3.0, y: 4.0)
original += vectorToAdd
// original 的值现在为 (4.0, 6.0)

注意

不能对默认的赋值运算符(=)进行重载。只有复合赋值运算符可以被重载。同样地,也无法对三元条件运算符 (a ? b : c) 进行重载。

等价运算符

通常情况下,自定义的类和结构体没有对等价运算符进行默认实现,等价运算符通常被称为相等运算符(==)与不等运算符(!=)。

为了使用等价运算符对自定义的类型进行判等运算,需要为“相等”运算符提供自定义实现,实现的方法与其它中缀运算符一样, 并且增加对标准库 Equatable 协议的遵循:

1
2
3
4
5
extension Vector2D: Equatable {
static func == (left: Vector2D, right: Vector2D) -> Bool {
return (left.x == right.x) && (left.y == right.y)
}
}

上述代码实现了“相等”运算符(==)来判断两个 Vector2D 实例是否相等。对于 Vector2D 来说,“相等”意味着“两个实例的 xy 都相等”,这也是代码中用来进行判等的逻辑。如果你已经实现了“相等”运算符,通常情况下你并不需要自己再去实现“不等”运算符(!=)。标准库对于“不等”运算符提供了默认的实现,它简单地将“相等”运算符的结果进行取反后返回。

现在我们可以使用这两个运算符来判断两个 Vector2D 实例是否相等:

1
2
3
4
5
6
let twoThree = Vector2D(x: 2.0, y: 3.0)
let anotherTwoThree = Vector2D(x: 2.0, y: 3.0)
if twoThree == anotherTwoThree {
print("These two vectors are equivalent.")
}
// 打印“These two vectors are equivalent.”

多数简单情况下,您可以使用 Swift 为您提供的等价运算符默认实现。Swift 为以下数种自定义类型提供等价运算符的默认实现:

  • 只拥有存储属性,并且它们全都遵循 Equatable 协议的结构体
  • 只拥有关联类型,并且它们全都遵循 Equatable 协议的枚举
  • 没有关联类型的枚举

在类型原始的声明中声明遵循 Equatable 来接收这些默认实现。

下面为三维位置向量 (x, y, z) 定义的 Vector3D 结构体,与 Vector2D 类似。由于 xyz 属性都是 Equatable 类型,Vector3D 获得了默认的等价运算符实现。

1
2
3
4
5
6
7
8
9
10
struct Vector3D: Equatable {
var x = 0.0, y = 0.0, z = 0.0
}

let twoThreeFour = Vector3D(x: 2.0, y: 3.0, z: 4.0)
let anotherTwoThreeFour = Vector3D(x: 2.0, y: 3.0, z: 4.0)
if twoThreeFour == anotherTwoThreeFour {
print("These two vectors are also equivalent.")
}
// 打印“These two vectors are also equivalent.”

自定义运算符

除了实现标准运算符,在 Swift 中还可以声明和实现自定义运算符。可以用来自定义运算符的字符列表请参考 运算符

新的运算符要使用 operator 关键字在全局作用域内进行定义,同时还要指定 prefixinfix 或者 postfix 修饰符:

1
prefix operator +++

上面的代码定义了一个新的名为 +++ 的前缀运算符。对于这个运算符,在 Swift 中并没有已知的意义,因此在针对 Vector2D 实例的特定上下文中,给予了它自定义的意义。对这个示例来讲,+++ 被实现为“前缀双自增”运算符。它使用了前面定义的复合加法运算符来让矩阵与自身进行相加,从而让 Vector2D 实例的 x 属性和 y 属性值翻倍。你可以像下面这样通过对 Vector2D 添加一个 +++ 类方法,来实现 +++ 运算符:

1
2
3
4
5
6
7
8
9
10
11
extension Vector2D {
static prefix func +++ (vector: inout Vector2D) -> Vector2D {
vector += vector
return vector
}
}

var toBeDoubled = Vector2D(x: 1.0, y: 4.0)
let afterDoubling = +++toBeDoubled
// toBeDoubled 现在的值为 (2.0, 8.0)
// afterDoubling 现在的值也为 (2.0, 8.0)

自定义中缀运算符的优先级

每个自定义中缀运算符都属于某个优先级组。优先级组指定了这个运算符相对于其他中缀运算符的优先级和结合性。优先级和结合性 中详细阐述了这两个特性是如何对中缀运算符的运算产生影响的。

而没有明确放入某个优先级组的自定义中缀运算符将会被放到一个默认的优先级组内,其优先级高于三元运算符。

以下例子定义了一个新的自定义中缀运算符 +-,此运算符属于 AdditionPrecedence 优先组:

1
2
3
4
5
6
7
8
9
10
infix operator +-: AdditionPrecedence
extension Vector2D {
static func +- (left: Vector2D, right: Vector2D) -> Vector2D {
return Vector2D(x: left.x + right.x, y: left.y - right.y)
}
}
let firstVector = Vector2D(x: 1.0, y: 2.0)
let secondVector = Vector2D(x: 3.0, y: 4.0)
let plusMinusVector = firstVector +- secondVector
// plusMinusVector 是一个 Vector2D 实例,并且它的值为 (4.0, -2.0)

这个运算符把两个向量的 x 值相加,同时从第一个向量的 y 中减去第二个向量的 y 。因为它本质上是属于“相加型”运算符,所以将它放置在 +- 等默认中缀“相加型”运算符相同的优先级组中。关于 Swift 标准库提供的运算符,以及完整的运算符优先级组和结合性设置,请参考 运算符声明。而更多关于优先级组以及自定义操作符和优先级组的语法,请参考 运算符声明

注意

当定义前缀与后缀运算符的时候,我们并没有指定优先级。然而,如果对同一个值同时使用前缀与后缀运算符,则后缀运算符会先参与运算。

留言與分享

swift访问控制

分類 编程语言, swift

访问控制

访问控制可以限定其它源文件或模块中的代码对你的代码的访问级别。这个特性可以让我们隐藏代码的一些实现细节,并且可以为其他人可以访问和使用的代码提供接口。

你可以明确地给单个类型(类、结构体、枚举)设置访问级别,也可以给这些类型的属性、方法、构造器、下标等设置访问级别。协议也可以被限定在一定的范围内使用,包括协议里的全局常量、变量和函数。

Swift 不仅提供了多种不同的访问级别,还为某些典型场景提供了默认的访问级别,这样就不需要我们在每段代码中都申明显式访问级别。其实,如果只是开发一个单一 target 的应用程序,我们完全可以不用显式声明代码的访问级别。

注意

为了简单起见,对于代码中可以设置访问级别的特性(属性、基本类型、函数等),在下面的章节中我们会称之为“实体”。

模块和源文件

Swift 中的访问控制模型基于模块和源文件这两个概念。

模块指的是独立的代码单元,框架或应用程序会作为一个独立的模块来构建和发布。在 Swift 中,一个模块可以使用 import 关键字导入另外一个模块。

在 Swift 中,Xcode 的每个 target(例如框架或应用程序)都被当作独立的模块处理。如果你是为了实现某个通用的功能,或者是为了封装一些常用方法而将代码打包成独立的框架,这个框架就是 Swift 中的一个模块。当它被导入到某个应用程序或者其他框架时,框架内容都将属于这个独立的模块。

源文件就是 Swift 中的源代码文件,它通常属于一个模块,即一个应用程序或者框架。尽管我们一般会将不同的类型分别定义在不同的源文件中,但是同一个源文件也可以包含多个类型、函数之类的定义。

访问级别

Swift 为代码中的实体提供了五种不同的访问级别。这些访问级别不仅与源文件中定义的实体相关,同时也与源文件所属的模块相关。

  • OpenPublic 级别可以让实体被同一模块源文件中的所有实体访问,在模块外也可以通过导入该模块来访问源文件里的所有实体。通常情况下,你会使用 Open 或 Public 级别来指定框架的外部接口。Open 和 Public 的区别在后面会提到。
  • Internal 级别让实体被同一模块源文件中的任何实体访问,但是不能被模块外的实体访问。通常情况下,如果某个接口只在应用程序或框架内部使用,就可以将其设置为 Internal 级别。
  • File-private 限制实体只能在其定义的文件内部访问。如果功能的部分细节只需要在文件内使用时,可以使用 File-private 来将其隐藏。
  • Private 限制实体只能在其定义的作用域,以及同一文件内的 extension 访问。如果功能的部分细节只需要在当前作用域内使用时,可以使用 Private 来将其隐藏。

Open 为最高访问级别(限制最少),Private 为最低访问级别(限制最多)。

Open 只能作用于类和类的成员,它和 Public 的区别如下:

  • Public 或者其它更严访问级别的类,只能在其定义的模块内部被继承。
  • Public 或者其它更严访问级别的类成员,只能在其定义的模块内部的子类中重写。
  • Open 的类,可以在其定义的模块中被继承,也可以在引用它的模块中被继承。
  • Open 的类成员,可以在其定义的模块中子类中重写,也可以在引用它的模块中的子类重写。

把一个类标记为 open,明确的表示你已经充分考虑过外部模块使用此类作为父类的影响,并且设计好了你的类的代码了。

访问级别基本原则

Swift 中的访问级别遵循一个基本原则:实体不能定义在具有更低访问级别(更严格)的实体中

例如:

  • 一个 Public 的变量,其类型的访问级别不能是 Internal,File-private 或是 Private。因为无法保证变量的类型在使用变量的地方也具有访问权限。
  • 函数的访问级别不能高于它的参数类型和返回类型的访问级别。因为这样就会出现函数可以在任何地方被访问,但是它的参数类型和返回类型却不可以的情况。

关于此原则在各种情况下的具体表现,将在下文有所体现。

默认访问级别

如果你没有为代码中的实体显式指定访问级别,那么它们默认为 internal 级别(有一些例外情况,稍后会进行说明)。因此,在大多数情况下,我们不需要显式指定实体的访问级别。

单 target 应用程序的访问级别

当你编写一个单目标应用程序时,应用的所有功能都是为该应用服务,而不需要提供给其他应用或者模块使用,所以我们不需要明确设置访问级别,使用默认的访问级别 Internal 即可。但是,你也可以使用 fileprivate 访问或 private 访问级别,用于隐藏一些功能的实现细节。

框架的访问级别

当你开发框架时,就需要把一些对外的接口定义为 Open 或 Public,以便使用者导入该框架后可以正常使用其功能。这些被你定义为对外的接口,就是这个框架的 API。

注意

框架的内部实现仍然可以使用默认的访问级别 internal,当你需要对框架内部其它部分隐藏细节时可以使用 privatefileprivate。对于框架的对外 API 部分,你就需要将它们设置为 openpublic 了。

单元测试 target 的访问级别

当你的应用程序包含单元测试 target 时,为了测试,测试模块需要访问应用程序模块中的代码。默认情况下只有 openpublic 级别的实体才可以被其他模块访问。然而,如果在导入应用程序模块的语句前使用 @testable 特性,然后在允许测试的编译设置(Build Options -> Enable Testability)下编译这个应用程序模块,单元测试目标就可以访问应用程序模块中所有内部级别的实体。

访问控制语法

通过修饰符 openpublicinternalfileprivateprivate 来声明实体的访问级别:

1
2
3
4
5
6
7
8
9
public class SomePublicClass {}
internal class SomeInternalClass {}
fileprivate class SomeFilePrivateClass {}
private class SomePrivateClass {}

public var somePublicVariable = 0
internal let someInternalConstant = 0
fileprivate func someFilePrivateFunction() {}
private func somePrivateFunction() {}

除非专门指定,否则实体默认的访问级别为 internal,可以查阅 默认访问级别 这一节。这意味着在不使用修饰符显式声明访问级别的情况下,SomeInternalClasssomeInternalConstant 仍然拥有隐式的 internal

1
2
class SomeInternalClass {}   // 隐式 internal
var someInternalConstant = 0 // 隐式 internal

自定义类型

如果想为一个自定义类型指定访问级别,在定义类型时进行指定即可。新类型只能在它的访问级别限制范围内使用。例如,你定义了一个 fileprivate 级别的类,那这个类就只能在定义它的源文件中使用,可以作为属性类型、函数参数类型或者返回类型,等等。

一个类型的访问级别也会影响到类型成员(属性、方法、构造器、下标)的默认访问级别。如果你将类型指定为 private 或者 fileprivate 级别,那么该类型的所有成员的默认访问级别也会变成 private 或者 fileprivate 级别。如果你将类型指定为 internalpublic(或者不明确指定访问级别,而使用默认的 internal ),那么该类型的所有成员的默认访问级别将是 internal

重点

上面提到,一个 public 类型的所有成员的访问级别默认为 internal 级别,而不是 public 级别。如果你想将某个成员指定为 public 级别,那么你必须显式指定。这样做的好处是,在你定义公共接口的时候,可以明确地选择哪些接口是需要公开的,哪些是内部使用的,避免不小心将内部使用的接口公开。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class SomePublicClass {                  // 显式 public 类
public var somePublicProperty = 0 // 显式 public 类成员
var someInternalProperty = 0 // 隐式 internal 类成员
fileprivate func someFilePrivateMethod() {} // 显式 fileprivate 类成员
private func somePrivateMethod() {} // 显式 private 类成员
}

class SomeInternalClass { // 隐式 internal 类
var someInternalProperty = 0 // 隐式 internal 类成员
fileprivate func someFilePrivateMethod() {} // 显式 fileprivate 类成员
private func somePrivateMethod() {} // 显式 private 类成员
}

fileprivate class SomeFilePrivateClass { // 显式 fileprivate 类
func someFilePrivateMethod() {} // 隐式 fileprivate 类成员
private func somePrivateMethod() {} // 显式 private 类成员
}

private class SomePrivateClass { // 显式 private 类
func somePrivateMethod() {} // 隐式 private 类成员
}

元组类型

元组的访问级别将由元组中访问级别最严格的类型来决定。例如,如果你构建了一个包含两种不同类型的元组,其中一个类型为 internal,另一个类型为 private,那么这个元组的访问级别为 private

注意

元组不同于类、结构体、枚举、函数那样有单独的定义。元组的访问级别是在它被使用时自动推断出的,而无法明确指定。

函数类型

函数的访问级别根据访问级别最严格的参数类型或返回类型的访问级别来决定。但是,如果这种访问级别不符合函数定义所在环境的默认访问级别,那么就需要明确地指定该函数的访问级别。

下面的例子定义了一个名为 someFunction() 的全局函数,并且没有明确地指定其访问级别。也许你会认为该函数应该拥有默认的访问级别 internal,但事实并非如此。事实上,如果按下面这种写法,代码将无法通过编译:

1
2
3
func someFunction() -> (SomeInternalClass, SomePrivateClass) {
// 此处是函数实现部分
}

我们可以看到,这个函数的返回类型是一个元组,该元组中包含两个自定义的类(可查阅 自定义类型)。其中一个类的访问级别是 internal,另一个的访问级别是 private,所以根据元组访问级别的原则,该元组的访问级别是 private(元组的访问级别与元组中访问级别最低的类型一致)。

因为该函数返回类型的访问级别是 private,所以你必须使用 private 修饰符,明确指定该函数的访问级别:

1
2
3
private func someFunction() -> (SomeInternalClass, SomePrivateClass) {
// 此处是函数实现部分
}

将该函数指定为 publicinternal,或者使用默认的访问级别 internal 都是错误的,因为如果把该函数当做 publicinternal 级别来使用的话,可能会无法访问 private 级别的返回值。

枚举类型

枚举成员的访问级别和该枚举类型相同,你不能为枚举成员单独指定不同的访问级别。

比如下面的例子,枚举 CompassPoint 被明确指定为 public,那么它的成员 NorthSouthEastWest 的访问级别同样也是 public

1
2
3
4
5
6
public enum CompassPoint {
case north
case south
case east
case west
}

原始值和关联值

枚举定义中的任何原始值或关联值的类型的访问级别至少不能低于枚举类型的访问级别。例如,你不能在一个 internal 的枚举中定义 private 的原始值类型。

嵌套类型

如果在 private 的类型中定义嵌套类型,那么该嵌套类型就自动拥有 private 访问级别。如果在 public 或者 internal 级别的类型中定义嵌套类型,那么该嵌套类型自动拥有 internal 访问级别。如果想让嵌套类型拥有 public 访问级别,那么需要明确指定该嵌套类型的访问级别。

子类

子类的访问级别不得高于父类的访问级别。例如,父类的访问级别是 internal,子类的访问级别就不能是 public

此外,你可以在符合当前访问级别的条件下重写任意类成员(方法、属性、构造器、下标等)。

可以通过重写为继承来的类成员提供更高的访问级别。下面的例子中,类 A 的访问级别是 public,它包含一个方法 someMethod(),访问级别为 private。类 B 继承自类 A,访问级别为 internal,但是在类 B 中重写了类 A 中访问级别为 private 的方法 someMethod(),并重新指定为 internal 级别。通过这种方式,我们就可以将某类中 private 级别的类成员重新指定为更高的访问级别,以便其他人使用:

1
2
3
4
5
6
7
public class A {
fileprivate func someMethod() {}
}

internal class B: A {
override internal func someMethod() {}
}

我们甚至可以在子类中,用子类成员去访问访问级别更低的父类成员,只要这一操作在相应访问级别的限制范围内(也就是说,在同一源文件中访问父类 private 级别的成员,在同一模块内访问父类 internal 级别的成员):

1
2
3
4
5
6
7
8
9
public class A {
fileprivate func someMethod() {}
}

internal class B: A {
override internal func someMethod() {
super.someMethod()
}
}

因为父类 A 和子类 B 定义在同一个源文件中,所以在子类 B 可以在重写的 someMethod() 方法中调用 super.someMethod()

常量、变量、属性、下标

常量、变量、属性不能拥有比它们的类型更高的访问级别。例如,你不能定义一个 public 级别的属性,但是它的类型却是 private 级别的。同样,下标也不能拥有比索引类型或返回类型更高的访问级别。

如果常量、变量、属性、下标的类型是 private 级别的,那么它们必须明确指定访问级别为 private

1
private var privateInstance = SomePrivateClass()

Getter 和 Setter

常量、变量、属性、下标的 GettersSetters 的访问级别和它们所属类型的访问级别相同。

Setter 的访问级别可以低于对应的 Getter 的访问级别,这样就可以控制变量、属性或下标的读写权限。在 varsubscript 关键字之前,你可以通过 fileprivate(set)private(set)internal(set) 为它们的写入权限指定更低的访问级别。

注意

这个规则同时适用于存储型属性和计算型属性。即使你不明确指定存储型属性的 GetterSetter,Swift 也会隐式地为其创建 GetterSetter,用于访问该属性的后备存储。使用 fileprivate(set)private(set)internal(set) 可以改变 Setter 的访问级别,这对计算型属性也同样适用。

下面的例子中定义了一个名为 TrackedString 的结构体,它记录了 value 属性被修改的次数:

1
2
3
4
5
6
7
8
struct TrackedString {
private(set) var numberOfEdits = 0
var value: String = "" {
didSet {
numberOfEdits += 1
}
}
}

TrackedString 结构体定义了一个用于存储 String 值的属性 value,并将初始值设为 ""(一个空字符串)。该结构体还定义了另一个用于存储 Int 值的属性 numberOfEdits,它用于记录属性 value 被修改的次数。这个功能通过属性 valuedidSet 观察器实现,每当给 value 赋新值时就会调用 didSet 方法,然后将 numberOfEdits 的值加一。

结构体 TrackedString 和它的属性 value 都没有显式地指定访问级别,所以它们都是用默认的访问级别 internal。但是该结构体的 numberOfEdits 属性使用了 private(set) 修饰符,这意味着 numberOfEdits 属性只能在结构体的定义中进行赋值。numberOfEdits 属性的 Getter 依然是默认的访问级别 internal,但是 Setter 的访问级别是 private,这表示该属性只能在内部修改,而在结构体的外部则表现为一个只读属性。

如果你实例化 TrackedString 结构体,并多次对 value 属性的值进行修改,你就会看到 numberOfEdits 的值会随着修改次数而变化:

1
2
3
4
5
6
var stringToEdit = TrackedString()
stringToEdit.value = "This string will be tracked."
stringToEdit.value += " This edit will increment numberOfEdits."
stringToEdit.value += " So will this one."
print("The number of edits is \(stringToEdit.numberOfEdits)")
// 打印“The number of edits is 3”

虽然你可以在其他的源文件中实例化该结构体并且获取到 numberOfEdits 属性的值,但是你不能对其进行赋值。这一限制保护了该记录功能的实现细节,同时还提供了方便的访问方式。

你可以在必要时为 GetterSetter 显式指定访问级别。下面的例子将 TrackedString 结构体明确指定为了 public 访问级别。结构体的成员(包括 numberOfEdits 属性)拥有默认的访问级别 internal。你可以结合 publicprivate(set) 修饰符把结构体中的 numberOfEdits 属性的 Getter 的访问级别设置为 public,而 Setter 的访问级别设置为 private

1
2
3
4
5
6
7
8
9
public struct TrackedString {
public private(set) var numberOfEdits = 0
public var value: String = "" {
didSet {
numberOfEdits += 1
}
}
public init() {}
}

构造器

自定义构造器的访问级别可以低于或等于其所属类型的访问级别。唯一的例外是 必要构造器,它的访问级别必须和所属类型的访问级别相同。

如同函数或方法的参数,构造器参数的访问级别也不能低于构造器本身的访问级别。

默认构造器

默认构造器 所述,Swift 会为结构体和类提供一个默认的无参数的构造器,只要它们为所有存储型属性设置了默认初始值,并且未提供自定义的构造器。

默认构造器的访问级别与所属类型的访问级别相同,除非类型的访问级别是 public。如果一个类型被指定为 public 级别,那么默认构造器的访问级别将为 internal。如果你希望一个 public 级别的类型也能在其他模块中使用这种无参数的默认构造器,你只能自己提供一个 public 访问级别的无参数构造器。

结构体默认的成员逐一构造器

如果结构体中任意存储型属性的访问级别为 private,那么该结构体默认的成员逐一构造器的访问级别就是 private。否则,这种构造器的访问级别依然是 internal

如同前面提到的默认构造器,如果你希望一个 public 级别的结构体也能在其他模块中使用其默认的成员逐一构造器,你依然只能自己提供一个 public 访问级别的成员逐一构造器。

协议

如果想为一个协议类型明确地指定访问级别,在定义协议时指定即可。这将限制该协议只能在适当的访问级别范围内被遵循。

协议中的每一个要求都具有和该协议相同的访问级别。你不能将协议中的要求设置为其他访问级别。这样才能确保该协议的所有要求对于任意遵循者都将可用。

注意

如果你定义了一个 public 访问级别的协议,那么该协议的所有实现也会是 public 访问级别。这一点不同于其他类型,例如,当类型是 public 访问级别时,其成员的访问级别却只是 internal

协议继承

如果定义了一个继承自其他协议的新协议,那么新协议拥有的访问级别最高也只能和被继承协议的访问级别相同。例如,你不能将继承自 internal 协议的新协议定义为 public 协议。

协议遵循

一个类型可以遵循比它级别更低的协议。例如,你可以定义一个 public 级别类型,它能在别的模块中使用,但是如果它遵循一个 internal 协议,这个遵循的部分就只能在这个 internal 协议所在的模块中使用。

遵循协议时的上下文级别是类型和协议中级别最小的那个。如果一个类型是 public 级别,但它要遵循的协议是 internal 级别,那么这个类型对该协议的遵循上下文就是 internal 级别。

当你编写或扩展一个类型让它遵循一个协议时,你必须确保该类型对协议的每一个要求的实现,至少与遵循协议的上下文级别一致。例如,一个 public 类型遵循一个 internal 协议,这个类型对协议的所有实现至少都应是 internal 级别的。

注意

Swift 和 Objective-C 一样,协议遵循是全局的,也就是说,在同一程序中,一个类型不可能用两种不同的方式实现同一个协议。

Extension

Extension 可以在访问级别允许的情况下对类、结构体、枚举进行扩展。Extension 的成员具有和原始类型成员一致的访问级别。例如,你使用 extension 扩展了一个 public 或者 internal 类型,extension 中的成员就默认使用 internal 访问级别,和原始类型中的成员一致。如果你使用 extension 扩展了一个 private 类型,则 extension 的成员默认使用 private 访问级别。

或者,你可以明确指定 extension 的访问级别(例如,private extension),从而给该 extension 中的所有成员指定一个新的默认访问级别。这个新的默认访问级别仍然可以被单独指定的访问级别所覆盖。

如果你使用 extension 来遵循协议的话,就不能显式地声明 extension 的访问级别。extension 每个 protocol 要求的实现都默认使用 protocol 的访问级别。

Extension 的私有成员

扩展同一文件内的类,结构体或者枚举,extension 里的代码会表现得跟声明在原类型里的一模一样。也就是说你可以这样:

  • 在类型的声明里声明一个私有成员,在同一文件的 extension 里访问。
  • 在 extension 里声明一个私有成员,在同一文件的另一个 extension 里访问。
  • 在 extension 里声明一个私有成员,在同一文件的类型声明里访问。

这意味着你可以像组织的代码去使用 extension,而且不受私有成员的影响。例如,给定下面这样一个简单的协议:

1
2
3
protocol SomeProtocol {
func doSomething()
}

你可以使用 extension 来遵循协议,就像这样:

1
2
3
4
5
6
7
8
9
struct SomeStruct {
private var privateVariable = 12
}

extension SomeStruct: SomeProtocol {
func doSomething() {
print(privateVariable)
}
}

泛型

泛型类型或泛型函数的访问级别取决于泛型类型或泛型函数本身的访问级别,还需结合类型参数的类型约束的访问级别,根据这些访问级别中的最低访问级别来确定。

类型别名

你定义的任何类型别名都会被当作不同的类型,以便于进行访问控制。类型别名的访问级别不可高于其表示的类型的访问级别。例如,private 级别的类型别名可以作为 privatefile-privateinternalpublic 或者 open 类型的别名,但是 public 级别的类型别名只能作为 public 类型的别名,不能作为 internalfile-privateprivate 类型的别名。

注意

这条规则也适用于为满足协议遵循而将类型别名用于关联类型的情况。

留言與分享

swift内存安全

分類 编程语言, swift

内存安全

默认情况下,Swift 会阻止你代码里不安全的行为。例如,Swift 会保证变量在使用之前就完成初始化,在内存被回收之后就无法被访问,并且数组的索引会做越界检查。

Swift 也保证同时访问同一块内存时不会冲突,通过约束代码里对于存储地址的写操作,去获取那一块内存的访问独占权。因为 Swift 自动管理内存,所以大部分时候你完全不需要考虑内存访问的事情。然而,理解潜在的冲突也是很重要的,可以避免你写出访问冲突的代码。而如果你的代码确实存在冲突,那在编译时或者运行时就会得到错误。

理解内存访问冲突

内存的访问,会发生在你给变量赋值,或者传递参数给函数时。例如,下面的代码就包含了读和写的访问:

1
2
3
4
5
// 向 one 所在的内存区域发起一次写操作
var one = 1

// 向 one 所在的内存区域发起一次读操作
print("We're number \(one)!")

内存访问的冲突会发生在你的代码尝试同时访问同一个存储地址的时侯。同一个存储地址的多个访问同时发生会造成不可预计或不一致的行为。在 Swift 里,有很多修改值的行为都会持续好几行代码,在修改值的过程中进行访问是有可能发生的。

你可以思考一下预算表更新的过程,会看到同样的问题。更新预算表总共有两步:首先你把预算项的名字和费用加上,然后再更新总数来反映预算表的现况。在更新之前和之后,你都可以从预算表里读取任何信息并获得正确的答案,就像下面展示的那样。

而当你添加预算项进入表里的时候,它只是在一个临时的,错误的状态,因为总数还没有被更新。在添加数据的过程中读取总数就会读取到错误的信息。

这个例子也演示了你在修复内存访问冲突时会遇到的问题:有时修复的方式会有很多种,但哪一种是正确的就不总是那么明显了。在这个例子里,根据你是否需要更新后的总数,$5 和 $320 都可能是正确的值。在你修复访问冲突之前,你需要决定它的倾向。

注意

如果你写过并发和多线程的代码,内存访问冲突也许是同样的问题。然而,这里访问冲突的讨论是在单线程的情境下讨论的,并没有使用并发或者多线程。

如果你曾经在单线程代码里有访问冲突,Swift 可以保证你在编译或者运行时会得到错误。对于多线程的代码,可以使用 Thread Sanitizer 去帮助检测多线程的冲突。

内存访问性质

内存访问冲突时,要考虑内存访问上下文中的这三个性质:访问是读还是写,访问的时长,以及被访问的存储地址。特别是,冲突会发生在当你有两个访问符合下列的情况:

  • 至少有一个是写访问
  • 它们访问的是同一个存储地址
  • 它们的访问在时间线上部分重叠

读和写访问的区别很明显:一个写访问会改变存储地址,而读操作不会。存储地址是指向正在访问的东西(例如一个变量,常量或者属性)的位置的值 。内存访问的时长要么是瞬时的,要么是长期的。

如果一个访问不可能在其访问期间被其它代码访问,那么就是一个瞬时访问。正常来说,两个瞬时访问是不可能同时发生的。大多数内存访问都是瞬时的。例如,下面列举的所有读和写访问都是瞬时的:

1
2
3
4
5
6
7
8
func oneMore(than number: Int) -> Int {
return number + 1
}

var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// 打印“2”

然而,有几种被称为长期访问的内存访问方式,会在别的代码执行时持续进行。瞬时访问和长期访问的区别在于别的代码有没有可能在访问期间同时访问,也就是在时间线上的重叠。一个长期访问可以被别的长期访问或瞬时访问重叠。

重叠的访问主要出现在使用 in-out 参数的函数和方法或者结构体的 mutating 方法里。Swift 代码里典型的长期访问会在后面进行讨论。

In-Out 参数的访问冲突

一个函数会对它所有的 in-out 参数进行长期写访问。in-out 参数的写访问会在所有非 in-out 参数处理完之后开始,直到函数执行完毕为止。如果有多个 in-out 参数,则写访问开始的顺序与参数的顺序一致。

长期访问的存在会造成一个结果,你不能在访问以 in-out 形式传入后的原变量,即使作用域原则和访问权限允许——任何访问原变量的行为都会造成冲突。例如:

1
2
3
4
5
6
7
8
var stepSize = 1

func increment(_ number: inout Int) {
number += stepSize
}

increment(&stepSize)
// 错误:stepSize 访问冲突

在上面的代码里,stepSize 是一个全局变量,并且它可以在 increment(_:) 里正常访问。然而,对于 stepSize 的读访问与 number 的写访问重叠了。就像下面展示的那样,numberstepSize 都指向了同一个存储地址。同一块内存的读和写访问重叠了,就此产生了冲突。

解决这个冲突的一种方式,是显示拷贝一份 stepSize

1
2
3
4
5
6
7
// 显式拷贝
var copyOfStepSize = stepSize
increment(&copyOfStepSize)

// 更新原来的值
stepSize = copyOfStepSize
// stepSize 现在的值是 2

当你在调用 increment(_:) 之前做一份拷贝,显然 copyOfStepSize 就会根据当前的 stepSize 增加。读访问在写操作之前就已经结束了,所以不会有冲突。

长期写访问的存在还会造成另一种结果,往同一个函数的多个 in-out 参数里传入同一个变量也会产生冲突,例如:

1
2
3
4
5
6
7
8
9
10
func balance(_ x: inout Int, _ y: inout Int) {
let sum = x + y
x = sum / 2
y = sum - x
}
var playerOneScore = 42
var playerTwoScore = 30
balance(&playerOneScore, &playerTwoScore) // 正常
balance(&playerOneScore, &playerOneScore)
// 错误:playerOneScore 访问冲突

上面的 balance(_:_:) 函数会将传入的两个参数平均化。将 playerOneScoreplayerTwoScore 作为参数传入不会产生错误 —— 有两个访问重叠了,但它们访问的是不同的内存位置。相反,将 playerOneScore 作为参数同时传入就会产生冲突,因为它会发起两个写访问,同时访问同一个的存储地址。

注意

因为操作符也是函数,它们也会对 in-out 参数进行长期访问。例如,假设 balance(_:_:) 是一个名为 <^> 的操作符函数,那么 playerOneScore <^> playerOneScore 也会造成像 balance(&playerOneScore, &playerOneScore) 一样的冲突。

方法里 self 的访问冲突

一个结构体的 mutating 方法会在调用期间对 self 进行写访问。例如,想象一下这么一个游戏,每一个玩家都有血量,受攻击时血量会下降,并且有敌人的数量,使用特殊技能时会减少敌人数量。

1
2
3
4
5
6
7
8
9
10
struct Player {
var name: String
var health: Int
var energy: Int

static let maxHealth = 10
mutating func restoreHealth() {
health = Player.maxHealth
}
}

在上面的 restoreHealth() 方法里,一个对于 self 的写访问会从方法开始直到方法 return。在这种情况下,restoreHealth() 里的其它代码不可以对 Player 实例的属性发起重叠的访问。下面的 shareHealth(with:) 方法接受另一个 Player 的实例作为 in-out 参数,产生了访问重叠的可能性。

1
2
3
4
5
6
7
8
9
extension Player {
mutating func shareHealth(with teammate: inout Player) {
balance(&teammate.health, &health)
}
}

var oscar = Player(name: "Oscar", health: 10, energy: 10)
var maria = Player(name: "Maria", health: 5, energy: 10)
oscar.shareHealth(with: &maria) // 正常

上面的例子里,调用 shareHealth(with:) 方法去把 oscar 玩家的血量分享给 maria 玩家并不会造成冲突。在方法调用期间会对 oscar 发起写访问,因为在 mutating 方法里 self 就是 oscar,同时对于 maria 也会发起写访问,因为 maria 作为 in-out 参数传入。过程如下,它们会访问内存的不同位置。即使两个写访问重叠了,它们也不会冲突。

当然,如果你将 oscar 作为参数传入 shareHealth(with:) 里,就会产生冲突:

1
2
oscar.shareHealth(with: &oscar)
// 错误:oscar 访问冲突

mutating 方法在调用期间需要对 self 发起写访问,而同时 in-out 参数也需要写访问。在方法里,selfteammate 都指向了同一个存储地址——就像下面展示的那样。对于同一块内存同时进行两个写访问,并且它们重叠了,就此产生了冲突。

属性的访问冲突

如结构体,元组和枚举的类型都是由多个独立的值组成的,例如结构体的属性或元组的元素。因为它们都是值类型,修改值的任何一部分都是对于整个值的修改,意味着其中一个属性的读或写访问都需要访问整一个值。例如,元组元素的写访问重叠会产生冲突:

1
2
3
var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// 错误:playerInformation 的属性访问冲突

上面的例子里,传入同一元组的元素对 balance(_:_:) 进行调用,产生了冲突,因为 playerInformation 的访问产生了写访问重叠。playerInformation.healthplayerInformation.energy 都被作为 in-out 参数传入,意味着 balance(_:_:) 需要在函数调用期间对它们发起写访问。任何情况下,对于元组元素的写访问都需要对整个元组发起写访问。这意味着对于 playerInfomation 发起的两个写访问重叠了,造成冲突。

下面的代码展示了一样的错误,对于一个存储在全局变量里的结构体属性的写访问重叠了。

1
2
var holly = Player(name: "Holly", health: 10, energy: 10)
balance(&holly.health, &holly.energy) // 错误

在实践中,大多数对于结构体属性的访问都会安全的重叠。例如,将上面例子里的变量 holly 改为本地变量而非全局变量,编译器就会可以保证这个重叠访问是安全的:

1
2
3
4
func someFunction() {
var oscar = Player(name: "Oscar", health: 10, energy: 10)
balance(&oscar.health, &oscar.energy) // 正常
}

上面的例子里,oscarhealthenergy 都作为 in-out 参数传入了 balance(_:_:) 里。编译器可以保证内存安全,因为两个存储属性任何情况下都不会相互影响。

限制结构体属性的重叠访问对于保证内存安全不是必要的。保证内存安全是必要的,但因为访问独占权的要求比内存安全还要更严格——意味着即使有些代码违反了访问独占权的原则,也是内存安全的,所以如果编译器可以保证这种非专属的访问是安全的,那 Swift 就会允许这种行为的代码运行。特别是当你遵循下面的原则时,它可以保证结构体属性的重叠访问是安全的:

  • 你访问的是实例的存储属性,而不是计算属性或类的属性
  • 结构体是本地变量的值,而非全局变量
  • 结构体要么没有被闭包捕获,要么只被非逃逸闭包捕获了

如果编译器无法保证访问的安全性,它就不会允许那次访问。

留言與分享

swift自动引用计数

分類 编程语言, swift

自动引用计数

Swift 使用*自动引用计数(ARC)*机制来跟踪和管理你的应用程序的内存。通常情况下,Swift 内存管理机制会一直起作用,你无须自己来考虑内存的管理。ARC 会在类的实例不再被使用时,自动释放其占用的内存。

然而在少数情况下,为了能帮助你管理内存,ARC 需要更多的,代码之间关系的信息。本章描述了这些情况,并且为你示范怎样才能使 ARC 来管理你的应用程序的所有内存。在 Swift 使用 ARC 与在 Obejctive-C 中使用 ARC 非常类似,具体请参考 过渡到 ARC 的发布说明

注意

引用计数仅仅应用于类的实例。结构体和枚举类型是值类型,不是引用类型,也不是通过引用的方式存储和传递。

自动引用计数的工作机制

当你每次创建一个类的新的实例的时候,ARC 会分配一块内存来储存该实例信息。内存中会包含实例的类型信息,以及这个实例所有相关的存储型属性的值。

此外,当实例不再被使用时,ARC 释放实例所占用的内存,并让释放的内存能挪作他用。这确保了不再被使用的实例,不会一直占用内存空间。

然而,当 ARC 收回和释放了正在被使用中的实例,该实例的属性和方法将不能再被访问和调用。实际上,如果你试图访问这个实例,你的应用程序很可能会崩溃。

为了确保使用中的实例不会被销毁,ARC 会跟踪和计算每一个实例正在被多少属性,常量和变量所引用。哪怕实例的引用数为 1,ARC 都不会销毁这个实例。

为了使上述成为可能,无论你将实例赋值给属性、常量或变量,它们都会创建此实例的强引用。之所以称之为“强”引用,是因为它会将实例牢牢地保持住,只要强引用还在,实例是不允许被销毁的。

自动引用计数实践

下面的例子展示了自动引用计数的工作机制。例子以一个简单的 Person 类开始,并定义了一个叫 name 的常量属性:

1
2
3
4
5
6
7
8
9
10
class Person {
let name: String
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}

Person 类有一个构造器,此构造器为实例的 name 属性赋值,并打印一条消息以表明初始化过程生效。Person 类也拥有一个析构器,这个析构器会在实例被销毁时打印一条消息。

接下来的代码片段定义了三个类型为 Person? 的变量,用来按照代码片段中的顺序,为新的 Person 实例建立多个引用。由于这些变量是被定义为可选类型(Person?,而不是 Person),它们的值会被自动初始化为 nil,目前还不会引用到 Person 类的实例。

1
2
3
var reference1: Person?
var reference2: Person?
var reference3: Person?

现在你可以创建 Person 类的新实例,并且将它赋值给三个变量中的一个:

1
2
reference1 = Person(name: "John Appleseed")
// 打印“John Appleseed is being initialized”

应当注意到当你调用 Person 类的构造器的时候,"John Appleseed is being initialized" 会被打印出来。由此可以确定构造器被执行。

由于 Person 类的新实例被赋值给了 reference1 变量,所以 reference1Person 类的新实例之间建立了一个强引用。正是因为这一个强引用,ARC 会保证 Person 实例被保持在内存中不被销毁。

如果你将同一个 Person 实例也赋值给其他两个变量,该实例又会多出两个强引用:

1
2
reference2 = reference1
reference3 = reference1

现在这一个 Person 实例已经有三个强引用了。

如果你通过给其中两个变量赋值 nil 的方式断开两个强引用(包括最先的那个强引用),只留下一个强引用,Person 实例不会被销毁:

1
2
reference1 = nil
reference2 = nil

在你清楚地表明不再使用这个 Person 实例时,即第三个也就是最后一个强引用被断开时,ARC 会销毁它:

1
2
reference3 = nil
// 打印“John Appleseed is being deinitialized”

类实例之间的循环强引用

在上面的例子中,ARC 会跟踪你所新创建的 Person 实例的引用数量,并且会在 Person 实例不再被需要时销毁它。

然而,我们可能会写出一个类实例的强引用数永远不能变成 0 的代码。如果两个类实例互相持有对方的强引用,因而每个实例都让对方一直存在,就是这种情况。这就是所谓的循环强引用

你可以通过定义类之间的关系为弱引用或无主引用,以替代强引用,从而解决循环强引用的问题。具体的过程在 解决类实例之间的循环强引用 中有描述。不管怎样,在你学习怎样解决循环强引用之前,很有必要了解一下它是怎样产生的。

下面展示了一个不经意产生循环强引用的例子。例子定义了两个类:PersonApartment,用来建模公寓和它其中的居民:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
let name: String
init(name: String) { self.name = name }
var apartment: Apartment?
deinit { print("\(name) is being deinitialized") }
}

class Apartment {
let unit: String
init(unit: String) { self.unit = unit }
var tenant: Person?
deinit { print("Apartment \(unit) is being deinitialized") }
}

每一个 Person 实例有一个类型为 String,名字为 name 的属性,并有一个可选的初始化为 nilapartment 属性。apartment 属性是可选的,因为一个人并不总是拥有公寓。

类似的,每个 Apartment 实例有一个叫 unit,类型为 String 的属性,并有一个可选的初始化为 niltenant 属性。tenant 属性是可选的,因为一栋公寓并不总是有居民。

这两个类都定义了析构器,用以在类实例被析构的时候输出信息。这让你能够知晓 PersonApartment 的实例是否像预期的那样被销毁。

接下来的代码片段定义了两个可选类型的变量 johnunit4A,并分别被设定为下面的 ApartmentPerson 的实例。这两个变量都被初始化为 nil,这正是可选类型的优点:

1
2
var john: Person?
var unit4A: Apartment?

现在你可以创建特定的 PersonApartment 实例并将赋值给 johnunit4A 变量:

1
2
john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")

在两个实例被创建和赋值后,下图表现了强引用的关系。变量 john 现在有一个指向 Person 实例的强引用,而变量 unit4A 有一个指向 Apartment 实例的强引用:

现在你能够将这两个实例关联在一起,这样人就能有公寓住了,而公寓也有了房客。注意感叹号是用来展开和访问可选变量 johnunit4A 中的实例,这样实例的属性才能被赋值:

1
2
john!.apartment = unit4A
unit4A!.tenant = john

在将两个实例联系在一起之后,强引用的关系如图所示:

不幸的是,这两个实例关联后会产生一个循环强引用。Person 实例现在有了一个指向 Apartment 实例的强引用,而 Apartment 实例也有了一个指向 Person 实例的强引用。因此,当你断开 johnunit4A 变量所持有的强引用时,引用计数并不会降为 0,实例也不会被 ARC 销毁:

1
2
john = nil
unit4A = nil

注意,当你把这两个变量设为 nil 时,没有任何一个析构器被调用。循环强引用会一直阻止 PersonApartment 类实例的销毁,这就在你的应用程序中造成了内存泄漏。

在你将 johnunit4A 赋值为 nil 后,强引用关系如下图:

PersonApartment 实例之间的强引用关系保留了下来并且不会被断开。

解决实例之间的循环强引用

Swift 提供了两种办法用来解决你在使用类的属性时所遇到的循环强引用问题:弱引用(weak reference)和无主引用(unowned reference)。

弱引用和无主引用允许循环引用中的一个实例引用另一个实例而保持强引用。这样实例能够互相引用而不产生循环强引用。

当其他的实例有更短的生命周期时,使用弱引用,也就是说,当其他实例析构在先时。在上面公寓的例子中,很显然一个公寓在它的生命周期内会在某个时间段没有它的主人,所以一个弱引用就加在公寓类里面,避免循环引用。相比之下,当其他实例有相同的或者更长生命周期时,请使用无主引用。

弱引用

弱引用不会对其引用的实例保持强引用,因而不会阻止 ARC 销毁被引用的实例。这个特性阻止了引用变为循环强引用。声明属性或者变量时,在前面加上 weak 关键字表明这是一个弱引用。

因为弱引用不会保持所引用的实例,即使引用存在,实例也有可能被销毁。因此,ARC 会在引用的实例被销毁后自动将其弱引用赋值为 nil。并且因为弱引用需要在运行时允许被赋值为 nil,所以它们会被定义为可选类型变量,而不是常量。

你可以像其他可选值一样,检查弱引用的值是否存在,你将永远不会访问已销毁的实例的引用。

注意

当 ARC 设置弱引用为 nil 时,属性观察不会被触发。

下面的例子跟上面 PersonApartment 的例子一致,但是有一个重要的区别。这一次,Apartmenttenant 属性被声明为弱引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
let name: String
init(name: String) { self.name = name }
var apartment: Apartment?
deinit { print("\(name) is being deinitialized") }
}

class Apartment {
let unit: String
init(unit: String) { self.unit = unit }
weak var tenant: Person?
deinit { print("Apartment \(unit) is being deinitialized") }
}

然后跟之前一样,建立两个变量(johnunit4A)之间的强引用,并关联两个实例:

1
2
3
4
5
6
7
8
var john: Person?
var unit4A: Apartment?

john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")

john!.apartment = unit4A
unit4A!.tenant = john

现在,两个关联在一起的实例的引用关系如下图所示:

Person 实例依然保持对 Apartment 实例的强引用,但是 Apartment 实例只持有对 Person 实例的弱引用。这意味着当你通过把 john 变量赋值为 nil 而断开其所保持的强引用时,再也没有指向 Person 实例的强引用了:

1
2
john = nil
// 打印“John Appleseed is being deinitialized”

由于再也没有指向 Person 实例的强引用,该实例会被销毁,且 tenant 属性会被赋值为 nil

唯一剩下的指向 Apartment 实例的强引用来自于变量 unit4A。如果你断开这个强引用,再也没有指向 Apartment 实例的强引用了:

1
2
unit4A = nil
// 打印“Apartment 4A is being deinitialized”

由于再也没有指向 Person 实例的强引用,该实例会被销毁:

注意

在使用垃圾收集的系统里,弱指针有时用来实现简单的缓冲机制,因为没有强引用的对象只会在内存压力触发垃圾收集时才被销毁。但是在 ARC 中,一旦值的最后一个强引用被移除,就会被立即销毁,这导致弱引用并不适合上面的用途。

无主引用

和弱引用类似,无主引用不会牢牢保持住引用的实例。和弱引用不同的是,无主引用在其他实例有相同或者更长的生命周期时使用。你可以在声明属性或者变量时,在前面加上关键字 unowned 表示这是一个无主引用。

无主引用通常都被期望拥有值。不过 ARC 无法在实例被销毁后将无主引用设为 nil,因为非可选类型的变量不允许被赋值为 nil

重点

使用无主引用,你必须确保引用始终指向一个未销毁的实例。

如果你试图在实例被销毁后,访问该实例的无主引用,会触发运行时错误。

下面的例子定义了两个类,CustomerCreditCard,模拟了银行客户和客户的信用卡。这两个类中,每一个都将另外一个类的实例作为自身的属性。这种关系可能会造成循环强引用。

CustomerCreditCard 之间的关系与前面弱引用例子中 ApartmentPerson 的关系略微不同。在这个数据模型中,一个客户可能有或者没有信用卡,但是一张信用卡总是关联着一个客户。为了表示这种关系,Customer 类有一个可选类型的 card 属性,但是 CreditCard 类有一个非可选类型的 customer 属性。

此外,只能通过将一个 number 值和 customer 实例传递给 CreditCard 构造器的方式来创建 CreditCard 实例。这样可以确保当创建 CreditCard 实例时总是有一个 customer 实例与之关联。

由于信用卡总是关联着一个客户,因此将 customer 属性定义为无主引用,用以避免循环强引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Customer {
let name: String
var card: CreditCard?
init(name: String) {
self.name = name
}
deinit { print("\(name) is being deinitialized") }
}

class CreditCard {
let number: UInt64
unowned let customer: Customer
init(number: UInt64, customer: Customer) {
self.number = number
self.customer = customer
}
deinit { print("Card #\(number) is being deinitialized") }
}

注意

CreditCard 类的 number 属性被定义为 UInt64 类型而不是 Int 类型,以确保 number 属性的存储量在 32 位和 64 位系统上都能足够容纳 16 位的卡号。

下面的代码片段定义了一个叫 john 的可选类型 Customer 变量,用来保存某个特定客户的引用。由于是可选类型,所以变量被初始化为 nil

1
var john: Customer?

现在你可以创建 Customer 类的实例,用它初始化 CreditCard 实例,并将新创建的 CreditCard 实例赋值为客户的 card 属性:

1
2
john = Customer(name: "John Appleseed")
john!.card = CreditCard(number: 1234_5678_9012_3456, customer: john!)

在你关联两个实例后,它们的引用关系如下图所示:

Customer 实例持有对 CreditCard 实例的强引用,而 CreditCard 实例持有对 Customer 实例的无主引用。

由于 customer 的无主引用,当你断开 john 变量持有的强引用时,再也没有指向 Customer 实例的强引用了:

由于再也没有指向 Customer 实例的强引用,该实例被销毁了。其后,再也没有指向 CreditCard 实例的强引用,该实例也随之被销毁了:

1
2
3
john = nil
// 打印“John Appleseed is being deinitialized”
// 打印“Card #1234567890123456 is being deinitialized”

最后的代码展示了在 john 变量被设为 nilCustomer 实例和 CreditCard 实例的析构器都打印出了“销毁”的信息。

注意

上面的例子展示了如何使用安全的无主引用。对于需要禁用运行时的安全检查的情况(例如,出于性能方面的原因),Swift 还提供了不安全的无主引用。与所有不安全的操作一样,你需要负责检查代码以确保其安全性。 你可以通过 unowned(unsafe) 来声明不安全无主引用。如果你试图在实例被销毁后,访问该实例的不安全无主引用,你的程序会尝试访问该实例之前所在的内存地址,这是一个不安全的操作。

无主引用和隐式解包可选值属性

上面弱引用和无主引用的例子涵盖了两种常用的需要打破循环强引用的场景。

PersonApartment 的例子展示了两个属性的值都允许为 nil,并会潜在的产生循环强引用。这种场景最适合用弱引用来解决。

CustomerCreditCard 的例子展示了一个属性的值允许为 nil,而另一个属性的值不允许为 nil,这也可能会产生循环强引用。这种场景最适合通过无主引用来解决。

然而,存在着第三种场景,在这种场景中,两个属性都必须有值,并且初始化完成后永远不会为 nil。在这种场景中,需要一个类使用无主属性,而另外一个类使用隐式解包可选值属性。

这使两个属性在初始化完成后能被直接访问(不需要可选展开),同时避免了循环引用。这一节将为你展示如何建立这种关系。

下面的例子定义了两个类,CountryCity,每个类将另外一个类的实例保存为属性。在这个模型中,每个国家必须有首都,每个城市必须属于一个国家。为了实现这种关系,Country 类拥有一个 capitalCity 属性,而 City 类有一个 country 属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Country {
let name: String
var capitalCity: City!
init(name: String, capitalName: String) {
self.name = name
self.capitalCity = City(name: capitalName, country: self)
}
}

class City {
let name: String
unowned let country: Country
init(name: String, country: Country) {
self.name = name
self.country = country
}
}

为了建立两个类的依赖关系,City 的构造器接受一个 Country 实例作为参数,并且将实例保存到 country 属性。

Country 的构造器调用了 City 的构造器。然而,只有 Country 的实例完全初始化后,Country 的构造器才能把 self 传给 City 的构造器。在 两段式构造过程 中有具体描述。

为了满足这种需求,通过在类型结尾处加上感叹号(City!)的方式,将 CountrycapitalCity 属性声明为隐式解包可选值类型的属性。这意味着像其他可选类型一样,capitalCity 属性的默认值为 nil,但是不需要展开它的值就能访问它。在 隐式解包可选值 中有描述。

由于 capitalCity 默认值为 nil,一旦 Country 的实例在构造器中给 name 属性赋值后,整个初始化过程就完成了。这意味着一旦 name 属性被赋值后,Country 的构造器就能引用并传递隐式的 selfCountry 的构造器在赋值 capitalCity 时,就能将 self 作为参数传递给 City 的构造器。

以上的意义在于你可以通过一条语句同时创建 CountryCity 的实例,而不产生循环强引用,并且 capitalCity 的属性能被直接访问,而不需要通过感叹号来展开它的可选值:

1
2
3
var country = Country(name: "Canada", capitalName: "Ottawa")
print("\(country.name)'s capital city is called \(country.capitalCity.name)")
// 打印“Canada's capital city is called Ottawa”

在上面的例子中,使用隐式解包可选值值意味着满足了类的构造器的两个构造阶段的要求。capitalCity 属性在初始化完成后,能像非可选值一样使用和存取,同时还避免了循环强引用。

闭包的循环强引用

前面我们看到了循环强引用是在两个类实例属性互相保持对方的强引用时产生的,还知道了如何用弱引用和无主引用来打破这些循环强引用。

循环强引用还会发生在当你将一个闭包赋值给类实例的某个属性,并且这个闭包体中又使用了这个类实例时。这个闭包体中可能访问了实例的某个属性,例如 self.someProperty,或者闭包中调用了实例的某个方法,例如 self.someMethod()。这两种情况都导致了闭包“捕获”self,从而产生了循环强引用。

循环强引用的产生,是因为闭包和类相似,都是引用类型。当你把一个闭包赋值给某个属性时,你是将这个闭包的引用赋值给了属性。实质上,这跟之前的问题是一样的——两个强引用让彼此一直有效。但是,和两个类实例不同,这次一个是类实例,另一个是闭包。

Swift 提供了一种优雅的方法来解决这个问题,称之为 闭包捕获列表(closure capture list)。同样的,在学习如何用闭包捕获列表打破循环强引用之前,先来了解一下这里的循环强引用是如何产生的,这对我们很有帮助。

下面的例子为你展示了当一个闭包引用了 self 后是如何产生一个循环强引用的。例子中定义了一个叫 HTMLElement 的类,用一种简单的模型表示 HTML 文档中的一个单独的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class HTMLElement {

let name: String
let text: String?

lazy var asHTML: () -> String = {
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}

init(name: String, text: String? = nil) {
self.name = name
self.text = text
}

deinit {
print("\(name) is being deinitialized")
}

}

HTMLElement 类定义了一个 name 属性来表示这个元素的名称,例如代表头部元素的 "h1",代表段落的 "p",或者代表换行的 "br"HTMLElement 还定义了一个可选属性 text,用来设置 HTML 元素呈现的文本。

除了上面的两个属性,HTMLElement 还定义了一个 lazy 属性 asHTML。这个属性引用了一个将 nametext 组合成 HTML 字符串片段的闭包。该属性是 Void -> String 类型,或者可以理解为“一个没有参数,返回 String 的函数”。

默认情况下,闭包赋值给了 asHTML 属性,这个闭包返回一个代表 HTML 标签的字符串。如果 text 值存在,该标签就包含可选值 text;如果 text 不存在,该标签就不包含文本。对于段落元素,根据 text"some text" 还是 nil,闭包会返回 "<p>some text</p>" 或者 "<p />"

可以像实例方法那样去命名、使用 asHTML 属性。然而,由于 asHTML 是闭包而不是实例方法,如果你想改变特定 HTML 元素的处理方式的话,可以用自定义的闭包来取代默认值。

例如,可以将一个闭包赋值给 asHTML 属性,这个闭包能在 text 属性是 nil 时使用默认文本,这是为了避免返回一个空的 HTML 标签:

1
2
3
4
5
6
7
let heading = HTMLElement(name: "h1")
let defaultText = "some default text"
heading.asHTML = {
return "<\(heading.name)>\(heading.text ?? defaultText)</\(heading.name)>"
}
print(heading.asHTML())
// 打印“<h1>some default text</h1>”

注意

asHTML 声明为 lazy 属性,因为只有当元素确实需要被处理为 HTML 输出的字符串时,才需要使用 asHTML。也就是说,在默认的闭包中可以使用 self,因为只有当初始化完成以及 self 确实存在后,才能访问 lazy 属性。

HTMLElement 类只提供了一个构造器,通过 nametext(如果有的话)参数来初始化一个新元素。该类也定义了一个析构器,当 HTMLElement 实例被销毁时,打印一条消息。

下面的代码展示了如何用 HTMLElement 类创建实例并打印消息:

1
2
3
var paragraph: HTMLElement? = HTMLElement(name: "p", text: "hello, world")
print(paragraph!.asHTML())
// 打印“<p>hello, world</p>”

注意

上面的 paragraph 变量定义为可选类型的 HTMLElement,因此我们可以赋值 nil 给它来演示循环强引用。

不幸的是,上面写的 HTMLElement 类产生了类实例和作为 asHTML 默认值的闭包之间的循环强引用。循环强引用如下图所示:

实例的 asHTML 属性持有闭包的强引用。但是,闭包在其闭包体内使用了 self(引用了 self.nameself.text),因此闭包捕获了 self,这意味着闭包又反过来持有了 HTMLElement 实例的强引用。这样两个对象就产生了循环强引用。(更多关于闭包捕获值的信息,请参考 值捕获)。

注意

虽然闭包多次使用了 self,它只捕获 HTMLElement 实例的一个强引用。

如果设置 paragraph 变量为 nil,打破它持有的 HTMLElement 实例的强引用,HTMLElement 实例和它的闭包都不会被销毁,也是因为循环强引用:

1
paragraph = nil

注意,HTMLElement 的析构器中的消息并没有被打印,证明了 HTMLElement 实例并没有被销毁。

解决闭包的循环强引用

在定义闭包时同时定义捕获列表作为闭包的一部分,通过这种方式可以解决闭包和类实例之间的循环强引用。捕获列表定义了闭包体内捕获一个或者多个引用类型的规则。跟解决两个类实例间的循环强引用一样,声明每个捕获的引用为弱引用或无主引用,而不是强引用。应当根据代码关系来决定使用弱引用还是无主引用。

注意

Swift 有如下要求:只要在闭包内使用 self 的成员,就要用 self.someProperty 或者 self.someMethod()(而不只是 somePropertysomeMethod())。这提醒你可能会一不小心就捕获了 self

定义捕获列表

捕获列表中的每一项都由一对元素组成,一个元素是 weakunowned 关键字,另一个元素是类实例的引用(例如 self)或初始化过的变量(如 delegate = self.delegate!)。这些项在方括号中用逗号分开。

如果闭包有参数列表和返回类型,把捕获列表放在它们前面:

1
2
3
4
lazy var someClosure: (Int, String) -> String = {
[unowned self, weak delegate = self.delegate!] (index: Int, stringToProcess: String) -> String in
// 这里是闭包的函数体
}

如果闭包没有指明参数列表或者返回类型,它们会通过上下文推断,那么可以把捕获列表和关键字 in 放在闭包最开始的地方:

1
2
3
4
lazy var someClosure: () -> String = {
[unowned self, weak delegate = self.delegate!] in
// 这里是闭包的函数体
}

弱引用和无主引用

在闭包和捕获的实例总是互相引用并且总是同时销毁时,将闭包内的捕获定义为 无主引用

相反的,在被捕获的引用可能会变为 nil 时,将闭包内的捕获定义为 弱引用。弱引用总是可选类型,并且当引用的实例被销毁后,弱引用的值会自动置为 nil。这使我们可以在闭包体内检查它们是否存在。

注意

如果被捕获的引用绝对不会变为 nil,应该用无主引用,而不是弱引用。

前面的 HTMLElement 例子中,无主引用是正确的解决循环强引用的方法。这样编写 HTMLElement 类来避免循环强引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class HTMLElement {

let name: String
let text: String?

lazy var asHTML: () -> String = {
[unowned self] in
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}

init(name: String, text: String? = nil) {
self.name = name
self.text = text
}

deinit {
print("\(name) is being deinitialized")
}

}

上面的 HTMLElement 实现和之前的实现一致,除了在 asHTML 闭包中多了一个捕获列表。这里,捕获列表是 [unowned self],表示“将 self 捕获为无主引用而不是强引用”。

和之前一样,我们可以创建并打印 HTMLElement 实例:

1
2
3
var paragraph: HTMLElement? = HTMLElement(name: "p", text: "hello, world")
print(paragraph!.asHTML())
// 打印“<p>hello, world</p>”

使用捕获列表后引用关系如下图所示:

这一次,闭包以无主引用的形式捕获 self,并不会持有 HTMLElement 实例的强引用。如果将 paragraph 赋值为 nilHTMLElement 实例将会被销毁,并能看到它的析构器打印出的消息:

1
2
paragraph = nil
// 打印“p is being deinitialized”

你可以查看 捕获列表 章节,获取更多关于捕获列表的信息。

留言與分享

swift泛型

分類 编程语言, swift

泛型

泛型代码让你能根据自定义的需求,编写出适用于任意类型的、灵活可复用的函数及类型。你可避免编写重复的代码,而是用一种清晰抽象的方式来表达代码的意图。

泛型是 Swift 最强大的特性之一,很多 Swift 标准库是基于泛型代码构建的。实际上,即使你没有意识到,你也一直在语言指南中使用泛型。例如,Swift 的 ArrayDictionary 都是泛型集合。你可以创建一个 Int 类型数组,也可创建一个 String 类型数组,甚至可以是任意其他 Swift 类型的数组。同样,你也可以创建一个存储任意指定类型的字典,并对该类型没有限制。

泛型解决的问题

下面是一个标准的非泛型函数 swapTwoInts(_:_:),用来交换两个 Int 值:

1
2
3
4
5
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
let temporaryA = a
a = b
b = temporaryA
}

这个函数使用输入输出参数(inout)来交换 ab 的值,具体请参考 输入输出参数

swapTwoInts(_:_:) 函数将 b 的原始值换成了 a,将 a 的原始值换成了 b,你可以调用这个函数来交换两个 Int 类型变量:

1
2
3
4
5
var someInt = 3
var anotherInt = 107
swapTwoInts(&someInt, &anotherInt)
print("someInt is now \(someInt), and anotherInt is now \(anotherInt)")
// 打印“someInt is now 107, and anotherInt is now 3”

swapTwoInts(_:_:) 函数很实用,但它只能作用于 Int 类型。如果你想交换两个 String 类型值,或者 Double 类型值,你必须编写对应的函数,类似下面 swapTwoStrings(_:_:)swapTwoDoubles(_:_:) 函数:

1
2
3
4
5
6
7
8
9
10
11
func swapTwoStrings(_ a: inout String, _ b: inout String) {
let temporaryA = a
a = b
b = temporaryA
}

func swapTwoDoubles(_ a: inout Double, _ b: inout Double) {
let temporaryA = a
a = b
b = temporaryA
}

你可能注意到了,swapTwoInts(_:_:‘)swapTwoStrings(_:_:)swapTwoDoubles(_:_:) 函数体是一样的,唯一的区别是它们接受的参数类型(IntStringDouble)。

在实际应用中,通常需要一个更实用更灵活的函数来交换两个任意类型的值,幸运的是,泛型代码帮你解决了这种问题。(这些函数的泛型版本已经在下面定义好了。)

注意

在上面三个函数中,ab 类型必须相同。如果 ab 类型不同,那它们俩就不能互换值。Swift 是类型安全的语言,所以它不允许一个 String 类型的变量和一个 Double 类型的变量互换值。试图这样做将导致编译错误。

泛型函数

泛型函数可适用于任意类型,下面是函数 swapTwoInts(_:_:) 的泛型版本,命名为 swapTwoValues(_:_:)

1
2
3
4
5
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
let temporaryA = a
a = b
b = temporaryA
}

swapTwoValues(_:_:)swapTwoInts(_:_:) 函数体内容相同,它们只在第一行不同,如下所示:

1
2
func swapTwoInts(_ a: inout Int, _ b: inout Int)
func swapTwoValues<T>(_ a: inout T, _ b: inout T)

泛型版本的函数使用占位符类型名(这里叫做 T ),而不是 实际类型名(例如 IntStringDouble),占位符类型名并不关心 T 具体的类型,但它要求 ab 必须是相同的类型,T 的实际类型由每次调用 swapTwoValues(_:_:) 来决定。

泛型函数和非泛型函数的另外一个不同之处在于这个泛型函数名(swapTwoValues(_:_:))后面跟着占位类型名(T),并用尖括号括起来(<T>)。这个尖括号告诉 Swift 那个 TswapTwoValues(_:_:) 函数定义内的一个占位类型名,因此 Swift 不会去查找名为 T的实际类型。

swapTwoValues(_:_:) 函数现在可以像 swapTwoInts(_:_:) 那样调用,不同的是它能接受两个任意类型的值,条件是这两个值有着相同的类型。swapTwoValues(_:_:) 函数被调用时,T 所代表的类型都会由传入的值的类型推断出来。

在下面的两个例子中,T 分别代表 IntString

1
2
3
4
5
6
7
8
9
var someInt = 3
var anotherInt = 107
swapTwoValues(&someInt, &anotherInt)
// someInt 现在是 107,anotherInt 现在是 3

var someString = "hello"
var anotherString = "world"
swapTwoValues(&someString, &anotherString)
// someString 现在是“world”,anotherString 现在是“hello”

注意

上面定义的 swapTwoValues(_:_:) 函数是受 swap(_:_:) 函数启发而实现的。后者存在于 Swift 标准库,你可以在你的应用程序中使用它。如果你在代码中需要类似 swapTwoValues(_:_:) 函数的功能,你可以使用已存在的 swap(_:_:) 函数。

类型参数

上面 swapTwoValues(_:_:) 例子中,占位类型 T 是一个类型参数的例子,类型参数指定并命名一个占位类型,并且紧随在函数名后面,使用一对尖括号括起来(例如 <T>)。

一旦一个类型参数被指定,你可以用它来定义一个函数的参数类型(例如 swapTwoValues(_:_:) 函数中的参数 ab),或者作为函数的返回类型,还可以用作函数主体中的注释类型。在这些情况下,类型参数会在函数调用时被实际类型所替换。(在上面的 swapTwoValues(_:_:) 例子中,当函数第一次被调用时,TInt 替换,第二次调用时,被 String 替换。)

你可提供多个类型参数,将它们都写在尖括号中,用逗号分开。

命名类型参数

大多情况下,类型参数具有描述下的名称,例如字典 Dictionary<Key, Value> 中的 KeyValue 及数组 Array<Element> 中的 Element,这能告诉阅读代码的人这些参数类型与泛型类型或函数之间的关系。然而,当它们之间没有有意义的关系时,通常使用单个字符来表示,例如 TUV,例如上面演示函数 swapTwoValues(_:_:) 中的 T

注意

请始终使用大写字母开头的驼峰命名法(例如 TMyTypeParameter)来为类型参数命名,以表明它们是占位类型,而不是一个值。

泛型类型

除了泛型函数,Swift 还允许自定义泛型类型。这些自定义类、结构体和枚举可以适用于任意类型,类似于 ArrayDictionary

本节将向你展示如何编写一个名为 Stack(栈)的泛型集合类型。栈是值的有序集合,和数组类似,但比数组有更严格的操作限制。数组允许在其中任意位置插入或是删除元素。而栈只允许在集合的末端添加新的元素(称之为入栈)。类似的,栈也只能从末端移除元素(称之为出栈)。

注意

栈的概念已被 UINavigationController 类用来构造视图控制器的导航结构。你通过调用 UINavigationControllerpushViewController(_:animated:) 方法来添加新的视图控制器到导航栈,通过 popViewControllerAnimated(_:) 方法来从导航栈中移除视图控制器。每当你需要一个严格的“后进先出”方式来管理集合,栈都是最实用的模型。

下图展示了入栈(push)和出栈(pop)的行为:

  1. 现在有三个值在栈中。
  2. 第四个值被压入到栈的顶部。
  3. 现在栈中有四个值,最近入栈的那个值在顶部。
  4. 栈中最顶部的那个值被移除出栈。
  5. 一个值移除出栈后,现在栈又只有三个值了。

下面展示如何编写一个非泛型版本的栈,以 Int 型的栈为例:

1
2
3
4
5
6
7
8
9
struct IntStack {
var items = [Int]()
mutating func push(_ item: Int) {
items.append(item)
}
mutating func pop() -> Int {
return items.removeLast()
}
}

这个结构体在栈中使用一个名为 items 的数组属性来存储值。栈提供了两个方法:push(_:)pop(),用来向栈中压入值以及从栈中移除值。这些方法被标记为 mutating,因为它们需要修改结构体的 items 数组。

上面的 IntStack 结构体只能用于 Int 类型。不过,可以定义一个泛型 Stack 结构体,从而能够处理任意类型的值。

下面是相同代码的泛型版本:

1
2
3
4
5
6
7
8
9
struct Stack<Element> {
var items = [Element]()
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
}

注意,Stack 基本上和 IntStack 相同,只是用占位类型参数 Element 代替了实际的 Int 类型。这个类型参数包裹在紧随结构体名的一对尖括号里(<Element>)。

Element 为待提供的类型定义了一个占位名。这种待提供的类型可以在结构体的定义中通过 Element 来引用。在这个例子中,Element 在如下三个地方被用作占位符:

  • 创建 items 属性,使用 Element 类型的空数组对其进行初始化。
  • 指定 push(_:) 方法的唯一参数 item 的类型必须是 Element 类型。
  • 指定 pop() 方法的返回值类型必须是 Element 类型。

由于 Stack 是泛型类型,因此可以用来创建适用于 Swift 中任意有效类型的栈,就像 ArrayDictionary 那样。

你可以通过在尖括号中写出栈中需要存储的数据类型来创建并初始化一个 Stack 实例。例如,要创建一个 String 类型的栈,可以写成 Stack<String>()

1
2
3
4
5
6
var stackOfStrings = Stack<String>()
stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")
stackOfStrings.push("cuatro")
// 栈中现在有 4 个字符串

下图展示了 stackOfStrings 如何将这四个值压栈:

移除并返回栈顶部的值“cuatro”,即出栈:

1
2
let fromTheTop = stackOfStrings.pop()
// fromTheTop 的值为“cuatro”,现在栈中还有 3 个字符串

下图展示了如何将顶部的值出栈:

泛型扩展

当对泛型类型进行扩展时,你并不需要提供类型参数列表作为定义的一部分。原始类型定义中声明的类型参数列表在扩展中可以直接使用,并且这些来自原始类型中的参数名称会被用作原始定义中类型参数的引用。

下面的例子扩展了泛型类型 Stack,为其添加了一个名为 topItem 的只读计算型属性,它将会返回当前栈顶元素且不会将其从栈中移除:

1
2
3
4
5
extension Stack {
var topItem: Element? {
return items.isEmpty ? nil : items[items.count - 1]
}
}

topItem 属性会返回 Element 类型的可选值。当栈为空的时候,topItem 会返回 nil;当栈不为空的时候,topItem 会返回 items 数组中的最后一个元素。

注意:这个扩展并没有定义类型参数列表。相反的,Stack 类型已有的类型参数名称 Element,被用在扩展中来表示计算型属性 topItem 的可选类型。

计算型属性 topItem 现在可以用来访问任意 Stack 实例的顶端元素且不移除它:

1
2
3
4
if let topItem = stackOfStrings.topItem {
print("The top item on the stack is \(topItem).")
}
// 打印“The top item on the stack is tres.”

泛型类型的扩展,还可以包括类型扩展需要额外满足的条件,从而对类型添加新功能,这一部分将在具有泛型 Where 子句的扩展中进行讨论。

类型约束

swapTwoValues(_:_:) 函数和 Stack 适用于任意类型。不过,如果能对泛型函数或泛型类型中添加特定的类型约束,这将在某些情况下非常有用。类型约束指定类型参数必须继承自指定类、遵循特定的协议或协议组合。

例如,Swift 的 Dictionary 类型对字典的键的类型做了些限制。在 字典的描述 中,字典键的类型必须是可哈希(hashable)的。也就是说,必须有一种方法能够唯一地表示它。字典键之所以要是可哈希的,是为了便于检查字典中是否已经包含某个特定键的值。若没有这个要求,字典将无法判断是否可以插入或替换某个指定键的值,也不能查找到已经存储在字典中的指定键的值。

这个要求通过 Dictionary 键类型上的类型约束实现,它指明了键必须遵循 Swift 标准库中定义的 Hashable 协议。所有 Swift 的基本类型(例如 StringIntDoubleBool)默认都是可哈希的。

当自定义泛型类型时,你可以定义你自己的类型约束,这些约束将提供更为强大的泛型编程能力。像 可哈希(hashable) 这种抽象概念根据它们的概念特征来描述类型,而不是它们的具体类型。

类型约束语法

在一个类型参数名后面放置一个类名或者协议名,并用冒号进行分隔,来定义类型约束。下面将展示泛型函数约束的基本语法(与泛型类型的语法相同):

1
2
3
func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
// 这里是泛型函数的函数体部分
}

上面这个函数有两个类型参数。第一个类型参数 T 必须是 SomeClass 子类;第二个类型参数 U 必须符合 SomeProtocol 协议。

类型约束实践

这里有个名为 findIndex(ofString:in:) 的非泛型函数,该函数的功能是在一个 String 数组中查找给定 String 值的索引。若查找到匹配的字符串,findIndex(ofString:in:) 函数返回该字符串在数组中的索引值,否则返回 nil

1
2
3
4
5
6
7
8
func findIndex(ofString valueToFind: String, in array: [String]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}

findIndex(ofString:in:) 函数可以用于查找字符串数组中的某个字符串值:

1
2
3
4
5
let strings = ["cat", "dog", "llama", "parakeet", "terrapin"]
if let foundIndex = findIndex(ofString: "llama", in: strings) {
print("The index of llama is \(foundIndex)")
}
// 打印“The index of llama is 2”

如果只能查找字符串在数组中的索引,用处不是很大。不过,你可以用占位类型 T 替换 String 类型来写出具有相同功能的泛型函数 findIndex(_:_:)

下面展示了 findIndex(ofString:in:) 函数的泛型版本 findIndex(of:in:)。请注意这个函数返回值的类型仍然是 Int?,这是因为函数返回的是一个可选的索引数,而不是从数组中得到的一个可选值。需要提醒的是,这个函数无法通过编译,原因将在后面说明:

1
2
3
4
5
6
7
8
func findIndex<T>(of valueToFind: T, in array:[T]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}

上面所写的函数无法通过编译。问题出在相等性检查上,即 “if value == valueToFind”。不是所有的 Swift 类型都可以用等式符(==)进行比较。例如,如果你自定义类或结构体来描述复杂的数据模型,对于这个类或结构体而言,Swift 无法明确知道“相等”意味着什么。正因如此,这部分代码无法保证适用于任意类型 T,当你试图编译这部分代码时就会出现相应的错误。

不过,所有的这些并不会让我们无从下手。Swift 标准库中定义了一个 Equatable 协议,该协议要求任何遵循该协议的类型必须实现等式符(==)及不等符(!=),从而能对该类型的任意两个值进行比较。所有的 Swift 标准类型自动支持 Equatable 协议。

遵循 Equatable 协议的类型都可以安全地用于 findIndex(of:in:) 函数,因为其保证支持等式操作符。为了说明这个事情,当定义一个函数时,你可以定义一个 Equatable 类型约束作为类型参数定义的一部分:

1
2
3
4
5
6
7
8
func findIndex<T: Equatable>(of valueToFind: T, in array:[T]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}

findIndex(of:in:) 类型参数写做 T: Equatable,也就意味着“任何符合 Equatable 协议的类型 T”。

findIndex(of:in:) 函数现在可以成功编译了,并且适用于任何符合 Equatable 的类型,如 DoubleString

1
2
3
4
let doubleIndex = findIndex(of: 9.3, in: [3.14159, 0.1, 0.25])
// doubleIndex 类型为 Int?,其值为 nil,因为 9.3 不在数组中
let stringIndex = findIndex(of: "Andrea", in: ["Mike", "Malcolm", "Andrea"])
// stringIndex 类型为 Int?,其值为 2

关联类型

定义一个协议时,声明一个或多个关联类型作为协议定义的一部分将会非常有用。关联类型为协议中的某个类型提供了一个占位符名称,其代表的实际类型在协议被遵循时才会被指定。关联类型通过 associatedtype 关键字来指定。

关联类型实践

下面例子定义了一个 Container 协议,该协议定义了一个关联类型 Item

1
2
3
4
5
6
protocol Container {
associatedtype Item
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}

Container 协议定义了三个任何遵循该协议的类型(即容器)必须提供的功能:

  • 必须可以通过 append(_:) 方法添加一个新元素到容器里。
  • 必须可以通过 count 属性获取容器中元素的数量,并返回一个 Int 值。
  • 必须可以通过索引值类型为 Int 的下标检索到容器中的每一个元素。

该协议没有指定容器中元素该如何存储以及元素类型。该协议只指定了任何遵从 Container 协议的类型必须提供的三个功能。遵从协议的类型在满足这三个条件的情况下,也可以提供其他额外的功能。

任何遵从 Container 协议的类型必须能够指定其存储的元素的类型。具体来说,它必须确保添加到容器内的元素以及下标返回的元素类型是正确的。

为了定义这些条件,Container 协议需要在不知道容器中元素的具体类型的情况下引用这种类型。Container 协议需要指定任何通过 append(_:) 方法添加到容器中的元素和容器内的元素是相同类型,并且通过容器下标返回的元素的类型也是这种类型。

为此,Container 协议声明了一个关联类型 Item,写作 associatedtype Item。协议没有定义 Item 是什么,这个信息留给遵从协议的类型来提供。尽管如此,Item 别名提供了一种方式来引用 Container 中元素的类型,并将之用于 append(_:) 方法和下标,从而保证任何 Container 的行为都能如预期。

这是前面非泛型版本 IntStack 类型,使其遵循 Container 协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct IntStack: Container {
// IntStack 的原始实现部分
var items = [Int]()
mutating func push(_ item: Int) {
items.append(item)
}
mutating func pop() -> Int {
return items.removeLast()
}
// Container 协议的实现部分
typealias Item = Int
mutating func append(_ item: Int) {
self.push(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> Int {
return items[i]
}
}

IntStack 结构体实现了 Container 协议的三个要求,其原有功能也不会和这些要求相冲突。

此外,IntStack 在实现 Container 的要求时,指定 ItemInt 类型,即 typealias Item = Int,从而将 Container 协议中抽象的 Item 类型转换为具体的 Int 类型。

由于 Swift 的类型推断,实际上在 IntStack 的定义中不需要声明 ItemInt。因为 IntStack 符合 Container 协议的所有要求,Swift 只需通过 append(_:) 方法的 item 参数类型和下标返回值的类型,就可以推断出 Item 的具体类型。事实上,如果你在上面的代码中删除了 typealias Item = Int 这一行,一切也可正常工作,因为 Swift 清楚地知道 Item 应该是哪种类型。

你也可以让泛型 Stack 结构体遵循 Container 协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Stack<Element>: Container {
// Stack<Element> 的原始实现部分
var items = [Element]()
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
// Container 协议的实现部分
mutating func append(_ item: Element) {
self.push(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> Element {
return items[i]
}
}

这一次,占位类型参数 Element 被用作 append(_:) 方法的 item 参数和下标的返回类型。Swift 可以据此推断出 Element 的类型即是 Item 的类型。

扩展现有类型来指定关联类型

在扩展添加协议一致性 中描述了如何利用扩展让一个已存在的类型遵循一个协议,这包括使用了关联类型协议。

Swift 的 Array 类型已经提供 append(_:) 方法,count 属性,以及带有 Int 索引的下标来检索其元素。这三个功能都符合 Container 协议的要求,也就意味着你只需声明 Array 遵循Container 协议,就可以扩展 Array,使其遵从 Container 协议。你可以通过一个空扩展来实现这点,正如通过扩展采纳协议中的描述:

1
extension Array: Container {}

Arrayappend(_:) 方法和下标确保了 Swift 可以推断出 Item 具体类型。定义了这个扩展后,你可以将任意 Array 当作 Container 来使用。

给关联类型添加约束

你可以在协议里给关联类型添加约束来要求遵循的类型满足约束。例如,下面的代码定义了 Container 协议, 要求关联类型 Item 必须遵循 Equatable 协议:

1
2
3
4
5
6
protocol Container {
associatedtype Item: Equatable
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}

要遵守 Container 协议,Item 类型也必须遵守 Equatable 协议。

在关联类型约束里使用协议

协议可以作为它自身的要求出现。例如,有一个协议细化了 Container 协议,添加了一个suffix(_:) 方法。suffix(_:) 方法返回容器中从后往前给定数量的元素,并把它们存储在一个 Suffix 类型的实例里。

1
2
3
4
protocol SuffixableContainer: Container {
associatedtype Suffix: SuffixableContainer where Suffix.Item == Item
func suffix(_ size: Int) -> Suffix
}

在这个协议里,Suffix 是一个关联类型,就像上边例子中 ContainerItem 类型一样。Suffix 拥有两个约束:它必须遵循 SuffixableContainer 协议(就是当前定义的协议),以及它的 Item 类型必须是和容器里的 Item 类型相同。Item 的约束是一个 where 分句,它在下面具有泛型 Where 子句的扩展中有讨论。

这是上面 泛型类型Stack 类型的扩展,它遵循了 SuffixableContainer 协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extension Stack: SuffixableContainer {
func suffix(_ size: Int) -> Stack {
var result = Stack()
for index in (count-size)..<count {
result.append(self[index])
}
return result
}
// 推断 suffix 结果是Stack。
}
var stackOfInts = Stack<Int>()
stackOfInts.append(10)
stackOfInts.append(20)
stackOfInts.append(30)
let suffix = stackOfInts.suffix(2)
// suffix 包含 20 和 30

在上面的例子中,SuffixStack 的关联类型,也是 Stack ,所以 Stack 的后缀运算返回另一个 Stack 。另外,遵循 SuffixableContainer 的类型可以拥有一个与它自己不同的 Suffix 类型——也就是说后缀运算可以返回不同的类型。比如说,这里有一个非泛型 IntStack 类型的扩展,它遵循了 SuffixableContainer 协议,使用 Stack<Int> 作为它的后缀类型而不是 IntStack

1
2
3
4
5
6
7
8
9
10
extension IntStack: SuffixableContainer {
func suffix(_ size: Int) -> Stack<Int> {
var result = Stack<Int>()
for index in (count-size)..<count {
result.append(self[index])
}
return result
}
// 推断 suffix 结果是 Stack<Int>。
}

泛型 Where 语句

类型约束 让你能够为泛型函数、下标、类型的类型参数定义一些强制要求。

对关联类型添加约束通常是非常有用的。你可以通过定义一个泛型 where 子句来实现。通过泛型 where 子句让关联类型遵从某个特定的协议,以及某个特定的类型参数和关联类型必须类型相同。你可以通过将 where 关键字紧跟在类型参数列表后面来定义 where 子句,where 子句后跟一个或者多个针对关联类型的约束,以及一个或多个类型参数和关联类型间的相等关系。你可以在函数体或者类型的大括号之前添加 where 子句。

下面的例子定义了一个名为 allItemsMatch 的泛型函数,用来检查两个 Container 实例是否包含相同顺序的相同元素。如果所有的元素能够匹配,那么返回 true,否则返回 false

被检查的两个 Container 可以不是相同类型的容器(虽然它们可以相同),但它们必须拥有相同类型的元素。这个要求通过一个类型约束以及一个 where 子句来表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func allItemsMatch<C1: Container, C2: Container>
(_ someContainer: C1, _ anotherContainer: C2) -> Bool
where C1.Item == C2.Item, C1.Item: Equatable {

// 检查两个容器含有相同数量的元素
if someContainer.count != anotherContainer.count {
return false
}

// 检查每一对元素是否相等
for i in 0..<someContainer.count {
if someContainer[i] != anotherContainer[i] {
return false
}
}

// 所有元素都匹配,返回 true
return true
}

这个函数接受 someContaineranotherContainer 两个参数。参数 someContainer 的类型为 C1,参数 anotherContainer 的类型为 C2C1C2 是容器的两个占位类型参数,函数被调用时才能确定它们的具体类型。

这个函数的类型参数列表还定义了对两个类型参数的要求:

  • C1 必须符合 Container 协议(写作 C1: Container)。
  • C2 必须符合 Container 协议(写作 C2: Container)。
  • C1Item 必须和 C2Item 类型相同(写作 C1.Item == C2.Item)。
  • C1Item 必须符合 Equatable 协议(写作 C1.Item: Equatable)。

前两个要求定义在函数的类型形式参数列表里,后两个要求定义在了函数的泛型 where 分句中。

这些要求意味着:

  • someContainer 是一个 C1 类型的容器。
  • anotherContainer 是一个 C2 类型的容器。
  • someContaineranotherContainer 包含相同类型的元素。
  • someContainer 中的元素可以通过不等于操作符(!=)来检查它们是否相同。

第三个和第四个要求结合起来意味着 anotherContainer 中的元素也可以通过 != 操作符来比较,因为它们和 someContainer 中的元素类型相同。

这些要求让 allItemsMatch(_:_:) 函数能够比较两个容器,即使它们的容器类型不同。

allItemsMatch(_:_:) 函数首先检查两个容器元素个数是否相同,如果元素个数不同,那么一定不匹配,函数就会返回 false

进行这项检查之后,通过 for-in 循环和半闭区间操作符(..<)来迭代每个元素,检查 someContainer 中的元素是否不等于 anotherContainer 中的对应元素。如果两个元素不相等,那么两个容器不匹配,函数返回 false。

如果循环体结束后未发现任何不匹配的情况,表明两个容器匹配,函数返回 true

下面是 allItemsMatch(_:_:) 函数的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
var stackOfStrings = Stack<String>()
stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")

var arrayOfStrings = ["uno", "dos", "tres"]

if allItemsMatch(stackOfStrings, arrayOfStrings) {
print("All items match.")
} else {
print("Not all items match.")
}
// 打印“All items match.”

上面的例子创建 Stack 实例来存储 String 值,然后将三个字符串压栈。这个例子还通过数组字面量创建了一个 Array 实例,数组中包含同栈中一样的三个字符串。即使栈和数组是不同的类型,但它们都遵从 Container 协议,而且它们都包含相同类型的值。因此你可以用这两个容器作为参数来调用 allItemsMatch(_:_:) 函数。在上面的例子中,allItemsMatch(_:_:) 函数正确地显示了这两个容器中的所有元素都是相互匹配的。

具有泛型 Where 子句的扩展

你也可以使用泛型 where 子句作为扩展的一部分。基于以前的例子,下面的示例扩展了泛型 Stack 结构体,添加一个 isTop(_:) 方法。

1
2
3
4
5
6
7
8
extension Stack where Element: Equatable {
func isTop(_ item: Element) -> Bool {
guard let topItem = items.last else {
return false
}
return topItem == item
}
}

这个新的 isTop(_:) 方法首先检查这个栈是不是空的,然后比较给定的元素与栈顶部的元素。如果你尝试不用泛型 where 子句,会有一个问题:在 isTop(_:) 里面使用了 == 运算符,但是 Stack 的定义没有要求它的元素是符合 Equatable 协议的,所以使用 == 运算符导致编译时错误。使用泛型 where 子句可以为扩展添加新的条件,因此只有当栈中的元素符合 Equatable 协议时,扩展才会添加 isTop(_:) 方法。

以下是 isTop(_:) 方法的调用方式:

1
2
3
4
5
6
if stackOfStrings.isTop("tres") {
print("Top element is tres.")
} else {
print("Top element is something else.")
}
// 打印“Top element is tres.”

如果尝试在其元素不符合 Equatable 协议的栈上调用 isTop(_:) 方法,则会收到编译时错误。

1
2
3
4
5
struct NotEquatable { }
var notEquatableStack = Stack<NotEquatable>()
let notEquatableValue = NotEquatable()
notEquatableStack.push(notEquatableValue)
notEquatableStack.isTop(notEquatableValue) // 报错

你可以使用泛型 where 子句去扩展一个协议。基于以前的示例,下面的示例扩展了 Container 协议,添加一个 startsWith(_:) 方法。

1
2
3
4
5
extension Container where Item: Equatable {
func startsWith(_ item: Item) -> Bool {
return count >= 1 && self[0] == item
}
}

这个 startsWith(_:) 方法首先确保容器至少有一个元素,然后检查容器中的第一个元素是否与给定的元素相等。任何符合 Container 协议的类型都可以使用这个新的 startsWith(_:) 方法,包括上面使用的栈和数组,只要容器的元素是符合 Equatable 协议的。

1
2
3
4
5
6
if [9, 9, 9].startsWith(42) {
print("Starts with 42.")
} else {
print("Starts with something else.")
}
// 打印“Starts with something else.”

上述示例中的泛型 where 子句要求 Item 遵循协议,但也可以编写一个泛型 where 子句去要求 Item 为特定类型。例如:

1
2
3
4
5
6
7
8
9
10
11
extension Container where Item == Double {
func average() -> Double {
var sum = 0.0
for index in 0..<count {
sum += self[index]
}
return sum / Double(count)
}
}
print([1260.0, 1200.0, 98.6, 37.0].average())
// 打印“648.9”

此示例将一个 average() 方法添加到 Item 类型为 Double 的容器中。此方法遍历容器中的元素将其累加,并除以容器的数量计算平均值。它将数量从 Int 转换为 Double 确保能够进行浮点除法。

就像可以在其他地方写泛型 where 子句一样,你可以在一个泛型 where 子句中包含多个条件作为扩展的一部分。用逗号分隔列表中的每个条件。

具有泛型 Where 子句的关联类型

你可以在关联类型后面加上具有泛型 where 的字句。例如,建立一个包含迭代器(Iterator)的容器,就像是标准库中使用的 Sequence 协议那样。你应该这么写:

1
2
3
4
5
6
7
8
9
protocol Container {
associatedtype Item
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }

associatedtype Iterator: IteratorProtocol where Iterator.Element == Item
func makeIterator() -> Iterator
}

迭代器(Iterator)的泛型 where 子句要求:无论迭代器是什么类型,迭代器中的元素类型,必须和容器项目的类型保持一致。makeIterator() 则提供了容器的迭代器的访问接口。

一个协议继承了另一个协议,你通过在协议声明的时候,包含泛型 where 子句,来添加了一个约束到被继承协议的关联类型。例如,下面的代码声明了一个 ComparableContainer 协议,它要求所有的 Item 必须是 Comparable 的。

1
protocol ComparableContainer: Container where Item: Comparable { }

泛型下标

下标可以是泛型,它们能够包含泛型 where 子句。你可以在 subscript 后用尖括号来写占位符类型,你还可以在下标代码块花括号前写 where 子句。例如:

1
2
3
4
5
6
7
8
9
10
extension Container {
subscript<Indices: Sequence>(indices: Indices) -> [Item]
where Indices.Iterator.Element == Int {
var result = [Item]()
for index in indices {
result.append(self[index])
}
return result
}
}

这个 Container 协议的扩展添加了一个下标方法,接收一个索引的集合,返回每一个索引所在的值的数组。这个泛型下标的约束如下:

  • 在尖括号中的泛型参数 Indices,必须是符合标准库中的 Sequence 协议的类型。
  • 下标使用的单一的参数,indices,必须是 Indices 的实例。
  • 泛型 where 子句要求 Sequence(Indices)的迭代器,其所有的元素都是 Int 类型。这样就能确保在序列(Sequence)中的索引和容器(Container)里面的索引类型是一致的。

综合一下,这些约束意味着,传入到 indices 下标,是一个整型的序列。

留言與分享

swift协议

分類 编程语言, swift

协议

协议 定义了一个蓝图,规定了用来实现某一特定任务或者功能的方法、属性,以及其他需要的东西。类、结构体或枚举都可以遵循协议,并为协议定义的这些要求提供具体实现。某个类型能够满足某个协议的要求,就可以说该类型遵循这个协议。

除了遵循协议的类型必须实现的要求外,还可以对协议进行扩展,通过扩展来实现一部分要求或者实现一些附加功能,这样遵循协议的类型就能够使用这些功能。

协议语法

协议的定义方式与类、结构体和枚举的定义非常相似:

1
2
3
protocol SomeProtocol {
// 这里是协议的定义部分
}

要让自定义类型遵循某个协议,在定义类型时,需要在类型名称后加上协议名称,中间以冒号(:)分隔。遵循多个协议时,各协议之间用逗号(,)分隔:

1
2
3
struct SomeStructure: FirstProtocol, AnotherProtocol {
// 这里是结构体的定义部分
}

若一个拥有父类的类在遵循协议时,应该将父类名放在协议名之前,以逗号分隔:

1
2
3
class SomeClass: SomeSuperClass, FirstProtocol, AnotherProtocol {
// 这里是类的定义部分
}

属性要求

协议可以要求遵循协议的类型提供特定名称和类型的实例属性或类型属性。协议不指定属性是存储属性还是计算属性,它只指定属性的名称和类型。此外,协议还指定属性是可读的还是可读可写的

如果协议要求属性是可读可写的,那么该属性不能是常量属性或只读的计算型属性。如果协议只要求属性是可读的,那么该属性不仅可以是可读的,如果代码需要的话,还可以是可写的。

协议总是用 var 关键字来声明变量属性,在类型声明后加上 { set get } 来表示属性是可读可写的,可读属性则用 { get } 来表示:

1
2
3
4
protocol SomeProtocol {
var mustBeSettable: Int { get set }
var doesNotNeedToBeSettable: Int { get }
}

在协议中定义类型属性时,总是使用 static 关键字作为前缀。当类类型遵循协议时,除了 static 关键字,还可以使用 class 关键字来声明类型属性:

1
2
3
protocol AnotherProtocol {
static var someTypeProperty: Int { get set }
}

如下所示,这是一个只含有一个实例属性要求的协议:

1
2
3
protocol FullyNamed {
var fullName: String { get }
}

FullyNamed 协议除了要求遵循协议的类型提供 fullName 属性外,并没有其他特别的要求。这个协议表示,任何遵循 FullyNamed 的类型,都必须有一个可读的 String 类型的实例属性 fullName

下面是一个遵循 FullyNamed 协议的简单结构体:

1
2
3
4
5
struct Person: FullyNamed {
var fullName: String
}
let john = Person(fullName: "John Appleseed")
// john.fullName 为 "John Appleseed"

这个例子中定义了一个叫做 Person 的结构体,用来表示一个具有名字的人。从第一行代码可以看出,它遵循了 FullyNamed 协议。

Person 结构体的每一个实例都有一个 String 类型的存储型属性 fullName。这正好满足了 FullyNamed 协议的要求,也就意味着 Person 结构体正确地符合了协议。(如果协议要求未被完全满足,在编译时会报错。)

下面是一个更为复杂的类,它采纳并遵循了 FullyNamed 协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Starship: FullyNamed {
var prefix: String?
var name: String
init(name: String, prefix: String? = nil) {
self.name = name
self.prefix = prefix
}
var fullName: String {
return (prefix != nil ? prefix! + " " : "") + name
}
}
var ncc1701 = Starship(name: "Enterprise", prefix: "USS")
// ncc1701.fullName 为 "USS Enterprise"

Starship 类把 fullName 作为只读的计算属性来实现。每一个 Starship 类的实例都有一个名为 name 的非可选属性和一个名为 prefix 的可选属性。 当 prefix 存在时,计算属性 fullName 会将 prefix 插入到 name 之前,从而得到一个带有 prefixfullName

方法要求

协议可以要求遵循协议的类型实现某些指定的实例方法或类方法。这些方法作为协议的一部分,像普通方法一样放在协议的定义中,但是不需要大括号和方法体。可以在协议中定义具有可变参数的方法,和普通方法的定义方式相同。但是,不支持为协议中的方法提供默认参数。

正如属性要求中所述,在协议中定义类方法的时候,总是使用 static 关键字作为前缀。即使在类实现时,类方法要求使用 classstatic 作为关键字前缀,前面的规则仍然适用:

1
2
3
protocol SomeProtocol {
static func someTypeMethod()
}

下面的例子定义了一个只含有一个实例方法的协议:

1
2
3
protocol RandomNumberGenerator {
func random() -> Double
}

RandomNumberGenerator 协议要求遵循协议的类型必须拥有一个名为 random, 返回值类型为 Double 的实例方法。尽管这里并未指明,但是我们假设返回值是从 0.0 到(但不包括)1.0

RandomNumberGenerator 协议并不关心每一个随机数是怎样生成的,它只要求必须提供一个随机数生成器。

如下所示,下边是一个遵循并符合 RandomNumberGenerator 协议的类。该类实现了一个叫做 线性同余生成器(linear congruential generator) 的伪随机数算法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class LinearCongruentialGenerator: RandomNumberGenerator {
var lastRandom = 42.0
let m = 139968.0
let a = 3877.0
let c = 29573.0
func random() -> Double {
lastRandom = ((lastRandom * a + c).truncatingRemainder(dividingBy:m))
return lastRandom / m
}
}
let generator = LinearCongruentialGenerator()
print("Here's a random number: \(generator.random())")
// 打印 “Here's a random number: 0.37464991998171”
print("And another one: \(generator.random())")
// 打印 “And another one: 0.729023776863283”

异变方法要求

有时需要在方法中改变(或异变)方法所属的实例。例如,在值类型(即结构体和枚举)的实例方法中,将 mutating 关键字作为方法的前缀,写在 func 关键字之前,表示可以在该方法中修改它所属的实例以及实例的任意属性的值。这一过程在 在实例方法中修改值类型 章节中有详细描述。

如果你在协议中定义了一个实例方法,该方法会改变遵循该协议的类型的实例,那么在定义协议时需要在方法前加 mutating 关键字。这使得结构体和枚举能够遵循此协议并满足此方法要求。

注意

实现协议中的 mutating 方法时,若是类类型,则不用写 mutating 关键字。而对于结构体和枚举,则必须写 mutating 关键字。

如下所示,Togglable 协议只定义了一个名为 toggle 的实例方法。顾名思义,toggle() 方法将改变实例属性,从而切换遵循该协议类型的实例的状态。

toggle() 方法在定义的时候,使用 mutating 关键字标记,这表明当它被调用时,该方法将会改变遵循协议的类型的实例:

1
2
3
protocol Togglable {
mutating func toggle()
}

当使用枚举或结构体来实现 Togglable 协议时,需要提供一个带有 mutating 前缀的 toggle() 方法。

下面定义了一个名为 OnOffSwitch 的枚举。这个枚举在两种状态之间进行切换,用枚举成员 OnOff 表示。枚举的 toggle() 方法被标记为 mutating,以满足 Togglable 协议的要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum OnOffSwitch: Togglable {
case off, on
mutating func toggle() {
switch self {
case .off:
self = .on
case .on:
self = .off
}
}
}
var lightSwitch = OnOffSwitch.off
lightSwitch.toggle()
// lightSwitch 现在的值为 .on

构造器要求

协议可以要求遵循协议的类型实现指定的构造器。你可以像编写普通构造器那样,在协议的定义里写下构造器的声明,但不需要写花括号和构造器的实体:

1
2
3
protocol SomeProtocol {
init(someParameter: Int)
}

协议构造器要求的类实现

你可以在遵循协议的类中实现构造器,无论是作为指定构造器,还是作为便利构造器。无论哪种情况,你都必须为构造器实现标上 required 修饰符:

1
2
3
4
5
class SomeClass: SomeProtocol {
required init(someParameter: Int) {
// 这里是构造器的实现部分
}
}

使用 required 修饰符可以确保所有子类也必须提供此构造器实现,从而也能符合协议。

关于 required 构造器的更多内容,请参考 必要构造器

注意

如果类已经被标记为 final,那么不需要在协议构造器的实现中使用 required 修饰符,因为 final 类不能有子类。关于 final 修饰符的更多内容,请参见 防止重写

如果一个子类重写了父类的指定构造器,并且该构造器满足了某个协议的要求,那么该构造器的实现需要同时标注 requiredoverride 修饰符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protocol SomeProtocol {
init()
}

class SomeSuperClass {
init() {
// 这里是构造器的实现部分
}
}

class SomeSubClass: SomeSuperClass, SomeProtocol {
// 因为遵循协议,需要加上 required
// 因为继承自父类,需要加上 override
required override init() {
// 这里是构造器的实现部分
}
}

可失败构造器要求

协议还可以为遵循协议的类型定义可失败构造器要求,详见 可失败构造器

遵循协议的类型可以通过可失败构造器(init?)或非可失败构造器(init)来满足协议中定义的可失败构造器要求。协议中定义的非可失败构造器要求可以通过非可失败构造器(init)或隐式解包可失败构造器(init!)来满足。

协议作为类型

尽管协议本身并未实现任何功能,但是协议可以被当做一个功能完备的类型来使用。协议作为类型使用,有时被称作「存在类型」,这个名词来自「存在着一个类型 T,该类型遵循协议 T」。

协议可以像其他普通类型一样使用,使用场景如下:

  • 作为函数、方法或构造器中的参数类型或返回值类型
  • 作为常量、变量或属性的类型
  • 作为数组、字典或其他容器中的元素类型

注意

协议是一种类型,因此协议类型的名称应与其他类型(例如 IntDoubleString)的写法相同,使用大写字母开头的驼峰式写法,例如(FullyNamedRandomNumberGenerator)。

下面是将协议作为类型使用的例子:

1
2
3
4
5
6
7
8
9
10
11
class Dice {
let sides: Int
let generator: RandomNumberGenerator
init(sides: Int, generator: RandomNumberGenerator) {
self.sides = sides
self.generator = generator
}
func roll() -> Int {
return Int(generator.random() * Double(sides)) + 1
}
}

例子中定义了一个 Dice 类,用来代表桌游中拥有 N 个面的骰子。Dice 的实例含有 sidesgenerator 两个属性,前者是整型,用来表示骰子有几个面,后者为骰子提供一个随机数生成器,从而生成随机点数。

generator 属性的类型为 RandomNumberGenerator,因此任何遵循了 RandomNumberGenerator 协议的类型的实例都可以赋值给 generator,除此之外并无其他要求。并且由于其类型是 RandomNumberGenerator,在 Dice 类中与 generator 交互的代码,必须适用于所有 generator 实例都遵循的方法。这句话的意思是不能使用由 generator 底层类型提供的任何方法或属性。但是你可以通过向下转型,从协议类型转换成底层实现类型,比如从父类向下转型为子类。请参考 向下转型

Dice 类还有一个构造器,用来设置初始状态。构造器有一个名为 generator,类型为 RandomNumberGenerator 的形参。在调用构造方法创建 Dice 的实例时,可以传入任何遵循 RandomNumberGenerator 协议的实例给 generator

Dice 类提供了一个名为 roll 的实例方法,用来模拟骰子的面值。它先调用 generatorrandom() 方法来生成一个 [0.0,1.0) 区间内的随机数,然后使用这个随机数生成正确的骰子面值。因为 generator 遵循了 RandomNumberGenerator 协议,可以确保它有个 random() 方法可供调用。

下面的例子展示了如何使用 LinearCongruentialGenerator 的实例作为随机数生成器来创建一个六面骰子:

1
2
3
4
5
6
7
8
9
var d6 = Dice(sides: 6, generator: LinearCongruentialGenerator())
for _ in 1...5 {
print("Random dice roll is \(d6.roll())")
}
// Random dice roll is 3
// Random dice roll is 5
// Random dice roll is 4
// Random dice roll is 5
// Random dice roll is 4

委托

委托是一种设计模式,它允许类或结构体将一些需要它们负责的功能委托给其他类型的实例。委托模式的实现很简单:定义协议来封装那些需要被委托的功能,这样就能确保遵循协议的类型能提供这些功能。委托模式可以用来响应特定的动作,或者接收外部数据源提供的数据,而无需关心外部数据源的类型。

下面的例子定义了两个基于骰子游戏的协议:

1
2
3
4
5
6
7
8
9
protocol DiceGame {
var dice: Dice { get }
func play()
}
protocol DiceGameDelegate {
func gameDidStart(_ game: DiceGame)
func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int)
func gameDidEnd(_ game: DiceGame)
}

DiceGame 协议可以被任意涉及骰子的游戏遵循。

DiceGameDelegate 协议可以被任意类型遵循,用来追踪 DiceGame 的游戏过程。为了防止强引用导致的循环引用问题,可以把协议声明为弱引用,更多相关的知识请看 类实例之间的循环强引用,当协议标记为类专属可以使 SnakesAndLadders 类在声明协议时强制要使用弱引用。若要声明类专属的协议就必须继承于 AnyObject ,更多请看 类专属的协议

如下所示,SnakesAndLadders控制流 章节引入的蛇梯棋游戏的新版本。新版本使用 Dice 实例作为骰子,并且实现了 DiceGameDiceGameDelegate 协议,后者用来记录游戏的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class SnakesAndLadders: DiceGame {
let finalSquare = 25
let dice = Dice(sides: 6, generator: LinearCongruentialGenerator())
var square = 0
var board: [Int]
init() {
board = Array(repeating: 0, count: finalSquare + 1)
board[03] = +08; board[06] = +11; board[09] = +09; board[10] = +02
board[14] = -10; board[19] = -11; board[22] = -02; board[24] = -08
}
var delegate: DiceGameDelegate?
func play() {
square = 0
delegate?.gameDidStart(self)
gameLoop: while square != finalSquare {
let diceRoll = dice.roll()
delegate?.game(self, didStartNewTurnWithDiceRoll: diceRoll)
switch square + diceRoll {
case finalSquare:
break gameLoop
case let newSquare where newSquare > finalSquare:
continue gameLoop
default:
square += diceRoll
square += board[square]
}
}
delegate?.gameDidEnd(self)
}
}

关于这个蛇梯棋游戏的详细描述请参阅 中断(Break)

这个版本的游戏封装到了 SnakesAndLadders 类中,该类遵循了 DiceGame 协议,并且提供了相应的可读的 dice 属性和 play() 方法。( dice 属性在构造之后就不再改变,且协议只要求 dice 为可读的,因此将 dice 声明为常量属性。)

游戏使用 SnakesAndLadders 类的 init() 构造器来初始化游戏。所有的游戏逻辑被转移到了协议中的 play() 方法,play() 方法使用协议要求的 dice 属性提供骰子摇出的值。

注意,delegate 并不是游戏的必备条件,因此 delegate 被定义为 DiceGameDelegate 类型的可选属性。因为 delegate 是可选值,因此会被自动赋予初始值 nil。随后,可以在游戏中为 delegate 设置适当的值。

DicegameDelegate 协议提供了三个方法用来追踪游戏过程。这三个方法被放置于游戏的逻辑中,即 play() 方法内。分别在游戏开始时,新一轮开始时,以及游戏结束时被调用。

因为 delegate 是一个 DiceGameDelegate 类型的可选属性,因此在 play() 方法中通过可选链式调用来调用它的方法。若 delegate 属性为 nil,则调用方法会优雅地失败,并不会产生错误。若 delegate 不为 nil,则方法能够被调用,并传递 SnakesAndLadders 实例作为参数。

如下示例定义了 DiceGameTracker 类,它遵循了 DiceGameDelegate 协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class DiceGameTracker: DiceGameDelegate {
var numberOfTurns = 0
func gameDidStart(_ game: DiceGame) {
numberOfTurns = 0
if game is SnakesAndLadders {
print("Started a new game of Snakes and Ladders")
}
print("The game is using a \(game.dice.sides)-sided dice")
}
func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int) {
numberOfTurns += 1
print("Rolled a \(diceRoll)")
}
func gameDidEnd(_ game: DiceGame) {
print("The game lasted for \(numberOfTurns) turns")
}
}

DiceGameTracker 实现了 DiceGameDelegate 协议要求的三个方法,用来记录游戏已经进行的轮数。当游戏开始时,numberOfTurns 属性被赋值为 0,然后在每新一轮中递增,游戏结束后,打印游戏的总轮数。

gameDidStart(_:) 方法从 game 参数获取游戏信息并打印。game 参数是 DiceGame 类型而不是 SnakeAndLadders 类型,所以在 gameDidStart(_:) 方法中只能访问 DiceGame 协议中的内容。当然了,SnakeAndLadders 的方法也可以在类型转换之后调用。在上例代码中,通过 is 操作符检查 game 是否为 SnakesAndLadders 类型的实例,如果是,则打印出相应的消息。

无论当前进行的是何种游戏,由于 game 符合 DiceGame 协议,可以确保 game 含有 dice 属性。因此在 gameDidStart(_:) 方法中可以通过传入的 game 参数来访问 dice 属性,进而打印出 dicesides 属性的值。

DiceGameTracker 的运行情况如下所示:

1
2
3
4
5
6
7
8
9
10
11
let tracker = DiceGameTracker()
let game = SnakesAndLadders()
game.delegate = tracker
game.play()
// Started a new game of Snakes and Ladders
// The game is using a 6-sided dice
// Rolled a 3
// Rolled a 5
// Rolled a 4
// Rolled a 5
// The game lasted for 4 turns

在扩展里添加协议遵循

即便无法修改源代码,依然可以通过扩展令已有类型遵循并符合协议。扩展可以为已有类型添加属性、方法、下标以及构造器,因此可以符合协议中的相应要求。详情请在 扩展 章节中查看。

注意

通过扩展令已有类型遵循并符合协议时,该类型的所有实例也会随之获得协议中定义的各项功能。

例如下面这个 TextRepresentable 协议,任何想要通过文本表示一些内容的类型都可以实现该协议。这些想要表示的内容可以是实例本身的描述,也可以是实例当前状态的文本描述:

1
2
3
protocol TextRepresentable {
var textualDescription: String { get }
}

可以通过扩展,令先前提到的 Dice 类可以扩展来采纳和遵循 TextRepresentable 协议:

1
2
3
4
5
extension Dice: TextRepresentable {
var textualDescription: String {
return "A \(sides)-sided dice"
}
}

通过扩展遵循并采纳协议,和在原始定义中遵循并符合协议的效果完全相同。协议名称写在类型名之后,以冒号隔开,然后在扩展的大括号内实现协议要求的内容。

现在所有 Dice 的实例都可以看做 TextRepresentable 类型:

1
2
3
let d12 = Dice(sides: 12, generator: LinearCongruentialGenerator())
print(d12.textualDescription)
// 打印 “A 12-sided dice”

同样,SnakesAndLadders 类也可以通过扩展来采纳和遵循 TextRepresentable 协议:

1
2
3
4
5
6
7
extension SnakesAndLadders: TextRepresentable {
var textualDescription: String {
return "A game of Snakes and Ladders with \(finalSquare) squares"
}
}
print(game.textualDescription)
// 打印 “A game of Snakes and Ladders with 25 squares”

有条件地遵循协议

泛型类型可能只在某些情况下满足一个协议的要求,比如当类型的泛型形式参数遵循对应协议时。你可以通过在扩展类型时列出限制让泛型类型有条件地遵循某协议。在你采纳协议的名字后面写泛型 where 分句。更多关于泛型 where 分句,见 泛型 Where 分句

下面的扩展让 Array 类型只要在存储遵循 TextRepresentable 协议的元素时就遵循 TextRepresentable 协议。

1
2
3
4
5
6
7
8
9
extension Array: TextRepresentable where Element: TextRepresentable {
var textualDescription: String {
let itemsAsText = self.map { $0.textualDescription }
return "[" + itemsAsText.joined(separator: ", ") + "]"
}
}
let myDice = [d6, d12]
print(myDice.textualDescription)
// 打印 "[A 6-sided dice, A 12-sided dice]"

在扩展里声明采纳协议

当一个类型已经符合了某个协议中的所有要求,却还没有声明采纳该协议时,可以通过空的扩展来让它采纳该协议:

1
2
3
4
5
6
7
struct Hamster {
var name: String
var textualDescription: String {
return "A hamster named \(name)"
}
}
extension Hamster: TextRepresentable {}

从现在起,Hamster 的实例可以作为 TextRepresentable 类型使用:

1
2
3
4
let simonTheHamster = Hamster(name: "Simon")
let somethingTextRepresentable: TextRepresentable = simonTheHamster
print(somethingTextRepresentable.textualDescription)
// 打印 “A hamster named Simon”

注意

即使满足了协议的所有要求,类型也不会自动遵循协议,必须显式地遵循协议。

协议类型的集合

协议类型可以在数组或者字典这样的集合中使用,在 协议类型 提到了这样的用法。下面的例子创建了一个元素类型为 TextRepresentable 的数组:

1
let things: [TextRepresentable] = [game, d12, simonTheHamster]

如下所示,可以遍历 things 数组,并打印每个元素的文本表示:

1
2
3
4
5
6
for thing in things {
print(thing.textualDescription)
}
// A game of Snakes and Ladders with 25 squares
// A 12-sided dice
// A hamster named Simon

注意 thing 常量是 TextRepresentable 类型而不是 DiceDiceGameHamster 等类型,即使实例在幕后确实是这些类型中的一种。由于 thingTextRepresentable 类型,任何 TextRepresentable 的实例都有一个 textualDescription 属性,所以在每次循环中可以安全地访问 thing.textualDescription

协议的继承

协议能够继承一个或多个其他协议,可以在继承的协议的基础上增加新的要求。协议的继承语法与类的继承相似,多个被继承的协议间用逗号分隔:

1
2
3
protocol InheritingProtocol: SomeProtocol, AnotherProtocol {
// 这里是协议的定义部分
}

如下所示,PrettyTextRepresentable 协议继承了 TextRepresentable 协议:

1
2
3
protocol PrettyTextRepresentable: TextRepresentable {
var prettyTextualDescription: String { get }
}

例子中定义了一个新的协议 PrettyTextRepresentable,它继承自 TextRepresentable 协议。任何遵循 PrettyTextRepresentable 协议的类型在满足该协议的要求时,也必须满足 TextRepresentable 协议的要求。在这个例子中,PrettyTextRepresentable 协议额外要求遵循协议的类型提供一个返回值为 String 类型的 prettyTextualDescription 属性。

如下所示,扩展 SnakesAndLadders,使其遵循并符合 PrettyTextRepresentable 协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extension SnakesAndLadders: PrettyTextRepresentable {
var prettyTextualDescription: String {
var output = textualDescription + ":\n"
for index in 1...finalSquare {
switch board[index] {
case let ladder where ladder > 0:
output += "▲ "
case let snake where snake < 0:
output += "▼ "
default:
output += "○ "
}
}
return output
}
}

上述扩展令 SnakesAndLadders 遵循了 PrettyTextRepresentable 协议,并提供了协议要求的 prettyTextualDescription 属性。每个 PrettyTextRepresentable 类型同时也是 TextRepresentable 类型,所以在 prettyTextualDescription 的实现中,可以访问 textualDescription 属性。然后,拼接上了冒号和换行符。接着,遍历数组中的元素,拼接一个几何图形来表示每个棋盘方格的内容:

  • 当从数组中取出的元素的值大于 0 时,用 表示。
  • 当从数组中取出的元素的值小于 0 时,用 表示。
  • 当从数组中取出的元素的值等于 0 时,用 表示。

任意 SankesAndLadders 的实例都可以使用 prettyTextualDescription 属性来打印一个漂亮的文本描述:

1
2
3
print(game.prettyTextualDescription)
// A game of Snakes and Ladders with 25 squares:
// ○ ○ ▲ ○ ○ ▲ ○ ○ ▲ ▲ ○ ○ ○ ▼ ○ ○ ○ ○ ▼ ○ ○ ▼ ○ ▼ ○

类专属的协议

你通过添加 AnyObject 关键字到协议的继承列表,就可以限制协议只能被类类型采纳(以及非结构体或者非枚举的类型)。

1
2
3
protocol SomeClassOnlyProtocol: AnyObject, SomeInheritedProtocol {
// 这里是类专属协议的定义部分
}

在以上例子中,协议 SomeClassOnlyProtocol 只能被类类型采纳。如果尝试让结构体或枚举类型采纳 SomeClassOnlyProtocol,则会导致编译时错误。

注意

当协议定义的要求需要遵循协议的类型必须是引用语义而非值语义时,应该采用类类型专属协议。关于引用语义和值语义的更多内容,请查看 结构体和枚举是值类型类是引用类型

协议合成

要求一个类型同时遵循多个协议是很有用的。你可以使用协议组合来复合多个协议到一个要求里。协议组合行为就和你定义的临时局部协议一样拥有构成中所有协议的需求。协议组合不定义任何新的协议类型。

协议组合使用 SomeProtocol & AnotherProtocol 的形式。你可以列举任意数量的协议,用和符号(&)分开。除了协议列表,协议组合也能包含类类型,这允许你标明一个需要的父类。

下面的例子中,将 NamedAged 两个协议按照上述语法组合成一个协议,作为函数参数的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protocol Named {
var name: String { get }
}
protocol Aged {
var age: Int { get }
}
struct Person: Named, Aged {
var name: String
var age: Int
}
func wishHappyBirthday(to celebrator: Named & Aged) {
print("Happy birthday, \(celebrator.name), you're \(celebrator.age)!")
}
let birthdayPerson = Person(name: "Malcolm", age: 21)
wishHappyBirthday(to: birthdayPerson)
// 打印 “Happy birthday Malcolm - you're 21!”

Named 协议包含 String 类型的 name 属性。Aged 协议包含 Int 类型的 age 属性。Person 结构体采纳了这两个协议。

wishHappyBirthday(to:) 函数的参数 celebrator 的类型为 Named & Aged, 这意味着“任何同时遵循 Named 和 Aged 的协议”。它不关心参数的具体类型,只要参数符合这两个协议即可。

上面的例子创建了一个名为 birthdayPersonPerson 的实例,作为参数传递给了 wishHappyBirthday(to:) 函数。因为 Person 同时符合这两个协议,所以这个参数合法,函数将打印生日问候语。

这里有一个例子:将 Location 类和前面的 Named 协议进行组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Location {
var latitude: Double
var longitude: Double
init(latitude: Double, longitude: Double) {
self.latitude = latitude
self.longitude = longitude
}
}
class City: Location, Named {
var name: String
init(name: String, latitude: Double, longitude: Double) {
self.name = name
super.init(latitude: latitude, longitude: longitude)
}
}
func beginConcert(in location: Location & Named) {
print("Hello, \(location.name)!")
}

let seattle = City(name: "Seattle", latitude: 47.6, longitude: -122.3)
beginConcert(in: seattle)
// 打印 "Hello, Seattle!"

beginConcert(in:) 函数接受一个类型为 Location & Named 的参数,这意味着“任何 Location 的子类,并且遵循 Named 协议”。例如,City 就满足这样的条件。

将 birthdayPerson 传入 beginConcert(in:) 函数是不合法的,因为 Person 不是 Location 的子类。同理,如果你新建一个类继承于 Location,但是没有遵循 Named 协议,而用这个类的实例去调用 beginConcert(in:) 函数也是非法的。

检查协议一致性

你可以使用 类型转换 中描述的 isas 操作符来检查协议一致性,即是否符合某协议,并且可以转换到指定的协议类型。检查和转换协议的语法与检查和转换类型是完全一样的:

  • is 用来检查实例是否符合某个协议,若符合则返回 true,否则返回 false
  • as? 返回一个可选值,当实例符合某个协议时,返回类型为协议类型的可选值,否则返回 nil
  • as! 将实例强制向下转换到某个协议类型,如果强转失败,将触发运行时错误。

下面的例子定义了一个 HasArea 协议,该协议定义了一个 Double 类型的可读属性 area

1
2
3
protocol HasArea {
var area: Double { get }
}

如下所示,Circle 类和 Country 类都遵循了 HasArea 协议:

1
2
3
4
5
6
7
8
9
10
class Circle: HasArea {
let pi = 3.1415927
var radius: Double
var area: Double { return pi * radius * radius }
init(radius: Double) { self.radius = radius }
}
class Country: HasArea {
var area: Double
init(area: Double) { self.area = area }
}

Circle 类把 area 属性实现为基于存储型属性 radius 的计算型属性。Country 类则把 area 属性实现为存储型属性。这两个类都正确地遵循了 HasArea 协议。

如下所示,Animal 是一个未遵循 HasArea 协议的类:

1
2
3
4
class Animal {
var legs: Int
init(legs: Int) { self.legs = legs }
}

CircleCountryAnimal 并没有一个共同的基类,尽管如此,它们都是类,它们的实例都可以作为 AnyObject 类型的值,存储在同一个数组中:

1
2
3
4
5
let objects: [AnyObject] = [
Circle(radius: 2.0),
Country(area: 243_610),
Animal(legs: 4)
]

objects 数组使用字面量初始化,数组包含一个 radius2Circle 的实例,一个保存了英国国土面积的 Country 实例和一个 legs4Animal 实例。

如下所示,objects 数组可以被迭代,并对迭代出的每一个元素进行检查,看它是否符合 HasArea 协议:

1
2
3
4
5
6
7
8
9
10
for object in objects {
if let objectWithArea = object as? HasArea {
print("Area is \(objectWithArea.area)")
} else {
print("Something that doesn't have an area")
}
}
// Area is 12.5663708
// Area is 243610.0
// Something that doesn't have an area

当迭代出的元素符合 HasArea 协议时,将 as? 操作符返回的可选值通过可选绑定,绑定到 objectWithArea 常量上。objectWithAreaHasArea 协议类型的实例,因此 area 属性可以被访问和打印。

objects 数组中的元素的类型并不会因为强转而丢失类型信息,它们仍然是 CircleCountryAnimal 类型。然而,当它们被赋值给 objectWithArea 常量时,只被视为 HasArea 类型,因此只有 area 属性能够被访问。

可选的协议要求

协议可以定义可选要求,遵循协议的类型可以选择是否实现这些要求。在协议中使用 optional 关键字作为前缀来定义可选要求。可选要求用在你需要和 Objective-C 打交道的代码中。协议和可选要求都必须带上 @objc 属性。标记 @objc 特性的协议只能被继承自 Objective-C 类的类或者 @objc 类遵循,其他类以及结构体和枚举均不能遵循这种协议。

使用可选要求时(例如,可选的方法或者属性),它们的类型会自动变成可选的。比如,一个类型为 (Int) -> String 的方法会变成 ((Int) -> String)?。需要注意的是整个函数类型是可选的,而不是函数的返回值。

协议中的可选要求可通过可选链式调用来使用,因为遵循协议的类型可能没有实现这些可选要求。类似 someOptionalMethod?(someArgument) 这样,你可以在可选方法名称后加上 ? 来调用可选方法。详细内容可在 可选链式调用 章节中查看。

下面的例子定义了一个名为 Counter 的用于整数计数的类,它使用外部的数据源来提供每次的增量。数据源由 CounterDataSource 协议定义,它包含两个可选要求:

1
2
3
4
@objc protocol CounterDataSource {
@objc optional func increment(forCount count: Int) -> Int
@objc optional var fixedIncrement: Int { get }
}

CounterDataSource 协议定义了一个可选方法 increment(forCount:) 和一个可选属性 fiexdIncrement,它们使用了不同的方法来从数据源中获取适当的增量值。

注意

严格来讲,CounterDataSource 协议中的方法和属性都是可选的,因此遵循协议的类可以不实现这些要求,尽管技术上允许这样做,不过最好不要这样写。

Counter 类含有 CounterDataSource? 类型的可选属性 dataSource,如下所示:

1
2
3
4
5
6
7
8
9
10
11
class Counter {
var count = 0
var dataSource: CounterDataSource?
func increment() {
if let amount = dataSource?.increment?(forCount: count) {
count += amount
} else if let amount = dataSource?.fixedIncrement {
count += amount
}
}
}

Counter 类使用变量属性 count 来存储当前值。该类还定义了一个 increment 方法,每次调用该方法的时候,将会增加 count 的值。

increment() 方法首先试图使用 increment(forCount:) 方法来得到每次的增量。increment() 方法使用可选链式调用来尝试调用 increment(forCount:),并将当前的 count 值作为参数传入。

这里使用了两层可选链式调用。首先,由于 dataSource 可能为 nil,因此在 dataSource 后边加上了 ?,以此表明只在 dataSource 非空时才去调用 increment(forCount:) 方法。其次,即使 dataSource 存在,也无法保证其是否实现了 increment(forCount:) 方法,因为这个方法是可选的。因此,increment(forCount:) 方法同样使用可选链式调用进行调用,只有在该方法被实现的情况下才能调用它,所以在 increment(forCount:) 方法后边也加上了 ?

调用 increment(forCount:) 方法在上述两种情形下都有可能失败,所以返回值为 Int? 类型。虽然在 CounterDataSource 协议中,increment(forCount:) 的返回值类型是非可选 Int。另外,即使这里使用了两层可选链式调用,最后的返回结果依旧是单层的可选类型。关于这一点的更多信息,请查阅 连接多层可选链式调用

在调用 increment(forCount:) 方法后,Int? 型的返回值通过可选绑定解包并赋值给常量 amount。如果可选值确实包含一个数值,也就是说,数据源和方法都存在,数据源方法返回了一个有效值。之后便将解包后的 amount 加到 count 上,增量操作完成。

如果没有从 increment(forCount:) 方法获取到值,可能由于 dataSourcenil,或者它并没有实现 increment(forCount:) 方法,那么 increment() 方法将试图从数据源的 fixedIncrement 属性中获取增量。fixedIncrement 是一个可选属性,因此属性值是一个 Int? 值,即使该属性在 CounterDataSource 协议中的类型是非可选的 Int

下面的例子展示了 CounterDataSource 的简单实现。ThreeSource 类遵循了 CounterDataSource 协议,它实现了可选属性 fixedIncrement,每次会返回 3

1
2
3
class ThreeSource: NSObject, CounterDataSource {
let fixedIncrement = 3
}

可以使用 ThreeSource 的实例作为 Counter 实例的数据源:

1
2
3
4
5
6
7
8
9
10
var counter = Counter()
counter.dataSource = ThreeSource()
for _ in 1...4 {
counter.increment()
print(counter.count)
}
// 3
// 6
// 9
// 12

上述代码新建了一个 Counter 实例,并将它的数据源设置为一个 ThreeSource 的实例,然后调用 increment() 方法 4 次。按照预期预期一样,每次调用都会将 count 的值增加 3.

下面是一个更为复杂的数据源 TowardsZeroSource,它将使得最后的值变为 0

1
2
3
4
5
6
7
8
9
10
11
class TowardsZeroSource: NSObject, CounterDataSource {
func increment(forCount count: Int) -> Int {
if count == 0 {
return 0
} else if count < 0 {
return 1
} else {
return -1
}
}
}

TowardsZeroSource 实现了 CounterDataSource 协议中的 increment(forCount:) 方法,以 count 参数为依据,计算出每次的增量。如果 count 已经为 0,此方法将返回 0,以此表明之后不应再有增量操作发生。

你可以使用 TowardsZeroSource 实例将 Counter 实例来从 -4 增加到 0。一旦增加到 0,数值便不会再有变动:

1
2
3
4
5
6
7
8
9
10
11
counter.count = -4
counter.dataSource = TowardsZeroSource()
for _ in 1...5 {
counter.increment()
print(counter.count)
}
// -3
// -2
// -1
// 0
// 0

协议扩展

协议可以通过扩展来为遵循协议的类型提供属性、方法以及下标的实现。通过这种方式,你可以基于协议本身来实现这些功能,而无需在每个遵循协议的类型中都重复同样的实现,也无需使用全局函数。

例如,可以扩展 RandomNumberGenerator 协议来提供 randomBool() 方法。该方法使用协议中定义的 random() 方法来返回一个随机的 Bool 值:

1
2
3
4
5
extension RandomNumberGenerator {
func randomBool() -> Bool {
return random() > 0.5
}
}

通过协议扩展,所有遵循协议的类型,都能自动获得这个扩展所增加的方法实现而无需任何额外修改:

1
2
3
4
5
let generator = LinearCongruentialGenerator()
print("Here's a random number: \(generator.random())")
// 打印 “Here's a random number: 0.37464991998171”
print("And here's a random Boolean: \(generator.randomBool())")
// 打印 “And here's a random Boolean: true”

提供默认实现

可以通过协议扩展来为协议要求的属性、方法以及下标提供默认的实现。如果遵循协议的类型为这些要求提供了自己的实现,那么这些自定义实现将会替代扩展中的默认实现被使用。

注意

通过协议扩展为协议要求提供的默认实现和可选的协议要求不同。虽然在这两种情况下,遵循协议的类型都无需自己实现这些要求,但是通过扩展提供的默认实现可以直接调用,而无需使用可选链式调用。

例如,PrettyTextRepresentable 协议继承自 TextRepresentable 协议,可以为其提供一个默认的 prettyTextualDescription 属性来简单地返回 textualDescription 属性的值:

1
2
3
4
5
extension PrettyTextRepresentable  {
var prettyTextualDescription: String {
return textualDescription
}
}

为协议扩展添加限制条件

在扩展协议的时候,可以指定一些限制条件,只有遵循协议的类型满足这些限制条件时,才能获得协议扩展提供的默认实现。这些限制条件写在协议名之后,使用 where 子句来描述,正如 泛型 Where 子句 中所描述的。

例如,你可以扩展 Collection 协议,适用于集合中的元素遵循了 Equatable 协议的情况。通过限制集合元素遵 Equatable 协议, 作为标准库的一部分, 你可以使用 ==!= 操作符来检查两个元素的等价性和非等价性。

1
2
3
4
5
6
7
8
9
10
extension Collection where Element: Equatable {
func allEqual() -> Bool {
for element in self {
if element != self.first {
return false
}
}
return true
}
}

如果集合中的所有元素都一致,allEqual() 方法才返回 true

看看两个整数数组,一个数组的所有元素都是一样的,另一个不一样:

1
2
let equalNumbers = [100, 100, 100, 100, 100]
let differentNumbers = [100, 100, 200, 100, 200]

由于数组遵循 Collection 而且整数遵循 EquatableequalNumbersdifferentNumbers 都可以使用 allEqual() 方法。

1
2
3
4
print(equalNumbers.allEqual())
// 打印 "true"
print(differentNumbers.allEqual())
// 打印 "false"

注意

如果一个遵循的类型满足了为同一方法或属性提供实现的多个限制型扩展的要求, Swift 会使用最匹配限制的实现。

留言與分享

作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau