第97天 项目 19 第二部分
今天我们要实现程序的前半部分,这意味着我们将获取一个滑雪胜地列表、一个用于显示更多信息的详情视图,以及一个能将它们并排显示的NavigationSplitView。这本身对你来说应该不成问题,但在此过程中,你还将学习如何从资源包中加载静态示例数据、控制NavigationSplitView在iPhone上应如何显示主视图和副视图,甚至如何更整齐地格式化字符串列表。
尽管你已经掌握了使这段代码运行所需的大部分知识,但在第97天的今天,我们仍然会介绍一些新的知识点。希望你不会因此感到气馁——学习是一项重要的技能,无论是在编程领域还是其他许多行业,你都可能在整个职业生涯中不断尝试新事物。有时候这可能会让人觉得困难,但正如西班牙画家巴勃罗·毕加索曾经说过的:“我总是在做我不能做的事,以便我能学会如何去做。”
所以,继续学习吧,并且为自己“仍在学习”而自豪——这是一项你值得拥有的重要技能!
今天你需要学习四个主题,通过这些主题,你将了解如何构建主视图和副视图、在iPad上并排显示它们、学习一种改进的列表格式化方法等等。
- 构建项目的主列表
- 在iPad上显示默认详情视图
- 为滑雪胜地创建详情视图
构建项目的主列表
作者:Paul Hudson 2024年3月17日
在这个应用中,我们要像苹果的“邮件”和“备忘录”应用那样,将两个视图并排显示。在SwiftUI中,实现这一功能的方法是将两个视图放入NavigationSplitView中,然后在主视图中使用NavigationLink来控制副视图中显示的内容。
因此,我们将从构建应用的主视图开始,该视图会显示所有滑雪胜地的列表,以及它们所属的国家和拥有的滑雪道数量——也就是你可以滑下的雪道数量,有时也被称为“trails”或简单称为“slopes”。
我已经在本书的GitHub仓库中为这个项目提供了一些资源文件,如果你还没有下载,请现在就去下载。你需要将resorts.json文件拖到项目导航器中,然后将所有图片复制到资源目录中。你可能会注意到,我为各国国旗提供了2倍和3倍分辨率的图片,但滑雪胜地的图片只提供了2倍分辨率的。这是有意为之的:这些国旗将用于视网膜屏(retina)和超视网膜屏(Super Retina)设备,而滑雪胜地的图片设计初衷是填满iPad Pro的整个屏幕——即使在超视网膜屏iPhone上以2倍分辨率显示,这些图片的尺寸也足够大。
为了快速搭建起列表,我们需要定义一个简单的Resort结构体,该结构体可以从JSON文件中加载数据。这意味着它需要遵循Codable协议,而为了在SwiftUI中更方便地使用它,我们还将让它同时遵循Hashable和Identifiable协议。
实际的数据大多是字符串和整数类型,但其中还有一个名为facilities的字符串数组,用于描述该滑雪胜地的其他配套设施——需要说明的是,这些数据大多是虚构的,所以不要尝试在实际应用中使用!
创建一个名为Resort.swift的新Swift文件,然后添加以下代码:
struct Resort: Codable, Hashable, Identifiable {
var id: String
var name: String
var country: String
var description: String
var imageCredit: String
var price: Int
var size: Int
var snowDepth: Int
var elevation: Int
var runs: Int
var facilities: [String]
}和往常一样,在模型中添加一个示例值是个不错的主意,这样在设计视图时就能更容易地展示可用数据。不过这次,需要处理的字段比较多,而且如果这些字段能有真实的数据会更有帮助,所以我不太想手动创建示例值。
相反,我们将从应用资源包中存储的JSON文件加载一个滑雪胜地数组,这意味着我们可以复用在第8个项目中编写的代码——即Bundle-Decodable.swift扩展。如果你还保留着这个文件,可以直接将其拖到新项目中;如果没有,就创建一个名为Bundle-Decodable.swift的新Swift文件,并添加以下代码:
extension Bundle {
func decode<T: Decodable>(_ file: String) -> T {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("未能在资源包中找到\(file)文件。")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("未能从资源包中加载\(file)文件。")
}
let decoder = JSONDecoder()
do {
return try decoder.decode(T.self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
fatalError("未能从资源包中解码\(file)文件,原因是缺少键'\(key.stringValue)'——\(context.debugDescription)")
} catch DecodingError.typeMismatch(_, let context) {
fatalError("未能从资源包中解码\(file)文件,原因是类型不匹配——\(context.debugDescription)")
} catch DecodingError.valueNotFound(let type, let context) {
fatalError("未能从资源包中解码\(file)文件,原因是缺少\(type)类型的值——\(context.debugDescription)")
} catch DecodingError.dataCorrupted(_) {
fatalError("未能从资源包中解码\(file)文件,因为该文件似乎是无效的JSON格式。")
} catch {
fatalError("未能从资源包中解码\(file)文件:\(error.localizedDescription)")
}
}
}有了这个扩展后,我们就可以在Resort结构体中添加一些属性来存储示例数据,这里有两种实现方式。第一种方式是添加两个静态属性:一个用于将所有滑雪胜地加载到数组中,另一个用于存储该数组的第一个元素,代码如下:
static let allResorts: [Resort] = Bundle.main.decode("resorts.json")
static let example = allResorts[0]第二种方式是将上述代码简化为一行。这需要进行一点简单的类型转换,因为我们的decode()扩展方法需要知道要解码的数据类型:
static let example = (Bundle.main.decode("resorts.json") as [Resort])[0]在这两种方式中,我更倾向于第一种,因为它更简洁,而且如果我们想展示随机示例而不是每次都展示同一个示例,它的实用性会更高。如果你好奇的话,当我们为属性使用static let时,Swift会自动将它们设置为延迟加载(lazy)——直到它们被使用时才会创建。这意味着当我们尝试读取Resort.example时,Swift会被迫先创建Resort.allResorts,然后将该数组的第一个元素作为Resort.example返回。这确保了这两个属性的创建顺序一定是正确的——不会出现因为allResorts尚未被调用而导致example无法获取的情况。
现在我们简单的Resort结构体已经完成,我们还可以使用同样的Bundle扩展在ContentView中添加一个属性,将所有滑雪胜地加载到一个数组中:
let resorts: [Resort] = Bundle.main.decode("resorts.json")对于视图的主体部分,我们将使用NavigationSplitView,并在其中放置一个List来显示所有滑雪胜地。在每一行中,我们将显示:
- 一个40x25尺寸的国旗图片,用于标识滑雪胜地所属国家。
- 滑雪胜地的名称。
- 该滑雪胜地拥有的滑雪道数量。
40x25的尺寸比我们的国旗源图片小,而且纵横比也不同,但我们可以通过使用resizable()、scaledToFill()和自定义框架来解决这个问题。为了让屏幕显示效果更好,我们还会使用自定义的裁剪形状和带边框的覆盖层。
当用户点击某一行时,我们会跳转到一个详情视图,显示该滑雪胜地的更多信息,但目前我们还没有构建这个详情视图,所以暂时先跳转到一个临时的文本视图作为占位。
将你当前的body属性替换为以下代码:
NavigationSplitView {
List(resorts) { resort in
NavigationLink(value: resort) {
HStack {
Image(resort.country)
.resizable()
.scaledToFill()
.frame(width: 40, height: 25)
.clipShape(
.rect(cornerRadius: 5)
)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(.black, lineWidth: 1)
)
VStack(alignment: .leading) {
Text(resort.name)
.font(.headline)
Text("\(resort.runs)条滑雪道")
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("滑雪胜地")
} detail: {
Text("详情")
}现在运行应用,你会发现效果还不错——记得在iPhone和iPad上分别测试 portrait(竖屏)和 landscape(横屏)两种模式。
接下来,让我们来完善这个详情视图……
在iPad上显示默认详情视图
作者:Paul Hudson 2024年3月17日
使用NavigationSplitView时,通常副视图会显示从侧边栏视图中选中的项目的相关信息。一般情况下,这种方式效果很好,但当应用首次启动时,副视图应该显示什么内容呢?
在iPhone上,这不会有问题,因为用户只会看到侧边栏视图;但在iPad上情况就更复杂了——根据屏幕方向的不同,用户首次启动应用时可能只能看到副视图。
一个简单的解决方案是创建一个小型视图,其中包含一些引导性说明,帮助用户开始使用应用。在这里,我们需要创建一个名为WelcomeView的新SwiftUI视图,然后添加以下代码:
struct WelcomeView: View {
var body: some View {
VStack {
Text("欢迎使用SnowSeeker!")
.font(.largeTitle)
Text("请从左侧菜单中选择一个滑雪胜地;从左边缘向右滑动即可显示菜单。")
.foregroundStyle(.secondary)
}
}
}这只是一个静态文本视图;它只会在应用首次启动时显示,因为一旦用户点击任何一个导航链接,它就会被用户要导航到的视图替换掉。
要将这个视图添加到ContentView中,使我们UI的两个部分能够并排使用,将我们之前添加的Text("详情")代码替换为以下内容:
WelcomeView()这样SwiftUI就能准确理解我们的需求了。尝试在不同的设备上运行应用,包括竖屏和横屏模式,看看SwiftUI的响应效果——如果你使用的是iPad,根据设备的屏幕方向以及应用是全屏显示还是分屏显示,你可能会看到不同的效果。
为滑雪胜地创建详情视图
作者:Paul Hudson 2024年3月17日
目前,我们的NavigationLink还不能跳转到任何地方,这对于原型设计来说还可以,但显然无法满足实际项目的需求。因此,在这一步中,我们将添加一个新的ResortView,该视图会显示滑雪胜地的图片、一些描述文本以及配套设施列表。
重要提示: 正如我之前所说,示例JSON文件中的内容大多是虚构的,其中也包括图片——这些图片只是从Unsplash上获取的通用滑雪主题图片。Unsplash上的图片无论用于商业用途还是非商业用途,都无需注明出处,但我还是在JSON文件中包含了图片的版权信息,以便你之后可以添加相关说明。至于文本内容,则来自维基百科。如果你打算在自己的正式项目中使用这些文本,务必注明维基百科及其作者,并明确说明这些内容基于知识共享署名-相同方式共享许可协议(CC-BY-SA)授权,该协议的详细信息可在以下网址查看:https://creativecommons.org/licenses/by-sa/3.0。
首先,我们的ResortView布局会比较简单——只不过是一个滚动视图(scroll view)、一个垂直堆叠视图(VStack)、一张图片(Image)和一些文本(Text)。唯一需要注意的是,我们将使用resort.facilities.joined(separator: ", ")将配套设施数组转换为单个字符串,并在一个文本视图中显示。
创建一个名为ResortView的新SwiftUI视图,并先添加以下代码:
struct ResortView: View {
let resort: Resort
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Image(decorative: resort.id)
.resizable()
.scaledToFit()
Group {
Text(resort.description)
.padding(.vertical)
Text("配套设施")
.font(.headline)
Text(resort.facilities.joined(separator: ", "))
.padding(.vertical)
}
.padding(.horizontal)
}
}
.navigationTitle("\(resort.name),\(resort.country)")
.navigationBarTitleDisplayMode(.inline)
}
}你还需要更新预览代码,以便在Xcode的预览窗口中传入一个示例滑雪胜地数据:
#Preview
ResortView(resort: .example)
}现在,我们可以更新ContentView,使其指向我们实际的详情视图——在ContentView的navigationTitle()之后添加以下修饰符:
.navigationDestination(for: Resort.self) { resort in
ResortView(resort: resort)
}到目前为止,我们的代码中还没有什么特别复杂的内容,但接下来情况会有所变化,因为我想在这个屏幕上添加更多细节——比如滑雪胜地的规模、大致的价格、海拔高度以及积雪深度。
我们可以直接将所有这些信息放入ResortView的一个水平堆叠视图(HStack)中,但这样会限制我们未来的扩展。因此,我们将把这些信息分成两个视图:一个用于显示滑雪胜地的基本信息(价格和规模),另一个用于显示滑雪相关信息(海拔高度和积雪深度)。
滑雪相关信息视图的实现相对简单,所以我们先从它开始:创建一个名为SkiDetailsView的新SwiftUI视图,并添加以下代码:
struct SkiDetailsView: View {
let resort: Resort
var body: some View {
Group {
VStack {
Text("海拔高度")
.font(.caption.bold())
Text("\(resort.elevation)米")
.font(.title3)
}
VStack {
Text("积雪深度")
.font(.caption.bold())
Text("\(resort.snowDepth)厘米")
.font(.title3)
}
}
.frame(maxWidth: .infinity)
}
}
#Preview {
SkiDetailsView(resort: .example)
}为Group视图设置最大宽度为.infinity,实际上并不会对Group本身产生影响,因为Group不影响布局。但是,这个设置会传递给它的子视图,这意味着子视图会自动在水平方向上展开。
至于滑雪胜地的基本信息视图,实现起来会稍微复杂一些,原因有两点:
- 滑雪胜地的规模以1到3之间的数值存储,但我们实际上希望用“小”(Small)、“中”(Average)和“大”(Large)来表示。
- 价格也以1到3之间的数值存储,但我们打算用“$”、“$$”或“$$$”来表示。
和往常一样,将计算逻辑从SwiftUI布局中抽离出来是个好主意,这样代码会更加清晰,因此我们将创建两个计算属性:size和price。
首先创建一个名为ResortDetailsView的新SwiftUI视图,并添加以下属性:
let resort: Resort和ResortView一样,你需要更新预览结构体,使其使用示例数据:
#Preview {
ResortDetailsView(resort: .example)
}在获取滑雪胜地规模时,我们可以直接在ResortDetailsView中添加以下属性:
var size: String {
["小", "中", "大"][resort.size - 1]
}这种方式虽然可行,但如果遇到无效值,程序会崩溃,而且从代码可读性来看,这种写法也不够直观。因此,更安全、更清晰的方式是使用switch语句,如下所示:
var size: String {
switch resort.size {
case 1: "小"
case 2: "中"
default: "大"
}
}至于price属性,我们可以利用在第17个项目中创建示例卡片时使用过的重复计数初始化器——String(repeating:count:),该初始化器通过将一个子字符串重复指定次数来创建一个新字符串。
因此,请在ResortDetailsView中添加第二个计算属性:
var price: String {
String(repeating: "$", count: resort.price)
}现在,body属性的实现就很简单了,我们只需要使用刚才编写的两个计算属性:
var body: some View {
Group {
VStack {
Text("规模")
.font(.caption.bold())
Text(size)
.font(.title3)
}
VStack {
Text("价格")
.font(.caption.bold())
Text(price)
.font(.title3)
}
}
.frame(maxWidth: .infinity)
}同样,为整个Group设置无限大的最大宽度,会让这些视图像之前那个视图的子视图一样,在水平方向上展开。
现在我们的两个小型视图已经完成,接下来可以将它们添加到ResortView中——在ResortView的Group之前添加以下代码:
HStack {
ResortDetailsView(resort: resort)
SkiDetailsView(resort: resort)
}
.padding(.vertical)
.background(.primary.opacity(0.1))稍后我们还会对这段代码进行补充,但现在我想做一个小调整:使用joined(separator:)将字符串数组转换为单个字符串的方式虽然可行,但我们的目标不是编写“还不错”的代码,而是编写“优秀”的代码。
之前我们使用过Text的format参数来控制数字的格式,但实际上,字符串数组也有对应的格式化方式。这种方式与使用joined(separator:)类似,但它不会像我们现在这样得到“A, B, C”这样的结果,而是会得到“A, B, 和 C”——这种格式读起来更自然。
将当前显示配套设施的文本视图替换为以下代码:
Text(resort.facilities, format: .list(type: .and))
.padding(.vertical)注意到这里使用的.and类型了吗?这是因为如果需要,你也可以使用.or,从而得到“A, B, 或 C”这样的结果。
无论如何,这只是一个微小的改动,但我认为效果会好很多!