1.2 Food Truck:构建 SwiftUI 多平台应用
原文链接: https://developer.apple.com/documentation/swiftui/food-truck-building-a-swiftui-multiplatform-app
1.2 Food Truck:构建 SwiftUI 多平台应用
为 Mac、iPad 和 iPhone 创建单一代码库和单一应用目标。
概述
借助 Food Truck 应用,经营流动餐车的人可以跟踪订单、了解最受欢迎的菜单项,并查看目的地的天气。这个示例实现了新的 NavigationSplitView 来管理应用的各个视图,用 Layout 显示主界面和待处理订单,用 Swift Charts 显示趋势,并用 WeatherService 获取天气数据。Food Truck 还实现了实时活动:在锁定屏幕上用 ActivityKit 显示订单剩余准备时间,在主屏幕上则用 DynamicIsland 显示。
你可以在 GitHub 上获取这个示例的源代码。
注意:这个示例代码项目与 WWDC22 的 110492:State of the Union 场次相关。
Food Truck 示例项目包含两种应用目标:
- 可以使用个人团队签名构建的简单应用目标。这个应用可运行在模拟器中,在设备上安装只需要标准 Apple ID。它包含应用内购买,以及一个让用户可以把小组件添加到 iOS 主屏幕或 macOS 通知中心的小组件扩展。
- 功能齐全的 Food Truck All 应用目标。完整版应用可运行在模拟器中,也可在拥有 Apple Developer 会员资格的设备的设备上运行。它还允许你创建通行密钥(passkey)并用它登录。
要在没有 Apple Developer 账号的情况下配置 Food Truck 应用,请按以下步骤操作:
- 在 Food Truck 目标的 Signing & Capabilities 面板中点击 Add Account,并用你的 Apple ID 登录。
- 在 Food Truck 和 Widgets 目标的团队菜单中选择 Your Name (Personal Team)。
- 构建并运行你的应用。
- 在 iOS 和 iPadOS 设备上,前往「设置」>「通用」>「VPN 与设备管理」,信任你的开发者证书。
要配置 Food Truck All 应用以便在你的设备上运行,请按以下步骤操作:
- 用 Xcode 14.3 或更高版本打开示例。
- 选中顶层的 Food Truck 项目。
- 对所有目标,在 Signing & Capabilities 面板的 Team 菜单中选择你的团队,让 Xcode 自动管理你的描述文件。
- 添加 Associated Domains 能力,并用
webcredentials 服务指定你的域名。关于 webcredentials 服务的更多信息,参见 Associated Domains 授权。 - 确保你的域名下的
.well-known 目录中存在 apple-app-site-association(AASA)文件,并且其中包含针对 webcredentials 服务的、本应用 App ID 的条目。关于 apple-app-site-association 文件的更多信息,参见支持关联域。 - 在
AccountManager.swift 文件中,把所有出现的 example.com 替换为你的域名。
注意:要使用示例中的天气预报功能,你需要按下面「为 WeatherKit 配置项目」一节所述完成额外步骤来配置 WeatherKit,否则示例会检测到错误,并改用项目中包含的静态数据。
Food Truck 是一个多平台应用,并没有为 macOS 或 iOS 分别准备独立的目标。相反,只有一个应用目标,同时为 macOS、iPadOS 和 iOS 构建。
定义默认导航目标
示例的导航界面由一个含 Sidebar 视图的 NavigationSplitView 和一个 NavigationStack 组成:
1
2
3
4
5
6
7
| NavigationSplitView {
Sidebar(selection: $selection)
} detail: {
NavigationStack(path: $path) {
DetailColumn(selection: $selection, model: model)
}
}
|
应用启动时,示例把 TruckView 作为默认视图呈现。Panel 枚举编码了用户可以在侧边栏中选择、从而出现在详情视图中的各个视图。TruckView 对应的值是 .truck,应用把它设为默认选择。
1
| @State private var selection: Panel? = Panel.truck
|
构造动态布局
在 Truck 视图中,New Orders 面板显示最近的五个订单,每个订单显示一个 DonutStackView,也就是由甜甜圈缩略图组成的斜向堆叠。Layout 协议让应用可以定义 DiagonalDonutStackLayout,把甜甜圈缩略图排成斜向布局。该布局的 placeSubviews(in:proposal:subviews:cache:) 实现负责计算各个甜甜圈的位置。
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
| for index in subviews.indices {
switch (index, subviews.count) {
case (_, 1):
subviews[index].place(
at: center,
anchor: .center,
proposal: ProposedViewSize(size)
)
case (_, 2):
let direction = index == 0 ? -1.0 : 1.0
let offsetX = minBound * direction * 0.15
let offsetY = minBound * direction * 0.20
subviews[index].place(
at: CGPoint(x: center.x + offsetX, y: center.y + offsetY),
anchor: .center,
proposal: ProposedViewSize(CGSize(width: size.width * 0.7, height: size.height * 0.7))
)
case (1, 3):
subviews[index].place(
at: center,
anchor: .center,
proposal: ProposedViewSize(CGSize(width: size.width * 0.65, height: size.height * 0.65))
)
case (_, 3):
let direction = index == 0 ? -1.0 : 1.0
let offsetX = minBound * direction * 0.15
let offsetY = minBound * direction * 0.23
subviews[index].place(
at: CGPoint(x: center.x + offsetX, y: center.y + offsetY),
anchor: .center,
proposal: ProposedViewSize(CGSize(width: size.width * 0.7, height: size.height * 0.65))
)
|
显示热门商品的图表
示例中包含若干图表。最受欢迎的商品显示在 TopFiveDonutsView 上。这个图表在 TopDonutSalesChart 中实现,它使用 BarMark 构造条形图。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| Chart {
ForEach(sortedSales) { sale in
BarMark(
x: .value("Donut", sale.donut.name),
y: .value("Sales", sale.sales)
)
.cornerRadius(6, style: .continuous)
.foregroundStyle(.linearGradient(colors: [Color("BarBottomColor"), .accentColor], startPoint: .bottom, endPoint: .top))
.annotation(position: .top, alignment: .top) {
Text(sale.sales.formatted())
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(.quaternary.opacity(0.5), in: Capsule())
.background(in: Capsule())
.font(.caption)
}
}
}
|
图表的 x 轴显示的标签包含与每个数据点对应的商品名称和缩略图。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| .chartXAxis {
AxisMarks { value in
AxisValueLabel {
let donut = donutFromAxisValue(for: value)
VStack {
DonutView(donut: donut)
.frame(height: 35)
Text(donut.name)
.lineLimit(2, reservesSpace: true)
.multilineTextAlignment(.center)
}
.frame(idealWidth: 80)
.padding(.horizontal, 4)
}
}
}
|
获取天气预报
应用在 Truck 视图的 Forecast 面板中显示预测温度曲线。应用从 WeatherKit 框架获取这些数据。
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
| .task(id: city.id) {
for parkingSpot in city.parkingSpots {
do {
let weather = try await WeatherService.shared.weather(for: parkingSpot.location)
condition = weather.currentWeather.condition
willRainSoon = weather.minuteForecast?.contains(where: { $0.precipitationChance >= 0.3 })
cloudCover = weather.currentWeather.cloudCover
temperature = weather.currentWeather.temperature
symbolName = weather.currentWeather.symbolName
let attribution = try await WeatherService.shared.attribution
attributionLink = attribution.legalPageURL
attributionLogo = colorScheme == .light ? attribution.combinedMarkLightURL : attribution.combinedMarkDarkURL
if willRainSoon == false {
spot = parkingSpot
break
}
} catch {
print("Could not gather weather information...", error.localizedDescription)
condition = .clear
willRainSoon = false
cloudCover = 0.15
}
}
}
|
WeatherKit 中 WeatherService 实例提供的数据需要为 Food Truck All 目标做额外配置。如果不配置 WeatherKit,示例会检测到错误,并改用项目中的静态数据。
- 在描述文件门户上创建一个唯一的 App ID,并在 App Services 标签页中选择 WeatherKit 服务。
- 在 Xcode 中,对 Food Truck All 目标,在 Signing & Capabilities 标签页里把 bundle ID 改成与第 1 步的 App ID 相同,并添加 WeatherKit 能力。
- 对 Widgets 目标,在 Signing & Capabilities 标签页里修改 bundle ID,使
.Widgets 之前的部分与 Food Truck All 目标的 bundle ID 相同。 - 等待 30 分钟,让服务注册你的应用 bundle ID。
- 构建并运行 Food Truck All 目标。
用实时活动跟踪准备时间
应用让餐车经营者可以跟踪订单准备时间,这个时间保证不超过 60 秒。为此,应用在订单详情屏幕上为状态为 placed 的订单实现了一个工具栏按钮。点击这个按钮会把订单状态改为 preparing,并创建一个 Activity 实例来启动实时活动,从而在 iPhone 锁定屏幕上显示倒计时和订单详情。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| let timerSeconds = 60
let activityAttributes = TruckActivityAttributes(
orderID: String(order.id.dropFirst(6)),
order: order.donuts.map(\.id),
sales: order.sales,
activityName: "Order preparation activity."
)
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
let initialContentState = TruckActivityAttributes.ContentState(timerRange: Date.now...future)
let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 2, to: Date())!)
do {
let myActivity = try Activity<TruckActivityAttributes>.request(attributes: activityAttributes, content: activityContent,
pushType: nil)
print(" Requested MyActivity live activity. ID: \(myActivity.id)")
postNotification()
} catch let error {
print("Error requesting live activity: \(error.localizedDescription)")
}
|
应用还实现了 DynamicIsland,在 iPhone 14 Pro 设备上把与锁定屏幕相同的信息显示在灵动岛中。
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
| DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
ExpandedLeadingView()
}
DynamicIslandExpandedRegion(.trailing, priority: 1) {
ExpandedTrailingView(orderNumber: context.attributes.orderID, timerInterval: context.state.timerRange)
.dynamicIsland(verticalPlacement: .belowIfTooWide)
}
} compactLeading: {
Image("IslandCompactIcon")
.padding(4)
.background(.indigo.gradient, in: ContainerRelativeShape())
} compactTrailing: {
Text(timerInterval: context.state.timerRange, countsDown: true)
.monospacedDigit()
.foregroundColor(Color("LightIndigo"))
.frame(width: 40)
} minimal: {
Image("IslandCompactIcon")
.padding(4)
.background(.indigo.gradient, in: ContainerRelativeShape())
}
.contentMargins(.trailing, 32, for: .expanded)
.contentMargins([.leading, .top, .bottom], 6, for: .compactLeading)
.contentMargins(.all, 6, for: .minimal)
.widgetURL(URL(string: "foodtruck://order/\(context.attributes.orderID)"))
|
再次点击同一个按钮会把状态改为 complete,并结束实时活动。这会从锁定屏幕和灵动岛中移除该实时活动。
1
2
3
4
5
6
7
8
| Task {
for activity in Activity<TruckActivityAttributes>.activities {
// 检查这是否是与该订单关联的活动。
if activity.attributes.orderID == String(order.id.dropFirst(6)) {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}
|