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 应用,请按以下步骤操作:

  1. 在 Food Truck 目标的 Signing & Capabilities 面板中点击 Add Account,并用你的 Apple ID 登录。
  2. 在 Food Truck 和 Widgets 目标的团队菜单中选择 Your Name (Personal Team)。
  3. 构建并运行你的应用。
  4. 在 iOS 和 iPadOS 设备上,前往「设置」>「通用」>「VPN 与设备管理」,信任你的开发者证书。

要配置 Food Truck All 应用以便在你的设备上运行,请按以下步骤操作:

  1. 用 Xcode 14.3 或更高版本打开示例。
  2. 选中顶层的 Food Truck 项目。
  3. 对所有目标,在 Signing & Capabilities 面板的 Team 菜单中选择你的团队,让 Xcode 自动管理你的描述文件。
  4. 添加 Associated Domains 能力,并用 webcredentials 服务指定你的域名。关于 webcredentials 服务的更多信息,参见 Associated Domains 授权。
  5. 确保你的域名下的 .well-known 目录中存在 apple-app-site-association(AASA)文件,并且其中包含针对 webcredentials 服务的、本应用 App ID 的条目。关于 apple-app-site-association 文件的更多信息,参见支持关联域。
  6. 在 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 配置项目

WeatherKit 中 WeatherService 实例提供的数据需要为 Food Truck All 目标做额外配置。如果不配置 WeatherKit,示例会检测到错误,并改用项目中的静态数据。

  1. 在描述文件门户上创建一个唯一的 App ID,并在 App Services 标签页中选择 WeatherKit 服务。
  2. 在 Xcode 中,对 Food Truck All 目标,在 Signing & Capabilities 标签页里把 bundle ID 改成与第 1 步的 App ID 相同,并添加 WeatherKit 能力。
  3. 对 Widgets 目标,在 Signing & Capabilities 标签页里修改 bundle ID,使 .Widgets 之前的部分与 Food Truck All 目标的 bundle ID 相同。
  4. 等待 30 分钟,让服务注册你的应用 bundle ID。
  5. 构建并运行 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)
        }
    }
}