Skip to content

第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中更方便地使用它,我们还将让它同时遵循HashableIdentifiable协议。

实际的数据大多是字符串和整数类型,但其中还有一个名为facilities的字符串数组,用于描述该滑雪胜地的其他配套设施——需要说明的是,这些数据大多是虚构的,所以不要尝试在实际应用中使用!

创建一个名为Resort.swift的新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文件,并添加以下代码:

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结构体中添加一些属性来存储示例数据,这里有两种实现方式。第一种方式是添加两个静态属性:一个用于将所有滑雪胜地加载到数组中,另一个用于存储该数组的第一个元素,代码如下:

swift
static let allResorts: [Resort] = Bundle.main.decode("resorts.json")
static let example = allResorts[0]

第二种方式是将上述代码简化为一行。这需要进行一点简单的类型转换,因为我们的decode()扩展方法需要知道要解码的数据类型:

swift
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中添加一个属性,将所有滑雪胜地加载到一个数组中:

swift
let resorts: [Resort] = Bundle.main.decode("resorts.json")

对于视图的主体部分,我们将使用NavigationSplitView,并在其中放置一个List来显示所有滑雪胜地。在每一行中,我们将显示:

  • 一个40x25尺寸的国旗图片,用于标识滑雪胜地所属国家。
  • 滑雪胜地的名称。
  • 该滑雪胜地拥有的滑雪道数量。

40x25的尺寸比我们的国旗源图片小,而且纵横比也不同,但我们可以通过使用resizable()scaledToFill()和自定义框架来解决这个问题。为了让屏幕显示效果更好,我们还会使用自定义的裁剪形状和带边框的覆盖层。

当用户点击某一行时,我们会跳转到一个详情视图,显示该滑雪胜地的更多信息,但目前我们还没有构建这个详情视图,所以暂时先跳转到一个临时的文本视图作为占位。

将你当前的body属性替换为以下代码:

swift
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视图,然后添加以下代码:

swift
struct WelcomeView: View {
    var body: some View {
        VStack {
            Text("欢迎使用SnowSeeker!")
                .font(.largeTitle)

            Text("请从左侧菜单中选择一个滑雪胜地;从左边缘向右滑动即可显示菜单。")
                .foregroundStyle(.secondary)
        }
    }
}

这只是一个静态文本视图;它只会在应用首次启动时显示,因为一旦用户点击任何一个导航链接,它就会被用户要导航到的视图替换掉。

要将这个视图添加到ContentView中,使我们UI的两个部分能够并排使用,将我们之前添加的Text("详情")代码替换为以下内容:

swift
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视图,并先添加以下代码:

swift
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的预览窗口中传入一个示例滑雪胜地数据:

swift
#Preview
    ResortView(resort: .example)
}

现在,我们可以更新ContentView,使其指向我们实际的详情视图——在ContentViewnavigationTitle()之后添加以下修饰符:

swift
.navigationDestination(for: Resort.self) { resort in
    ResortView(resort: resort)
}

到目前为止,我们的代码中还没有什么特别复杂的内容,但接下来情况会有所变化,因为我想在这个屏幕上添加更多细节——比如滑雪胜地的规模、大致的价格、海拔高度以及积雪深度。

我们可以直接将所有这些信息放入ResortView的一个水平堆叠视图(HStack)中,但这样会限制我们未来的扩展。因此,我们将把这些信息分成两个视图:一个用于显示滑雪胜地的基本信息(价格和规模),另一个用于显示滑雪相关信息(海拔高度和积雪深度)。

滑雪相关信息视图的实现相对简单,所以我们先从它开始:创建一个名为SkiDetailsView的新SwiftUI视图,并添加以下代码:

swift
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. 滑雪胜地的规模以1到3之间的数值存储,但我们实际上希望用“小”(Small)、“中”(Average)和“大”(Large)来表示。
  2. 价格也以1到3之间的数值存储,但我们打算用“$”、“$$”或“$$$”来表示。

和往常一样,将计算逻辑从SwiftUI布局中抽离出来是个好主意,这样代码会更加清晰,因此我们将创建两个计算属性:sizeprice

首先创建一个名为ResortDetailsView的新SwiftUI视图,并添加以下属性:

swift
let resort: Resort

ResortView一样,你需要更新预览结构体,使其使用示例数据:

swift
#Preview {
    ResortDetailsView(resort: .example)
}

在获取滑雪胜地规模时,我们可以直接在ResortDetailsView中添加以下属性:

swift
var size: String {
    ["小", "中", "大"][resort.size - 1]
}

这种方式虽然可行,但如果遇到无效值,程序会崩溃,而且从代码可读性来看,这种写法也不够直观。因此,更安全、更清晰的方式是使用switch语句,如下所示:

swift
var size: String {
    switch resort.size {
    case 1: "小"
    case 2: "中"
    default: "大"
    }
}

至于price属性,我们可以利用在第17个项目中创建示例卡片时使用过的重复计数初始化器——String(repeating:count:),该初始化器通过将一个子字符串重复指定次数来创建一个新字符串。

因此,请在ResortDetailsView中添加第二个计算属性:

swift
var price: String {
    String(repeating: "$", count: resort.price)
}

现在,body属性的实现就很简单了,我们只需要使用刚才编写的两个计算属性:

swift
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中——在ResortViewGroup之前添加以下代码:

swift
HStack {
    ResortDetailsView(resort: resort)
    SkiDetailsView(resort: resort)
}
.padding(.vertical)
.background(.primary.opacity(0.1))

稍后我们还会对这段代码进行补充,但现在我想做一个小调整:使用joined(separator:)将字符串数组转换为单个字符串的方式虽然可行,但我们的目标不是编写“还不错”的代码,而是编写“优秀”的代码。

之前我们使用过Textformat参数来控制数字的格式,但实际上,字符串数组也有对应的格式化方式。这种方式与使用joined(separator:)类似,但它不会像我们现在这样得到“A, B, C”这样的结果,而是会得到“A, B, 和 C”——这种格式读起来更自然。

将当前显示配套设施的文本视图替换为以下代码:

swift
Text(resort.facilities, format: .list(type: .and))
    .padding(.vertical)

注意到这里使用的.and类型了吗?这是因为如果需要,你也可以使用.or,从而得到“A, B, 或 C”这样的结果。

无论如何,这只是一个微小的改动,但我认为效果会好很多!

本站使用 VitePress 制作