由于 API 变动,此文章部分内容已失效,最新完整中文教程及代码请查看 https://github.com/WillieWangWei/SwiftUI-Tutorials
微信技术群
SwiftUI
代表未来构建 App 的方向,欢迎加群一起交流技术,解决问题。
加群现在需要申请了,可以先加我微信,备注 "SwiftUI",我会拉你进群。
创建 macOS App
在创建了一个 watchOS 版本的
Landmarks
之后,让我们把目光投向更大的内容:将Landmarks
运行在 Mac 上。在你目前为止所学到的基础上,强化你在构建 iOS、watchOS 和 macOS 的 SwiftUI 应用的经验。首先,给项目添加一个 macOS target,然后重用在 iOS app 中创建的共享数据。当所有资源都准备好后,你就可以通过创建 SwiftUI 视图,在 macOS 上显示详细信息和列表视图。
下载项目文件并按照以下步骤操作,也可以打开已完成的项目自行浏览代码。
- 预计完成时间:25 分钟
- 项目文件:下载
1. 添加一个 macOS Target
首先要给项目添加一个 macOS target。用 Xcode 给 macOS app 添加新的目录和一组初始文件,以及构建和运行该应用程序需要的 scheme。
1.1 选择 File
> New
> Target
。出现模版选单后,选择 macOS 栏目,选中 App
模版然后点击 Next
。
这个模版会添加一个新的 macOS app target 到项目中。
1.2 在选单中,Product Name 输入 MacLandmarks
。把 Language 设置成 Swift
,把 User Interface 设置成 SwiftUI
,然后点击 Finish
。
1.3 将 scheme 设置成 MacLandmarks
> My Mac
。
将 scheme 设置成 My Mac
之后,你就可以预览、构建和运行这个 macOS app 了。
接下来要构建的 app 依赖于低版本的 macOS 所不具备的某些功能,因此你需要更改 Deployment Target。
1.4 在项目导航栏中,选择顶部的 Xcode 项目,选择 target 下面的 MacLandmarks
,然后将 Deployment Target 设置成 10.15.3
。
1.5 在 MacLandmarks
目录中,选中 ContentView.swift
,打开 Canvas,然后点击 Resume
来观察预览。
和 iOS app 一样,SwiftUI 提供了默认的主视图及其 preview provider,让我们可以预览 app 的主窗口。
ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}
2. 共享数据和资源
接下来,我们会从 iOS app 中重用模型和资源文件,并在 macOS target 中共享。
2.1 在项目导航中,打开 Landmarks 目录并选中 Models
和 Resources
中所有的文件。
landmarkData.json
文件包含在这个教程的初始项目中,它给每个 landmark 包含一个新的描述字段,这在以前的教程中是没有的。
2.2 在文件检查器中,将刚才选中文件的 Target Membership 设置成 MacLandmarks
。
在构建视图时,app 需要访问这些共享资源。
为了使用新的描述字段,我们需要给 Landmark 结构体添加一个新的对应字段。
2.3 打开 Landmark.swift
文件,添加一个新的描述属性。
由于基于 Codable
协议来加载数据,因此只需要确保属性名称与 JSON 中用于加载新数据的名称一致就可以了。
Landmark.swift
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 isFavorite: Bool
var isFeatured: Bool
var description: String //
var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}
var featureImage: Image? {
guard isFeatured else { return nil }
return Image(
ImageStore.loadImage(name: "\(imageName)_feature"),
scale: 2,
label: Text(name))
}
enum Category: String, CaseIterable, Codable, Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
}
}
extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}
struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}
3. 创建行视图
使用 SwiftUI 的时候,通常从下至上构建视图。先创建较小的视图,然后将其组装为较大的视图。
首先,为 macOS 定义列表的单行的布局。该行包含 landmark 的名称、它的位置、一个图片以及表示这个 landmark 是否被收藏的可选标记。
3.1 给 MacLandmarks
目录添加一个新的 SwiftUI 视图,起名叫做 LandmarkRow.swift
。
这个视图和 iOS app 中一个的文件重名,但每个文件都有一个仅包含对应 app 的 target membership,这样就可以避免文件冲突。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var body: some View {
Text("Hello, World!")
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow()
}
}
3.2 给 LandmarkRow
结构体添加一个 landmark
属性,然后给 preview provider 添加一个 landmark
用来显示。
LandmarkRow.swift
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]) //
}
}
3.3 用以一个水平 stack 来替换掉占位符,它用来绘制 landmark 的图片。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
//
HStack(alignment: .center) {
landmark.image
.resizable()
.aspectRatio(1.0, contentMode: .fit)
.frame(width: 32, height: 32)
.fixedSize(horizontal: true, vertical: false)
.cornerRadius(4.0)
}
//
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}
3.4 添加关于 landmark 的文字,然后组合到一个竖直 stack 中。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack(alignment: .center) {
landmark.image
.resizable()
.aspectRatio(1.0, contentMode: .fit)
.frame(width: 32, height: 32)
.fixedSize(horizontal: true, vertical: false)
.cornerRadius(4.0)
//
VStack(alignment: .leading) {
Text(landmark.name)
.fontWeight(.bold)
.truncationMode(.tail)
.frame(minWidth: 20)
Text(landmark.park)
.font(.caption)
.opacity(0.625)
.truncationMode(.middle)
}
//
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}
3.5 给视图添加一个收藏指示器,然后通过一个 spacer
和已有的内容隔开。
spacer
可以将已有的内容推到左边,但是需要出现在右边的指示器现在还看不到,因为我们还没有把对应的图片资源添加到 app 中。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack(alignment: .center) {
landmark.image
.resizable()
.aspectRatio(1.0, contentMode: .fit)
.frame(width: 32, height: 32)
.fixedSize(horizontal: true, vertical: false)
.cornerRadius(4.0)
VStack(alignment: .leading) {
Text(landmark.name)
.fontWeight(.bold)
.truncationMode(.tail)
.frame(minWidth: 20)
Text(landmark.park)
.font(.caption)
.opacity(0.625)
.truncationMode(.middle)
}
//
Spacer()
if landmark.isFavorite {
Image("star-filled")
.resizable()
.renderingMode(.template)
.foregroundColor(.yellow)
.frame(width: 10, height: 10)
}
//
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}
3.6 在下载的项目资源目录中,把 star-filled.pdf
和 star-empty.pdf
文件拖拽到 Mac app 的资源文件夹中。
3.7 给行视图的内容添加一个竖直 padding,用来显示一个星星并填充黄色来表示收藏。
之后将多个行视图放在一个列表中时,padding 能提高可读性。
LandmarkRow.swift
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack(alignment: .center) {
landmark.image
.resizable()
.aspectRatio(1.0, contentMode: .fit)
.frame(width: 32, height: 32)
.fixedSize(horizontal: true, vertical: false)
.cornerRadius(4.0)
VStack(alignment: .leading) {
Text(landmark.name)
.fontWeight(.bold)
.truncationMode(.tail)
.frame(minWidth: 20)
Text(landmark.park)
.font(.caption)
.opacity(0.625)
.truncationMode(.middle)
}
Spacer()
if landmark.isFavorite {
Image("star-filled")
.resizable()
.renderingMode(.template)
.foregroundColor(.yellow)
.frame(width: 10, height: 10)
}
}
.padding(.vertical, 4) //
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}
4. 将行视图组合到列表中
通过使用上一步定义的行视图,我们可以创建一个列表来给用户展示所有的已知 landmark。当 UserData
中的 showFavoritesOnly
属性为 true
时,我们限制只显示被收藏的 landmark。
4.1 在构建中添加一个新的 SwiftUI 视图,起名为 LandmarkList.swift
。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
var body: some View {
Text("Hello, World!")
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
4.2 添加一个 userData
属性作为 environment object,然后更新 preview provider。
这样视图就可以访问描述 landmark 的全局 UserData
了。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
@EnvironmentObject private var userData: UserData //
var body: some View {
Text("Hello, World!")
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData()) //
}
}
4.3 创建一个列表,用来持有我们在 LandmarkRow
中创建的行视图。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
@EnvironmentObject private var userData: UserData
var body: some View {
//
List {
ForEach(userData.landmarks) { landmark in
LandmarkRow(landmark: landmark)
}
}
//
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}
4.4 为了让行视图可选中,我们需要为列表提供一个可选的选中 landmark 的 binding,并用 landmark 来标记它。
之后,我们会使用选中的 landmark 来驱动详细视图的内容。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
@EnvironmentObject private var userData: UserData
@Binding var selectedLandmark: Landmark? //
var body: some View {
List(selection: $selectedLandmark) { //
ForEach(userData.landmarks) { landmark in
LandmarkRow(landmark: landmark).tag(landmark) //
}
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList(selectedLandmark: .constant(landmarkData[0])) //
.environmentObject(UserData())
}
}
4.5 根据 showFavoritesOnly
属性的状态,以及收藏 landmark 的状态的组合来限制行视图的创建。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
@EnvironmentObject private var userData: UserData
@Binding var selectedLandmark: Landmark?
var body: some View {
List(selection: $selectedLandmark) {
ForEach(userData.landmarks) { landmark in
//
if (!self.userData.showFavoritesOnly || landmark.isFavorite) {
LandmarkRow(landmark: landmark).tag(landmark)
}
//
}
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList(selectedLandmark: .constant(landmarkData[0]))
.environmentObject(UserData())
}
}
5. 创建一个过滤器来管理列表
因为用户可以将一个 landmark 标记为收藏,所以我们需要提供一个方法只显示它们收藏的 landmark。创建一个过滤视图,它使用一个 Toggle
为用户提供一个复选框,用户可以单击该复选框打开或关闭过滤。
为了让用户快速缩小自己收藏的 landmark 列表的范围,我们会添加一个 Picker
来创建一个弹出按钮,用户可以根据自己设置的任何分类来过滤自己的收藏。
5.1 给构建添加一个新的 SwiftUI 视图,起名 Filter.swift
。
Filter.swift
import SwiftUI
struct Filter: View {
var body: some View {
Text("Hello, World!")
}
}
struct Filter_Previews: PreviewProvider {
static var previews: some View {
Filter()
}
}
5.2 添加一个 userData
属性作为 environment object,然后更新 preview provider。
Filter.swift
import SwiftUI
struct Filter: View {
@EnvironmentObject private var userData: UserData //
var body: some View {
Text("Hello, World!")
}
}
struct Filter_Previews: PreviewProvider {
static var previews: some View {
Filter()
.environmentObject(UserData()) //
}
}
5.3 将默认的文本替换成绑定到 showFavoritesOnly
布尔值的 toggle,并为其指定适当的 label。
当用户修改这个 toggle,列表视图会自动刷新。因为它绑定到了环境中的相同的 showFavoritesOnly
值。
Filter.swift
import SwiftUI
struct Filter: View {
@EnvironmentObject private var userData: UserData
var body: some View {
//
HStack {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}
}
//
}
}
struct Filter_Previews: PreviewProvider {
static var previews: some View {
Filter()
.environmentObject(UserData())
}
}
我们可以使用 landmark 分类信息来定义其他过滤。
5.4 创建一个 FilterType
来持有一个 landmark 的分类和对应的名字。
保证它符合 Hashable
协议,我们就可以将这个 FilterType
作为一个 picker 的选项,同时用名字作为选项的说明。
Filter.swift
import SwiftUI
struct Filter: View {
@EnvironmentObject private var userData: UserData
var body: some View {
HStack {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}
}
}
}
struct Filter_Previews: PreviewProvider {
static var previews: some View {
Filter()
.environmentObject(UserData())
}
}
//
struct FilterType: Hashable {
var name: String
var category: Landmark.Category?
init(_ category: Landmark.Category) {
self.name = category.rawValue
self.category = category
}
}
//
5.5 定义一个全部类型来表示不需要过滤。
这个额外类型需要一个新的初始化方法来处理 nil 类别的特殊情况。
Filter.swift
import SwiftUI
struct Filter: View {
@EnvironmentObject private var userData: UserData
var body: some View {
HStack {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}
}
}
}
struct Filter_Previews: PreviewProvider {
static var previews: some View {
Filter()
.environmentObject(UserData())
}
}
struct FilterType: Hashable {
var name: String
var category: Landmark.Category?
init(_ category: Landmark.Category) {
self.name = category.rawValue
self.category = category
}
//
init(name: String) {
self.name = name
self.category = nil
}
static var all = FilterType(name: "All")
//
}
遵循 CaseIterable
和 Identifiable
协议可以将 FilterType
结构体作为 ForEach
初始化方法中的数据,这样我们就可以将它添加到后续两步中。
5.6 实现 CaseIterable
协议,提供一个列表来表示所有可能的情况。
Filter.swift
import SwiftUI
struct Filter: View {
@EnvironmentObject private var userData: UserData
var body: some View {
HStack {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}
}
}
}
struct Filter_Previews: PreviewProvider {
static var previews: some View {
Filter()
.environmentObject(UserData())
}
}
struct FilterType: CaseIterable, Hashable { //
var name: String
var category: Landmark.Category?
init(_ category: Landmark.Category) {
self.name = category.rawValue
self.category = category
}
init(name: String) {
self.name = name
self.category = nil
}
static var all = FilterType(name: "All")
//
static var allCases: [FilterType] {
return [.all] + Landmark.Category.allCases.map(FilterType.init)
}
//
}
5.7 实现 Identifiable
协议,定义 id
属性。
Filter.swift
import SwiftUI
struct Filter: View {
@EnvironmentObject private var userData: UserData
var body: some View {
HStack {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}
}
}
}
struct Filter_Previews: PreviewProvider {
static var previews: some View {
Filter()
.environmentObject(UserData())
}
}
struct FilterType: CaseIterable, Hashable, Identifiable { //
var name: String
var category: Landmark.Category?
init(_ category: Landmark.Category) {
self.name = category.rawValue
self.category = category
}
init(name: String) {
self.name = name
self.category = nil
}
static var all = FilterType(name: "All")
static var allCases: [FilterType] {
return [.all] + Landmark.Category.allCases.map(FilterType.init)
}
//
var id: FilterType {
return self
}
//
}
5.8 在过滤视图中,添加一个 picker,它使用 FilterType
实例的 binding 作为选项,使用 FilterType
的名字作为菜单的选择。
对 FilterType
实例使用 binding,可以让该视图的父视图观察用户的选择。
Filter.swift
import SwiftUI
struct Filter: View {
@EnvironmentObject private var userData: UserData
@Binding var filter: FilterType //
var body: some View {
HStack {
//
Picker(selection: $filter, label: EmptyView()) {
ForEach(FilterType.allCases) { choice in
Text(choice.name).tag(choice)
}
}
Spacer()
//
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}
}
}
}
struct Filter_Previews: PreviewProvider {
static var previews: some View {
Filter(filter: .constant(.all)) //
.environmentObject(UserData())
}
}
struct FilterType: CaseIterable, Hashable, Identifiable {
var name: String
var category: Landmark.Category?
init(_ category: Landmark.Category) {
self.name = category.rawValue
self.category = category
}
init(name: String) {
self.name = name
self.category = nil
}
static var all = FilterType(name: "All")
static var allCases: [FilterType] {
return [.all] + Landmark.Category.allCases.map(FilterType.init)
}
var id: FilterType {
return self
}
}
5.9 返回上一节中的列表视图,添加一个 FilterType
的 binding。
与过滤器视图一样,将会与父视图共享。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
@EnvironmentObject private var userData: UserData
@Binding var selectedLandmark: Landmark?
@Binding var filter: FilterType //
var body: some View {
List(selection: $selectedLandmark) {
ForEach(userData.landmarks) { landmark in
if (!self.userData.showFavoritesOnly || landmark.isFavorite) {
LandmarkRow(landmark: landmark).tag(landmark)
}
}
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
//
LandmarkList(selectedLandmark: .constant(landmarkData[0]),
filter: .constant(.all))
//
.environmentObject(UserData())
}
}
5.10 更新限制创建行视图的逻辑,加入分类的过滤。
查找 landmark 的分类来匹配选中的分类,或在用户选择特色分类时查找任何特色地标。
LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
@EnvironmentObject private var userData: UserData
@Binding var selectedLandmark: Landmark?
@Binding var filter: FilterType
var body: some View {
List(selection: $selectedLandmark) {
ForEach(userData.landmarks) { landmark in
//
if (!self.userData.showFavoritesOnly || landmark.isFavorite)
&& (self.filter == .all
|| self.filter.category == landmark.category
|| (self.filter.category == .featured && landmark.isFeatured)) {
//
LandmarkRow(landmark: landmark).tag(landmark)
}
}
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList(selectedLandmark: .constant(landmarkData[0]),
filter: .constant(.all))
.environmentObject(UserData())
}
}
6. 组合列表和过滤视图
创建一个组合了过过滤器和列表的主视图。将 landmark 选项绑定到主视图的父视图的同时,为过滤器提供新的状态信息。
6.1 在项目中创建一个新的 SwiftUI 视图,起名 NavigationMaster.swift
。
NavigationMaster.swift
import SwiftUI
struct NavigationMaster: View {
var body: some View {
Text("Hello, World!")
}
}
struct NavigationMaster_Previews: PreviewProvider {
static var previews: some View {
NavigationMaster()
}
}
6.2 声明过滤器的状态。
在这添加状态会使此视图成为该信息的真相来源。在接下来的几步中,我们会将此属性绑定到过滤器视图和列表视图。
NavigationMaster.swift
import SwiftUI
struct NavigationMaster: View {
@State private var filter: FilterType = .all //
var body: some View {
Text("Hello, World!")
}
}
struct NavigationMaster_Previews: PreviewProvider {
static var previews: some View {
NavigationMaster()
}
}
6.3 添加过滤视图,并将它绑定到过滤器状态上。
此时 preview 会构建失败。因为过滤器依赖环境中的用户信息对象,我们会在下一步修复这个问题。
NavigationMaster.swift
import SwiftUI
struct NavigationMaster: View {
@State private var filter: FilterType = .all
var body: some View {
//
VStack {
Filter(filter: $filter)
.controlSize(.small)
.padding([.top, .leading], 8)
.padding(.trailing, 4)
}
//
}
}
struct NavigationMaster_Previews: PreviewProvider {
static var previews: some View {
NavigationMaster()
}
}
6.4 在环境中添加 UserData
对象。
尽管导航主视图不需要直接的 UserData
,但子视图却需要。要启用 preview,需要请将 UserData
作为环境对象提供给导航主视图。
NavigationMaster.swift
import SwiftUI
struct NavigationMaster: View {
@State private var filter: FilterType = .all
var body: some View {
VStack {
Filter(filter: $filter)
.controlSize(.small)
.padding([.top, .leading], 8)
.padding(.trailing, 4)
}
}
}
struct NavigationMaster_Previews: PreviewProvider {
static var previews: some View {
NavigationMaster()
.environmentObject(UserData()) //
}
}
6.5 给选中的 landmark 添加一个 binding。
NavigationMaster.swift
import SwiftUI
struct NavigationMaster: View {
@Binding var selectedLandmark: Landmark? //
@State private var filter: FilterType = .all
var body: some View {
VStack {
Filter(filter: $filter)
.controlSize(.small)
.padding([.top, .leading], 8)
.padding(.trailing, 4)
}
}
}
struct NavigationMaster_Previews: PreviewProvider {
static var previews: some View {
NavigationMaster(selectedLandmark: .constant(landmarkData[1])) //
.environmentObject(UserData())
}
}
6.6 添加 landmark 列表视图,将它绑定到选中的 landmark 和过滤器的状态上。
preview 选择了列表中的第二项,因为我们提供了 landmarkData[1] 作为 selectedLandmark
的输入。
NavigationMaster.swift
import SwiftUI
struct NavigationMaster: View {
@Binding var selectedLandmark: Landmark?
@State private var filter: FilterType = .all
var body: some View {
VStack {
Filter(filter: $filter)
.controlSize(.small)
.padding([.top, .leading], 8)
.padding(.trailing, 4)
//
LandmarkList(
selectedLandmark: $selectedLandmark,
filter: $filter
)
.listStyle(SidebarListStyle())
//
}
}
}
struct NavigationMaster_Previews: PreviewProvider {
static var previews: some View {
NavigationMaster(selectedLandmark: .constant(landmarkData[1]))
.environmentObject(UserData())
}
}
6.7 约束导航视图的宽度,防止用户让它过宽或过窄。
NavigationMaster.swift
import SwiftUI
struct NavigationMaster: View {
@Binding var selectedLandmark: Landmark?
@State private var filter: FilterType = .all
var body: some View {
VStack {
Filter(filter: $filter)
.controlSize(.small)
.padding([.top, .leading], 8)
.padding(.trailing, 4)
LandmarkList(
selectedLandmark: $selectedLandmark,
filter: $filter
)
.listStyle(SidebarListStyle())
}
.frame(minWidth: 225, maxWidth: 300) //
}
}
struct NavigationMaster_Previews: PreviewProvider {
static var previews: some View {
NavigationMaster(selectedLandmark: .constant(landmarkData[1]))
.environmentObject(UserData())
}
}
7. 准备重用的 CircleImage
有时我们仅需进行少量修改就能跨平台共享视图。在为 macOS 构建 landmark 详细视图时,我们会重用为 iOS 创建的 CircleImage
。为了满足 macOS 的不同布局要求,我们会添加一个参数来控制阴影半径。
7.1 在项目导航中,选择 Landmarks
> Supporting Views
,然后选中 CircleImage.swift
文件。
7.2 把 CircleImage.swift
文件添加到 MacLandmarks
target 中。
7.3 中 CircleImage.swift
中,修改结构体,添加一个新的阴影半径参数。
通过给新参数提供与以前的常量相同的默认值,可以确保 CircleImage
在现有客户端(如 iOS 和 watchOS app)在不做任何修改的情况下仍能像以前一样运行。
CircleImage.swift
import SwiftUI
struct CircleImage: View {
var image: Image
var shadowRadius: CGFloat = 10 //
var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: shadowRadius) //
}
}
struct CircleImage_Previews: PreviewProvider {
static var previews: some View {
CircleImage(image: Image("turtlerock"))
}
}
8 在 macOS 上展开 Map View
和圆形视图一样,我们会在 macOS 上重用 MapView
。但是, MapView
需要更大量的更新,因为它对 MapKit
的使用依赖于集成 UIKit
框架,在 macOS 中使用 MapKit
则需要集成 AppKit
框架。因此我们会添加一个编译时指令,为给定 target 提供正确的集成。
8.1 在项目导航中,选择 Landmarks
> Supporting Views
,然后选中 MapView.swift
文件。
8.2 将 MapView.swift
文件添加到 MacLandmarks
target 中。
此时 Xcode 会报错,因为地图视图使用了 UIViewRepresentable
,但它在 macOS SDK 中不支持。在下面但几步中,我们会在合适的时候使用 NSViewRepresentable
来展开这个视图。
8.3 插入可创建平台特定行为区域的编译指令。
我们会使用编译指令的两个分支来区分 UIViewRepresentable
和 NSViewRepresentable
协议。
MapView.swift
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)
}
}
//
#if os(macOS)
#else
#endif
//
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(coordinate: landmarkData[0].locationCoordinate)
}
}
8.4 将由 makeUIView
和 updateUIView
方法组成的 UIViewRepresentable
协议移动到适当的编译指令分支中的扩展中,这样就不用修改 MapKit
的实际交互。
此时 Xcode 仍报告使用未声明类型 Context
的错误。我们会在下一步中添加 NSViewRepresentable
协议来解决这个问题。
MapView.swift
import SwiftUI
import MapKit
struct MapView { //
var coordinate: CLLocationCoordinate2D
func makeMapView() -> MKMapView { //
MKMapView(frame: .zero)
}
func updateMapView(_ 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)
}
}
#if os(macOS)
#else
//
extension MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
makeMapView()
}
func updateUIView(_ uiView: MKMapView, context: Context) {
updateMapView(uiView, context: context)
}
}
//
#endif
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(coordinate: landmarkData[0].locationCoordinate)
}
}
8.5 添加与 UIViewRepresentable
对应的 NSViewRepresentable
。
与 UIViewRepresentable
一样, NSViewRepresentable
依赖于完成上一步后剩下的通用功能。
MapView.swift
import SwiftUI
import MapKit
struct MapView {
var coordinate: CLLocationCoordinate2D
func makeMapView() -> MKMapView {
MKMapView(frame: .zero)
}
func updateMapView(_ 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)
}
}
#if os(macOS)
//
extension MapView: NSViewRepresentable {
func makeNSView(context: Context) -> MKMapView {
makeMapView()
}
func updateNSView(_ nsView: MKMapView, context: Context) {
updateMapView(nsView, context: context)
}
}
//
#else
extension MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
makeMapView()
}
func updateUIView(_ uiView: MKMapView, context: Context) {
updateMapView(uiView, context: context)
}
}
#endif
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(coordinate: landmarkData[0].locationCoordinate)
}
}
9. 构建详情视图
详情视图显示选中 landmark 的相关信息。我们会创建一个想 iOS app 一样的视图,但是不同平台有不同的数据展示方式。
我们会裁剪详情视图来适配 macOS,并且重用一些前两节准备的视图。
9.1 给项目添加一个新的视图,叫做 NavigationDetail.swift
,并给它添加一个 landmark
属性。
实例化详情视图的视图会使用此属性指明要显示的 landmark。
NavigationDetail.swift
import SwiftUI
struct NavigationDetail: View {
var landmark: Landmark //
var body: some View {
Text("Hello, World!")
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0]) //
}
}
9.2 创建一个包含竖直 stack 的滚动视图,同时又包含一个水平 stack,用于显示 CircleImage
和有关 landmark 的文本。
通过设置垂直 stack 的最大宽度,可以确保其所有内容的宽度保持在合适的阅读范围内。
NavigationDetail.swift
import SwiftUI
struct NavigationDetail: View {
var landmark: Landmark
var body: some View {
//
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 24) {
CircleImage(image: landmark.image)
VStack(alignment: .leading) {
Text(landmark.name).font(.title)
Text(landmark.park)
Text(landmark.state)
}
.font(.caption)
}
}
.padding()
.frame(maxWidth: 700)
}
//
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0])
}
}
尽管跨平台重用视图很方便,但我们仍需要自定义 CircleImage
视图来适配此布局。
9.3 通过让输入的图像可调整大小,并约束视图的 frame,我们可以减小 CircleImage
的大小来匹配关联的文本块。
在这之后,我们就不再需要修改相关的 CircleImage
来。
NavigationDetail.swift
import SwiftUI
struct NavigationDetail: View {
var landmark: Landmark
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 24) {
//
CircleImage(image: landmark.image.resizable())
.frame(width: 160, height: 160)
//
VStack(alignment: .leading) {
Text(landmark.name).font(.title)
Text(landmark.park)
Text(landmark.state)
}
.font(.caption)
}
}
.padding()
.frame(maxWidth: 700)
}
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0])
}
}
9.4 调整阴影半径来适配更小的图片。
此修改通过 CircleImage
添加到项目时引入的参数来设置。
NavigationDetail.swift
import SwiftUI
struct NavigationDetail: View {
var landmark: Landmark
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 24) {
CircleImage(image: landmark.image.resizable(), shadowRadius: 4) //
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
Text(landmark.name).font(.title)
Text(landmark.park)
Text(landmark.state)
}
.font(.caption)
}
}
.padding()
.frame(maxWidth: 700)
}
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0])
}
}
我们可以使用按钮来控制用户是否将 landmark 标记为收藏。如果要修改,必须访问存储在 UserData
对象中的单个实质来源。
9.5 添加 UserData
作为一个环境对象,并基于当前选择的 landmark 在存储的 landmark 中创建索引。
NavigationDetail.swift
import SwiftUI
struct NavigationDetail: 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(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 24) {
CircleImage(image: landmark.image.resizable(), shadowRadius: 4)
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
Text(landmark.name).font(.title)
Text(landmark.park)
Text(landmark.state)
}
.font(.caption)
}
}
.padding()
.frame(maxWidth: 700)
}
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}
9.6 添加一个按钮,与 landmark 名称水平对齐。使用行视图中的同一星形图像,它可以切换 landmark 的 isFavorite
属性。
对 landmark 进行更改后,需要在 UserData
中查找 landmark,并将所做的更改持久保存在数据存储中。
NavigationDetail.swift
import SwiftUI
struct NavigationDetail: 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(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 24) {
CircleImage(image: landmark.image.resizable(), shadowRadius: 4)
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
//
HStack {
Text(landmark.name).font(.title)
Button(action: {
self.userData.landmarks[self.landmarkIndex]
.isFavorite.toggle()
}) {
if userData.landmarks[self.landmarkIndex].isFavorite {
Image("star-filled")
.resizable()
.renderingMode(.template)
.foregroundColor(.yellow)
.accessibility(label: Text("Remove from favorites"))
} else {
Image("star-empty")
.resizable()
.renderingMode(.template)
.foregroundColor(.gray)
.accessibility(label: Text("Add to favorites"))
}
}
.frame(width: 20, height: 20)
.buttonStyle(PlainButtonStyle())
}
//
Text(landmark.park)
Text(landmark.state)
}
.font(.caption)
}
}
.padding()
.frame(maxWidth: 700)
}
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}
9.7 在分隔线下方,使用新的描述字段添加有关 landmark 的更多信息。
标题栏移向了 preview 的头部,因为新内容使封闭的垂直 stack 变宽,但最多不超过先前指定的最大 frame 的大小。
NavigationDetail.swift
import SwiftUI
struct NavigationDetail: 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(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 24) {
CircleImage(image: landmark.image.resizable(), shadowRadius: 4)
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
HStack {
Text(landmark.name).font(.title)
Button(action: {
self.userData.landmarks[self.landmarkIndex]
.isFavorite.toggle()
}) {
if userData.landmarks[self.landmarkIndex].isFavorite {
Image("star-filled")
.resizable()
.renderingMode(.template)
.foregroundColor(.yellow)
.accessibility(label: Text("Remove from favorites"))
} else {
Image("star-empty")
.resizable()
.renderingMode(.template)
.foregroundColor(.gray)
.accessibility(label: Text("Add to favorites"))
}
}
.frame(width: 20, height: 20)
.buttonStyle(PlainButtonStyle())
}
Text(landmark.park)
Text(landmark.state)
}
.font(.caption)
}
//
Divider()
Text("About \(landmark.name)")
.font(.headline)
Text(landmark.details)
.lineLimit(nil)
//
}
.padding()
.frame(maxWidth: 700)
}
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}
9.8 在详情视图的顶部插入地图,然后将其他内容向上偏移到稍微重叠。
占据视图整个宽度的地图将详情文本推到 preview 底部下方,但仍然存在。
NavigationDetail.swift
import SwiftUI
struct NavigationDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark
var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
ScrollView {
//
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 250)
//
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 24) {
CircleImage(image: landmark.image.resizable(), shadowRadius: 4)
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
HStack {
Text(landmark.name).font(.title)
Button(action: {
self.userData.landmarks[self.landmarkIndex]
.isFavorite.toggle()
}) {
if userData.landmarks[self.landmarkIndex].isFavorite {
Image("star-filled")
.resizable()
.renderingMode(.template)
.foregroundColor(.yellow)
.accessibility(label: Text("Remove from favorites"))
} else {
Image("star-empty")
.resizable()
.renderingMode(.template)
.foregroundColor(.gray)
.accessibility(label: Text("Add to favorites"))
}
}
.frame(width: 20, height: 20)
.buttonStyle(PlainButtonStyle())
}
Text(landmark.park)
Text(landmark.state)
}
.font(.caption)
}
Divider()
Text("About \(landmark.name)")
.font(.headline)
Text(landmark.details)
.lineLimit(nil)
}
.padding()
.frame(maxWidth: 700)
.offset(x: 0, y: -50) //
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}
9.9 添加一个“Open in Maps”按钮,单击会将 Maps app 打开到该位置。
import SwiftUI
struct NavigationDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark
var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
ScrollView {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 250)
//
Button("Open in Maps") {
let destination = MKMapItem(placemark: MKPlacemark(coordinate: self.landmark.locationCoordinate))
destination.name = self.landmark.name
destination.openInMaps()
}
//
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 24) {
CircleImage(image: landmark.image.resizable(), shadowRadius: 4)
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
HStack {
Text(landmark.name).font(.title)
Button(action: {
self.userData.landmarks[self.landmarkIndex]
.isFavorite.toggle()
}) {
if userData.landmarks[self.landmarkIndex].isFavorite {
Image("star-filled")
.resizable()
.renderingMode(.template)
.foregroundColor(.yellow)
.accessibility(label: Text("Remove from favorites"))
} else {
Image("star-empty")
.resizable()
.renderingMode(.template)
.foregroundColor(.gray)
.accessibility(label: Text("Add to favorites"))
}
}
.frame(width: 20, height: 20)
.buttonStyle(PlainButtonStyle())
}
Text(landmark.park)
Text(landmark.state)
}
.font(.caption)
}
Divider()
Text("About \(landmark.name)")
.font(.headline)
Text(landmark.details)
.lineLimit(nil)
}
.padding()
.frame(maxWidth: 700)
.offset(x: 0, y: -50)
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}
9.10 将“Open in Maps”按钮放置在 overlay 中,使其显示在地图的右下角。
NavigationDetail.swift
import SwiftUI
import MapKit
struct NavigationDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark
var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
ScrollView {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 250)
//
.overlay(
GeometryReader { proxy in
Button("Open in Maps") {
let destination = MKMapItem(placemark: MKPlacemark(coordinate: self.landmark.locationCoordinate))
destination.name = self.landmark.name
destination.openInMaps()
}
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .bottomTrailing)
.offset(x: -10, y: -10)
}
)
//
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 24) {
CircleImage(image: landmark.image.resizable(), shadowRadius: 4)
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
HStack {
Text(landmark.name).font(.title)
Button(action: {
self.userData.landmarks[self.landmarkIndex]
.isFavorite.toggle()
}) {
if userData.landmarks[self.landmarkIndex].isFavorite {
Image("star-filled")
.resizable()
.renderingMode(.template)
.foregroundColor(.yellow)
.accessibility(label: Text("Remove from favorites"))
} else {
Image("star-empty")
.resizable()
.renderingMode(.template)
.foregroundColor(.gray)
.accessibility(label: Text("Add to favorites"))
}
}
.frame(width: 20, height: 20)
.buttonStyle(PlainButtonStyle())
}
Text(landmark.park)
Text(landmark.state)
}
.font(.caption)
}
Divider()
Text("About \(landmark.name)")
.font(.headline)
Text(landmark.details)
.lineLimit(nil)
}
.padding()
.frame(maxWidth: 700)
.offset(x: 0, y: -50)
}
}
}
struct NavigationDetail_Previews: PreviewProvider {
static var previews: some View {
NavigationDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}
10. 组合主视图和详情视图
现在我们已经构建了所有组件视图,接下来通过将主视图和详情视图合并到内容视图中来完善 app。
10.1 在 MacLandmarks
目录中,选中 ContentView.swift
文件。
ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}
10.2 给选中的 landmark 添加一个属性并用 @State
修饰。
给选中的 landmark 使用可选值可以避免设置默认值。这意味着 app 的 preview 和初始状态都将在没有选中 landmark 的情况下呈现。
ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var selectedLandmark: Landmark? //
var body: some View {
Text("Hello, World!")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}
10.3 添加 UserData
作为环境对象。
内容视图不直接依赖于环境中的 UserData
,但是稍后添加的某些子视图会依赖。为了使 preview 工作和编译成功,内容视图需要获取用户 UserData
。
ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var selectedLandmark: Landmark?
var body: some View {
Text("Hello, World!")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(UserData()) //
}
}
10.4 在 AppDelegate.swift
文件中,为内容视图提供环境对象,让添加到内容视图的子视图能正确编译。
AppDelegate.swift
func applicationDidFinishLaunching(_ aNotification: Notification) {
let contentView = ContentView().environmentObject(UserData()) //
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
}
10.5 将导航视图添加为内容视图中的顶级项目,并限制为最小大小。
ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var selectedLandmark: Landmark?
var body: some View {
NavigationView {
Text("Hello, World!")
}
.frame(minWidth: 700, minHeight: 300) //
}
}
struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(UserData())
}
}
10.6 添加绑定到所选 landmark 的主视图。
当用户在列表视图中进行选择时,该选择会传递到此视图中的 selectedLandmark
属性中。
ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var selectedLandmark: Landmark?
var body: some View {
NavigationView {
NavigationMaster(selectedLandmark: $selectedLandmark) //
}
.frame(minWidth: 700, minHeight: 300)
}
}
struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(UserData())
}
}
10.7 添加详情视图。
详情视图带有一个非可选的 landmark,因此在传递给详情视图之前,必须确保该值不为 nil
。在用户进行选择之前,不会显示详情视图,这就是为什么此步骤与上一步中对 preview 看起来一样的原因。
ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var selectedLandmark: Landmark?
var body: some View {
NavigationView {
NavigationMaster(selectedLandmark: $selectedLandmark)
//
if selectedLandmark != nil {
NavigationDetail(landmark: selectedLandmark!)
}
//
}
.frame(minWidth: 700, minHeight: 300)
}
}
struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(UserData())
}
}
10.8 构建并运行 app。
尝试更改过滤器设置,或者在详情视图中单击特定 landmark 的收藏指示符,查看内容是如何响应变化。