Skip to content

第98天 项目 19 第三部分

现在是时候编写我们最终项目的最后一部分了,这意味着要实现三个重要功能:调整用户界面以充分利用可用空间、在点击每个设施时显示更多相关信息,以及允许用户标记收藏项。

前两个功能听起来可能很简单,但你会发现它们背后存在一些需要仔细思考才能解决的有趣复杂性。不过没关系——现在你已经快要完成这100天的学习了,思考复杂的SwiftUI内容应该完全在你的能力范围之内。在四五周前,这些内容对你来说可能很难,但到目前为止,希望它们几乎已经成为你的本能。就像David A. Smith曾经说过的:“难只是暂时的,总会变得简单。”

今天你需要完成四个主题的学习,通过这些内容,你将为应用添加搜索和尺寸类支持、显示设施的更多信息,以及允许用户标记收藏的度假村。

  • 在列表中搜索数据
  • 根据尺寸类更改视图布局
  • 将警告框与可选字符串绑定
  • 允许用户标记收藏项

又一个项目完成了——别忘了和其他人分享你的进展,因为即使到了现在,保持责任感也很有价值!

在列表中搜索数据

作者:Paul Hudson 2024年3月17日

在完成ContentView中的列表之前,我们要添加一个SwiftUI修饰符,这个修饰符能让用户体验得到很大提升,而且实现起来并不复杂,它就是searchable()。添加这个修饰符后,用户可以过滤我们显示的度假村列表,从而轻松找到他们确切想要的内容。

实现这个功能只需四个步骤,首先在ContentView中添加一个新的@State属性,用于存储用户输入的搜索文本:

swift
@State private var searchText = ""

第二步,我们可以将这个属性与ContentView中的列表绑定,直接在现有的navigationDestination()修饰符下方添加以下代码:

swift
.searchable(text: $searchText, prompt: "Search for a resort")

第三步,我们需要一个计算属性来处理数据过滤。如果新的searchText属性为空,我们就返回所有加载的度假村;否则,我们使用localizedStandardContains()方法根据用户的搜索条件过滤数组:

swift
var filteredResorts: [Resort] {
    if searchText.isEmpty {
       resorts
    } else {
        resorts.filter { $0.name.localizedStandardContains(searchText) }
    }
}

最后一步,将filteredResorts用作列表的数据源,代码如下:

swift
List(filteredResorts) { resort in

这样就完成了!如果你再次运行应用,会发现轻轻向下拖动度假村列表就能看到搜索框,在其中输入内容就能立即过滤列表。说实话,searchable()是SwiftUI中“性价比最高”的功能之一——对用户来说这是一个非常重要的功能,而我们只需几分钟就能实现!

根据尺寸类更改视图布局

作者:Paul Hudson 2024年3月17日

SwiftUI为我们提供了两个环境值,用于监控应用当前的尺寸类。实际上,这意味着当空间有限时,我们可以显示一种布局;当空间充足时,又可以显示另一种布局。

例如,在我们当前的布局中,我们使用HStack显示度假村详情和滑雪详情,代码如下:

swift
HStack {
    ResortDetailsView(resort: resort)
    SkiDetailsView(resort: resort)
}

这两个子视图内部都使用了Group,而Group本身不会添加任何布局,所以最终所有四个文本都会以水平方式排列。当有足够空间时,这种布局看起来很棒,但当空间有限时,切换到2x2的网格布局会更有帮助。

要实现这一点,我们可以创建ResortDetailsViewSkiDetailsView的副本,让它们处理替代布局,但更聪明的解决方案是让这两个视图保持“布局中立”——即根据放置它们的父视图,自动适配HStackVStack布局。

首先,在ResortView中添加以下新的@Environment属性:

swift
@Environment(\.horizontalSizeClass) var horizontalSizeClass

这个属性会告诉我们当前是常规(regular)尺寸类还是紧凑(compact)尺寸类。大致来说:

  • 所有iPhone在竖屏模式下,宽度为紧凑尺寸类,高度为常规尺寸类。
  • 大多数iPhone在横屏模式下,宽度和高度均为紧凑尺寸类。
  • 大屏幕iPhone(Plus机型和Max机型)在横屏模式下,宽度为常规尺寸类,高度为紧凑尺寸类。
  • 所有iPad在两种屏幕方向下,当应用全屏运行时,宽度和高度均为常规尺寸类。

当iPad处于分屏模式(即同时运行两个应用)时,情况会稍微复杂一些——根据具体的iPad型号,iOS会在不同情况下自动将我们的应用降级为紧凑尺寸类。

幸运的是,一开始我们只关心两种水平方向的情况:是否有足够的水平空间(常规尺寸类),或者空间是否受限(紧凑尺寸类)。如果有足够的水平空间,我们会保留当前的HStack方式,让所有内容整齐地排列在一行;如果空间受限,我们就舍弃这种方式,将每个视图放入VStack中。

因此,找到包含ResortDetailsViewSkiDetailsViewHStack,并将其替换为以下代码:

swift
HStack {
    if horizontalSizeClass == .compact {
        VStack(spacing: 10) { ResortDetailsView(resort: resort) }
        VStack(spacing: 10) { SkiDetailsView(resort: resort) }
    } else {
        ResortDetailsView(resort: resort)
        SkiDetailsView(resort: resort)
    }
}
.padding(.vertical)
.background(.primary.opacity(0.1))

如你所见,这里使用了两个并排的垂直栈,而不是让所有四个视图都水平排列。

这样就完美了吗?其实并没有。当然,在紧凑布局中会有更多空间,这意味着即使用户使用更大的动态字体(Dynamic Type)大小,也不会出现空间不足的情况,但很多用户不会有这个问题,因为他们会使用默认字体大小,甚至更小的字体大小。

为了让布局更好,我们可以将对应用当前水平尺寸类的检查与对用户动态字体设置的检查结合起来——只有当空间确实紧张时(即用户使用紧凑尺寸类且动态字体设置较大),才使用水平平铺布局。

首先添加另一个属性,用于读取当前的动态字体设置:

swift
@Environment(\.dynamicTypeSize) var dynamicTypeSize

然后将尺寸类检查修改为以下代码:

swift
if horizontalSizeClass == .compact && dynamicTypeSize > .large {

现在,我们的布局终于能在两种屏幕方向下都呈现出良好的效果了:在常规尺寸类下,文本排成一行;在紧凑尺寸类且使用增大的字体大小时,文本排成两行垂直栈。虽然花了一些功夫,但最终我们还是做到了!

我们的解决方案没有导致代码重复,这是一个巨大的优势,同时也让两个子视图的设计更加合理——它们现在只负责提供内容,而不指定布局。因此,父视图可以随时在HStackVStack之间动态切换,SwiftUI会负责处理布局。

在结束这部分内容之前,我想向你展示一个有用的额外技巧:你可以限制特定视图支持的动态字体大小范围。例如,你可能已经努力支持了尽可能广泛的字体大小范围,但发现超过“超大超大超大”(extra extra extra large)的字体大小看起来很糟糕。在这种情况下,你可以对视图使用dynamicTypeSize()修饰符,代码如下:

swift
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)

这是一个单侧范围,表示允许的字体大小最高为并包含.xxxLarge,但不包括更大的尺寸。显然,在可能的情况下,最好避免设置这些限制,但如果合理使用,也不会有问题——例如,TabViewNavigationStack都会限制其文本标签的大小,以防止用户界面出现故障。

将警告框与可选字符串绑定

作者:Paul Hudson 2024年3月17日

SwiftUI允许我们在警告框中使用可选的真值来源,但正如你将看到的,要正确实现这一点需要一些思考。

为了演示这些可选警告框的实际应用,我们将重新设计度假村设施的显示方式。目前,我们使用以下代码生成一个普通文本视图来显示设施:

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

我们将把它替换为代表每个设施的图标,当用户点击某个图标时,会显示一个包含该设施描述的警告框。

和往常一样,我们会从简单的部分开始,逐步深入。首先,我们需要一种方法将“住宿”(Accommodation)这类设施名称转换为可显示的图标。虽然目前这只在ResortView中使用,但这种功能完全可以在项目的其他地方使用。因此,我们将创建一个新的结构体来存储所有相关信息。

创建一个名为Facility.swift的新Swift文件,将其中的Foundation导入语句替换为SwiftUI导入语句,然后添加以下代码:

swift
struct Facility: Identifiable {
    let id = UUID()
    var name: String

    private let icons = [
        "Accommodation": "house",
        "Beginners": "1.circle",
        "Cross-country": "map",
        "Eco-friendly": "leaf.arrow.circlepath",
        "Family": "person.3"
    ]

    var icon: some View {    
        if let iconName = icons[name] {
            Image(systemName: iconName)
                .accessibilityLabel(name)
                .foregroundStyle(.secondary)
        } else {
            fatalError("Unknown facility type: \(name)")
        }
    }
}

如你所见,该结构体遵循Identifiable协议,这样我们就可以在SwiftUI中循环遍历设施数组;在结构体内部,它会在字典中查找给定的设施名称,以返回对应的图标。我挑选了各种适合现有设施的SF Symbols图标,并且为图像使用了accessibilityLabel()修饰符,以确保它能在VoiceOver(屏幕阅读器)中正常工作。

下一步是为度假村(Resort)中的每一项设施创建Facility实例,我们可以在Resort结构体内部通过一个计算属性来实现:

swift
var facilityTypes: [Facility] {
    facilities.map(Facility.init)
}

现在,我们可以在ResortView中添加这个设施视图,将以下代码:

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

替换为:

swift
HStack {
    ForEach(resort.facilityTypes) { facility in
        facility.icon
            .font(.title)
    }
}
.padding(.vertical)

这段代码会循环遍历facilities数组中的每一项,将其转换为图标并放入HStack中。我使用了.font(.title)修饰符来放大图像——在这儿而不是在Facility内部使用这个修饰符,能让我们在其他地方使用这些图标时拥有更多灵活性。

这部分比较简单,接下来的部分会更复杂一些:我们要将设施图标变成按钮,这样用户点击时就能显示警告框。

使用alert()的可选形式,一开始会很容易——在ResortView中添加两个新属性,一个用于存储当前选中的设施,另一个用于存储当前是否应显示警告框:

swift
@State private var selectedFacility: Facility?
@State private var showingFacility = false

现在,将之前的ForEach循环替换为以下代码:

swift
ForEach(resort.facilityTypes) { facility in
    Button {
        selectedFacility = facility
        showingFacility = true
    } label: {
        facility.icon
            .font(.title)
    }
}

我们可以用与创建图标非常相似的方式来创建警告框——在Facility结构体中添加一个字典,包含我们需要的所有键值对:

swift
private let descriptions = [
    "Accommodation": "This resort has popular on-site accommodation.",
    "Beginners": "This resort has lots of ski schools.",
    "Cross-country": "This resort has many cross-country ski routes.",
    "Eco-friendly": "This resort has won an award for environmental friendliness.",
    "Family": "This resort is popular with families."
]

然后通过另一个计算属性读取这些描述:

swift
var description: String {
    if let message = descriptions[name] {
        message
    } else {
        fatalError("Unknown facility type: \(name)")
    }
}

到目前为止,事情还不算复杂,但接下来就是比较复杂的部分了。你要知道,selectedFacility属性是可选类型的,所以我们需要谨慎处理:

  • 不能将它作为警告框的唯一标题,因为我们必须提供一个非可选的字符串。我们可以用空合运算符(nil coalescing)来解决这个问题。
  • 我们始终要确保警告框从可选的selectedFacility中读取数据,因此需要传入其中解包后的值。
  • 这个警告框不需要任何按钮,所以我们可以让系统提供一个默认的“确定”(OK)按钮。
  • 我们需要根据解包后的设施数据提供警告框消息,调用我们刚才编写的新message(for:)方法。

综合以上所有要点,在ResortView中的navigationBarTitleDisplayMode()下方添加以下修饰符:

swift
.alert(selectedFacility?.name ?? "More information", isPresented: $showingFacility, presenting: selectedFacility) { _ in
} message: { facility in
    Text(facility.description)
}

注意,我们在警告框的操作闭包中使用了_ in,因为我们实际上并不关心从中获取解包后的Facility实例,但在message闭包中,这个实例非常重要,这样我们才能显示正确的描述。

允许用户标记收藏项

作者:Paul Hudson 2024年5月13日

这个项目的最后一项任务是允许用户为自己喜欢的度假村添加收藏标记。这大部分内容都很简单,会用到我们已经学过的技术:

  • 创建一个新的Favorites类,该类包含一个存储用户喜欢的度假村ID的集合(Set)。
  • 为该类添加add()remove()contains()方法,用于操作数据,同时将所有更改保存到UserDefaults中。
  • Favorites类的实例注入到环境中。
  • 添加一些新的用户界面元素,用于调用相应的方法。

Swift的集合已经包含添加、删除和检查元素的方法,但我们会在这些方法之外再封装一层自己的方法,这样我们就可以调用save()方法,确保用户的更改能被持久化保存。这反过来意味着我们可以将收藏集合标记为private访问控制级别,这样就不会不小心绕过我们的方法,导致忘记保存更改。

创建一个名为Favorites.swift的新Swift文件,将其中的Foundation导入语句替换为SwiftUI导入语句,然后添加以下代码:

swift
@Observable
class Favorites {
    // 用户收藏的实际度假村(ID)
    private var resorts: Set<String>

    // 我们在UserDefaults中用于读写数据的键
    private let key = "Favorites"

    init() {
        // 加载已保存的数据

        // 如果加载失败(仍执行到这里),则使用空数组
        resorts = []
    }

    // 检查集合中是否包含指定度假村,包含则返回true
    func contains(_ resort: Resort) -> Bool {
        resorts.contains(resort.id)
    }

    // 将度假村添加到集合中,并保存更改
    func add(_ resort: Resort) {
        resorts.insert(resort.id)
        save()
    }

    // 从集合中移除度假村,并保存更改
    func remove(_ resort: Resort) {
        resorts.remove(resort.id)
        save()
    }

    func save() {
        // 写出数据(保存数据)
    }
}

你会注意到,我省略了加载和保存收藏项的实际功能代码——这部分内容将由你在稍后自行补充。

我们需要在ContentView中创建Favorites的实例,并将其注入到环境中,以便所有视图都能共享它。因此,在ContentView中添加以下新属性:

swift
@State private var favorites = Favorites()

现在,通过为NavigationSplitView添加以下修饰符,将其注入到环境中:

swift
.environment(favorites)

由于这个修饰符附加在导航拆分视图(NavigationSplitView)上,导航栈呈现的所有视图也都会获取到这个Favorites实例。因此,我们可以在ResortView中通过添加以下新属性来加载它:

swift
@Environment(Favorites.self) var favorites

提示: 确保修改ResortView的预览代码,将一个示例Favorites对象注入到环境中,这样你的SwiftUI预览才能继续正常工作。以下代码可以实现这一点:.environment(Favorites())

到目前为止,这些工作还没有真正实现太多功能——诚然,Favorites类会在应用启动时加载,但尽管有存储它的属性,它还没有在任何地方被实际使用。

解决这个问题很容易:我们要在ResortView的滚动视图(scrollview)末尾添加一个按钮,让用户可以将度假村添加到收藏中或从收藏中移除,然后在ContentView中为收藏的度假村显示一个心形图标。

首先,在ResortView的滚动视图末尾添加以下代码:

swift
Button(favorites.contains(resort) ? "Remove from Favorites" : "Add to Favorites") {
    if favorites.contains(resort) {
        favorites.remove(resort)
    } else {
        favorites.add(resort)
    }
}
.buttonStyle(.borderedProminent)
.padding()

现在,我们可以在ContentView中,在NavigationLinkHStack标签末尾添加以下代码,为收藏的度假村显示一个彩色的心形图标:

swift
if favorites.contains(resort) {
    Spacer()
    Image(systemName: "heart.fill")
    .accessibilityLabel("This is a favorite resort")
        .foregroundStyle(.red)
}

提示: 如你所见,foregroundStyle()修饰符在这里效果很好,因为我们的图像使用的是SF Symbols图标。

这样,我们的项目就完成了,最后再尝试运行一下,看看效果如何。做得好!

本站使用 VitePress 制作