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`集合内嵌填充指定大小。

}
}