1.2 Wishlist:在 SwiftUI 应用中规划旅行

原文链接: https://developer.apple.com/documentation/swiftui/wishlist-planning-travel-in-a-swiftui-app

1.2 Wishlist:在 SwiftUI 应用中规划旅行

构建一个旅行规划应用,把行程组织成集合,并跟踪活动的完成情况。

概述

Wishlist 示例应用通过把旅行计划分组到季节性的集合中,帮助人们整理旅行安排。在每个行程中,人们可以创建活动,并在探索过程中把它们标记为完成。应用通过成就徽章来奖励进展,跟踪诸如完成第一次旅行或在所有冒险中达成某个活动里程碑之类的节点。

这个示例项目演示了如何:

  • 组合自定义视图。
  • 使用 Observable() 宏管理状态。
  • 自定义导航标题的外观。
  • 为视图变化添加动画。
  • 创建到导航目标以及按钮与工作表之间的缩放过渡。

组合自定义视图

SwiftUI 视图遵循 View 协议,并通过一个计算属性 body 定义其内容。每个视图返回一份关于屏幕上要显示什么的描述,由 SwiftUI 负责渲染。

Wishlist 通过组合 VStack、HStack、Text、Image 和 Button 等内置组件来构建自定义视图:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct TripCard: View {
    var trip: Trip
    var size: Size

    var body: some View {
        VStack(alignment: .leading, spacing: 5) {
            TripImageView(url: trip.photoURL)
                .scaledToFill()
                .frame(width: size.width, height: size.height)
                .clipShape(.rect(cornerRadius: 16))

            VStack(alignment: .leading, spacing: 0) {
                Text(trip.name)
                    .font(.body)

                if let subtitle = trip.subtitle {
                    Text(subtitle)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
            }
        }
    }
}

外层的 VStack 把图像和文本垂直堆叠,并把内容对齐到前缘。通过把修饰符依次串联,可以依次应用各种变换,每个修饰符都会把前一个视图包装成一个行为经过修改的新视图。

用可观察宏管理状态

当视图的依赖发生变化时,SwiftUI 会自动更新视图。用 Observable() 标记模型类,即可选择启用自动变更跟踪。在 Wishlist 中,@Observable 宏会合成必要的代码,用来发布对任何存储属性所做的更改:

1
2
3
4
5
6
7
8
9
@Observable
class DataSource {
    var trips: [Trip.ID: Trip] {
        didSet {
            updateGoalAchievements()
        }
    }
    var searchText = ""
}

DataSource 类以行程 ID 为键,把行程存储在 Dictionary 中,以便高效查找。每当 trips 字典发生变化时,didSet 属性观察器都会调用 updateGoalAchievements(),让目标进度与行程完成情况保持同步。任何读取 trips 字典的视图(例如 RecentTripsPageView)都会在字典变化时自动更新,例如添加或移除行程时。

为了在这个示例应用中共享这些数据,Wishlist 在 App 结构体内用 State 属性包装器创建一个状态,然后用 environment(_:) 修饰符把数据注入视图层级:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@main
struct WishlistApp: App {
    @State private var dataSource = DataSource()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(dataSource)
        }
    }
}

在视图内部,Wishlist 通过类型获取可观察对象,然后创建一个属性并把该对象的类型提供给 Environment 属性包装器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
struct WishlistView: View {
    @Environment(DataSource.self) private var dataSource

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 10) {
                    RecentTripsPageView()
                    ForEach(TripCollection.allCases) { tripCollection in
                        TripCollectionView(
                            tripCollection: tripCollection,
                            cardSize: tripCollection.cardSize,
                            namespace: namespace
                        )
                    }
                }
            }
        }
    }
}

@Environment 属性包装器会从环境中取出数据源,并在视图与可观察模型之间建立依赖关系。默认情况下,当以对象类型作为键从环境中读取对象时,返回的是非可选对象。这一默认行为假定当前层级中之前有视图用 environment(_:) 修饰符存储了该类型的一个非可选实例。如果某个视图尝试按其类型取回对象,而该对象不在环境中,SwiftUI 会抛出异常。

SwiftUI 会自动跟踪视图 body 内的属性访问。当任何被观察的属性发生变化时,SwiftUI 会更新视图中依赖该值的各个部分。

SwiftUI 中的一些内置视图和修饰符(例如 Toggle 和 searchable(text:placement:prompt:))接收指向某个属性的 Binding。这让这些视图和修饰符可以把更改写回该属性。使用 Bindable 属性包装器可以为 Observable 对象的属性创建绑定,其中也包括全局变量、存在于 SwiftUI 类型之外的属性,甚至局部变量。例如,这个示例应用在视图 body 内创建一个 @Bindable 变量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct SearchView: View {
    @Environment(DataSource.self) private var dataSource

    var body: some View {
        @Bindable var dataSource = dataSource

        NavigationStack {
            SearchResultsListView()
                .searchable(text: $dataSource.searchText)
        }
    }
}

@Bindable 属性包装器会暴露一个绑定投影前缀 $,用来生成 Binding 值。在上面的示例中,$dataSource.searchText 创建一个 Binding<String>,把搜索框连接到数据源。当有人在搜索框中输入时,SwiftUI 会通过该绑定写入新值,从而更新这个可观察属性并使依赖它的视图失效。

自定义导航标题外观

在显示导航标题时,SwiftUI 会把视觉呈现与语义含义分开处理。

在这个示例应用中,WishlistView 视图使用 ToolbarItem 和 title 的 toolbarItem 位置来自定义标题的视觉外观。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
NavigationStack {
    ScrollView {
        // 内容
    }
    .toolbar {
        ToolbarItem(placement: .title) {
            ExpandedNavigationTitle(title: "Wishlist")
        }
    }
    .navigationTitle("Wishlist")
    .toolbarTitleDisplayMode(.inline)
}

ExpandedNavigationTitle 视图用自定义字体渲染标题。始终要用 navigationTitle(_:) 添加导航标题,因为当有人从详情视图返回时,系统会用它作为辅助功能标签。指定 inline 的 toolbarTitleDisplayMode(_:) 修饰符会告诉 SwiftUI 把标题显示在导航栏的行内位置,而不是显示为大标题。

对于会延伸进安全区域的内容,请改用 largeTitle 位置。

为视图变化添加动画

为了把人们的注意力引向状态变化,可以用下列方式之一添加动画:

  • 在调用 withAnimation(::) 全局函数的过程中修改状态,从而为某个状态变化的所有视觉变化添加动画。
  • 为视图应用 animation(_:value:) 视图修饰符,从而在某个特定值变化时为该视图添加动画。

SwiftUI 会为许多内置视图修饰符产生的效果添加动画,例如设置缩放或不透明度值的那些。你也可以通过让自定义视图遵循 Animatable 协议,为其他值添加动画。用 Animatable() 宏即可做到这一点。

请根据你是在为一次离散动作添加动画,还是在响应特定的属性变化,来选择合适的方式。

在 Wishlist 中,当有人删除某个活动时,withAnimation 确保这次移除以动画方式平滑完成:

1
2
3
4
5
Button("Delete", role: .destructive) {
    withAnimation {
        model.removeActivity(activity)
    }
}

withAnimation 代码块会建立一个动画事务。removeActivity(_:) 触发的任何视图变化都会自动以动画呈现,并共享同一条动画曲线和时序。

为了获得更精细的控制,可以用 animation(_:value:) 修饰符把动画应用到特定视图。这种基于值的方式会创建一个有针对性的动画,只在指定值变化时触发,并且只影响该修饰符所在的视图层级。在 Wishlist 的活动完成按钮中,对勾图标只在 isComplete 切换时以动画呈现其外观,其他视图变化则保持不动画:

1
2
3
4
Image(systemName: activity.isComplete ? "checkmark.circle.fill" : "circle")
    .foregroundStyle(activity.isComplete ? Color.accentColor : .gray)
    .contentTransition(.symbolEffect)
    .animation(.snappy, value: activity.isComplete)

animation(.snappy, value: activity.isComplete) 修饰符告诉 SwiftUI 在 activity.isComplete 变化时为这个图像添加动画,并使用轻快的弹簧曲线。如果其他属性发生变化,例如视图的位置或不透明度,只要它们不依赖 isComplete,这些变化就不会有动画。

要了解更多关于动画的内容,请观看 WWDC23 的讲座 10156:探索 SwiftUI 动画。

在导航目标之间创建缩放过渡

要自定义视图之间的动画过渡,请为源视图应用 matchedTransitionSource(id:in:),并为目标视图应用带 zoom(sourceID:in:) 过渡的 navigationTransition(_:),同时使用相匹配的标识符。用 Namespace 属性包装器创建一个唯一值,把源视图和目标视图关联起来。

在 Wishlist 中,当有人轻点某张行程卡片时,它会平滑地缩放并展开成行程详情屏幕,在整个导航过程中保持视觉连续性。关闭详情视图时动画会反向播放,缩放回原来的卡片位置。

 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
struct WishlistView: View {
    @Namespace private var namespace

    var body: some View {
        NavigationStack {
            ...
            ForEach(TripCollection.allCases) { tripCollection in
                TripCollectionView(
                    tripCollection: tripCollection,
                    namespace: namespace
                )
            }
        }
    }
}

struct TripCollectionView: View {
    var tripCollection: TripCollection
    var namespace: Namespace.ID

    var body: some View {
        ...
        ForEach(dataSource.trips(in: tripCollection)) { trip in
            NavigationLink {
                TripDetailView(trip: trip)
                    .navigationTransition(.zoom(sourceID: trip.id, in: namespace))
            } label: {
                TripCard(trip: trip, size: cardSize)
                    .matchedTransitionSource(id: trip.id, in: namespace)
            }
        }
        ...
    }
}

@Namespace 属性包装器会创建一个唯一的标识符空间,SwiftUI 用它来协调这次过渡。把同一个命名空间传给源视图和目标视图,即可建立它们之间的关系。

行程集合中的每张 TripCard 都会收到带有该行程 ID 作为标识符的 matchedTransitionSource(id:in:)。当有人轻点某张卡片时,SwiftUI 会捕获它的位置、尺寸和圆角半径,作为这次过渡的起点。行程详情视图会应用带匹配标识符的 navigationTransition(.zoom(sourceID:in:)),声明自己是缩放目标。SwiftUI 会在两个几何形状之间插值,无缝地把一个变形为另一个。

请观看 WWDC24 的讲座 10145:增强你的界面动画与过渡效果,了解如何在你的应用中为导航和呈现采用缩放过渡。