WWDC Quick Look 💓 By SwiftGGTeam
Build a productivity app for Apple Watch

Build a productivity app for Apple Watch

观看原视频

Highlight

Xcode 14 将新建 watchOS App 简化为单一 Watch App target,watchOS 9 的 TextFieldLink、Stepper、ShareLink、Swift Charts 和 digitalCrownRotation 让开发者能在手表上完成任务录入、编辑、分享和趋势图浏览。

核心内容

Apple Watch 上的生产力 App 不能照搬 iPhone。用户抬腕时间短,任务要快,信息要少,入口要清楚。演讲一开始就给出三个标准:快速交互、容易拿到重要信息、围绕核心目的设计(01:59)。

这场 session 用一个待办列表 App 串起完整流程:创建 Watch-only App,显示任务列表,添加任务,编辑任务,分享任务,再用图表查看完成趋势。每一步都对应 watchOS 9 或 Xcode 14 的一个具体变化。

第一项变化在工程结构。Xcode 14 新建 Watch App 时使用单一 Watch app target,代码、资源、本地化、Siri Intent 和 Widget extension 都放在这个 target 里;这个结构支持到 watchOS 7(03:31)。图标也简化为一张 1024x1024 图片,系统会缩放到不同设备尺寸(05:20)。

第二项变化在交互控件。TextFieldLink 负责从按钮进入文本输入,Stepper 负责在小屏幕上调连续值,ShareLink 负责打开分享表单,Swift Charts 负责把完成记录画成柱状图。最后,digitalCrownRotation 把图表变成适合手表的可滚动视图。

详细内容

用单一数据模型驱动任务列表

06:12)演示先建立一个最小数据模型。每条任务需要可识别、可哈希,并保存用于显示的描述文本。

struct ListItem: Identifiable, Hashable {
    
    let id = UUID()
    var description: String
    
    init(_ description: String) {
        self.description = description
    }
}

class ItemListModel: NSObject, ObservableObject {
    @Published var items = [ListItem]()
}

@main
struct WatchTaskListSampleApp: App {
    
    @StateObject var itemListModel = ItemListModel()
    
    @SceneBuilder var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(itemListModel)
        }
    }
}

关键点:

  • ListItem 遵守 IdentifiableListForEach 能稳定区分每一行。
  • Hashable 让任务值能参与 SwiftUI 的数据比较和集合操作。
  • ItemListModel 遵守 ObservableObject@Publisheditems 变化会驱动界面刷新。
  • @StateObject 在 App 入口创建模型,生命周期跟随 App scene。
  • .environmentObject(itemListModel) 把模型注入视图树,列表行和详情页都能读取同一份数据。

06:37)有了模型以后,界面可以直接绑定数组元素。空列表时显示提示,避免用户看到一块没有含义的空白区域。

struct ContentView: View {
    @EnvironmentObject private var model: ItemListModel
    
    var body: some View {
        List {
            ForEach($model.items) { $item in
                ItemRow(item: $item)
            }
            
            if model.items.isEmpty {
                Text("No items to do!")
                    .foregroundStyle(.gray)
            }
        }
        .navigationTitle("Tasks")
    }
}

关键点:

  • @EnvironmentObject 读取入口处注入的 ItemListModel
  • ForEach($model.items) 迭代绑定数组,行视图拿到的是可写绑定。
  • ItemRow(item: $item) 让单行可以修改对应任务。
  • model.items.isEmpty 分支给空状态提供文案。
  • .navigationTitle("Tasks") 让手表顶部清楚显示当前位置。

06:59)watchOS 9 新增 TextFieldLink。它表现为一个按钮,点按后调用系统文本输入能力,提交后把输入字符串交给闭包。

struct AddItemLink: View {
    @EnvironmentObject private var model: ItemListModel
    
    var body: some View {
        TextFieldLink(prompt: Text("New Item")) {
            Label("Add",
                  systemImage: "plus.circle.fill")
        } onSubmit: {
            model.items.append(ListItem($0))
        } 
    }
}

关键点:

  • TextFieldLink(prompt:) 设置文本输入界面的提示。
  • Label("Add", systemImage: "plus.circle.fill") 给按钮同时提供文字和 SF Symbol。
  • onSubmit 闭包接收用户输入的字符串。
  • model.items.append(ListItem($0)) 把输入直接转成列表项。

07:57)按钮放在哪里也会影响体验。短列表可以把主要动作放到列表末尾;长列表的常用动作更适合放进 toolbar,避免每次都滚到底部。

struct ContentView: View {
    @EnvironmentObject private var model: ItemListModel
    
    var body: some View {
        List {
            ForEach($model.items) { $item in
                ItemRow(item: $item)
            }
            
            if model.items.isEmpty {
                Text("No items to do!")
                    .foregroundStyle(.gray)
            }
        }
        .toolbar {
            AddItemLink()
        }
        .navigationTitle("Tasks")
    }
}

关键点:

  • .toolbar 把添加入口交给系统布局。
  • AddItemLink() 复用前面封装好的文本输入入口。
  • 自动 toolbar placement 会在 watchOS 列表里选择合适位置。

用 sheet 和 Stepper 编辑任务

09:40)任务详情属于编辑流程。演讲把导航结构分成层级导航、页面导航、全屏视图和 modal sheet。编辑任务需要用户完成或取消后再回到列表,所以这里使用 sheet。

struct ItemRow: View {
    @EnvironmentObject private var model: ItemListModel
    
    @Binding var item: ListItem
    @State private var showDetail = false
    
    var body: some View {
        Button {
            showDetail = true
        } label: {
            HStack {
                Text(item.description)
                    .strikethrough(item.isComplete)
                Spacer()
                Image(systemName: "checkmark").opacity(item.isComplete ? 100 : 0)
            }
        }
        .sheet(isPresented: $showDetail) {
            ItemDetail(item: $item)
                .toolbar {
                    ToolbarItem(placement: .confirmationAction) {
                        Button("Done") {
                            showDetail = false
                        }
                    }
                }
        }
    }
}

关键点:

  • @Binding var item 让行视图编辑原数组中的任务。
  • showDetail 控制 sheet 是否显示。
  • Button 点按后把 showDetail 设为 true
  • .sheet(isPresented:) 在当前流程上方展示详情页。
  • ToolbarItem(placement: .confirmationAction) 给 modal sheet 放置完成按钮。

12:36)详情页加入估算工作量、创建日期和完成日期。isComplete 用计算属性封装完成状态,切换开关时写入或清除 completionDate

struct ListItem: Identifiable, Hashable {
    
    let id = UUID()
    var description: String
    var estimatedWork: Double = 1.0
    var creationDate = Date()
    var completionDate: Date?
    
    init(_ description: String) {
        self.description = description
    }

    var isComplete: Bool {
        get {
            completionDate != nil
        }
        set {
            if newValue {
                guard completionDate == nil else { return }
                completionDate = Date()
            } else {
                completionDate = nil
            }
        }
    }
}

关键点:

  • estimatedWork 保存任务预计工作量。
  • creationDate 记录任务创建时间。
  • completionDate 为可选值,nil 代表未完成。
  • isComplete 的 getter 从 completionDate 推导状态。
  • isComplete 的 setter 在完成时写入当前时间,取消完成时清空时间。

13:11)watchOS 9 的 Stepper 适合在手表上编辑连续值。它可以处理数字,也可以处理逻辑上有顺序的离散值。

struct ItemDetail: View {
    @Binding var item: ListItem
    
    var body: some View {
        Form {
            Section("List Item") {
                TextField("Item", text: $item.description, prompt: Text("List Item"))
            }
            Section("Estimated Work") {
                Stepper(value: $item.estimatedWork,
                        in: (0.0...14.0),
                        step: 0.5,
                        format: .number) {
                    Text("\(item.estimatedWork, specifier: "%.1f") days")
                }
            }
            
            Toggle(isOn: $item.isComplete) {
                Text("Completed")
            }
        }
    }
}

关键点:

  • Form 把编辑项组织成适合 watchOS 的表单。
  • TextField 绑定 item.description,用户能修改任务名称。
  • Stepper(value:in:step:format:) 把估算值限制在 0.0...14.0
  • step: 0.5 每次递增或递减半天。
  • Toggle(isOn: $item.isComplete) 通过计算属性改变完成状态。

14:41ShareLink 是 watchOS 9 中 SwiftUI 的分享入口。这个 App 用它分享某个任务,并预填主题、消息和预览。

struct ItemDetail: View {
    @Binding var item: ListItem
    
    var body: some View {
        Form {
            Section("List Item") {
                TextField("Item", text: $item.description, prompt: Text("List Item"))
            }
            Section("Estimated Work") {
                Stepper(value: $item.estimatedWork,
                        in: (0.0...14.0),
                        step: 0.5,
                        format: .number) {
                    Text("\(item.estimatedWork, specifier: "%.1f") days")
                }
            }
            
            Toggle(isOn: $item.isComplete) {
                Text("Completed")
            }
            
            ShareLink(item: item.description,
                      subject: Text("Please help!"),
                      message: Text("(I need some help finishing this.)"),
                      preview: SharePreview("\(item.description)"))
            .buttonStyle(.borderedProminent)
            .buttonBorderShape(.roundedRectangle)
            .listRowInsets(
                EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
            )
        }
    }
}

关键点:

  • ShareLink(item:) 指定要分享的内容,这里是任务描述字符串。
  • subject 给分享消息提供主题。
  • message 给分享内容提供初始正文。
  • SharePreview 控制分享表单里的预览标题。
  • .buttonStyle(.borderedProminent) 让分享按钮在表单中更醒目。

用 Swift Charts 显示完成趋势

15:45)任务列表和趋势图没有 list-detail 关系。演讲选择 page-based navigation,让用户在列表页和图表页之间横向滑动。

struct ContentView: View {
    var body: some View {
        TabView {
            NavigationStack {
                ItemList()
            }
            NavigationStack {
                ProductivityChart()
            }
        }.tabViewStyle(.page)
    }
}

关键点:

  • TabView 承载两个同级页面。
  • 每个页面都放入自己的 NavigationStack
  • .tabViewStyle(.page) 使用 watchOS 上适合横向切换的页面样式。

17:00)Swift Charts 在 watchOS 9 可用。这里先把任务按完成日期聚合,再用 BarMark 绘制每日完成数量。

/// Aggregate data for charting productivity.
struct ChartData {
    struct DataElement: Identifiable {
        var id: Date { return date }
        let date: Date
        let itemsComplete: Double
    }
    
    /// Create aggregate chart data from list items.
    /// - Parameter items: An array of list items to aggregate for charting.
    /// - Returns: The chart data source.
    static func createData(_ items: [ListItem]) -> [DataElement] {
        return Dictionary(grouping: items, by: \.completionDate)
            .compactMap {
                guard let date = $0 else { return nil }
                return DataElement(date: date, itemsComplete: Double($1.count))
            }
            .sorted {
                $0.date < $1.date
            }
    }
}

关键点:

  • DataElement 是图表的一条聚合数据。
  • id 使用 date,同一天的数据对应同一个标识。
  • Dictionary(grouping:by:) 按完成日期分组。
  • compactMap 跳过 completionDate == nil 的未完成任务。
  • Double($1.count) 把当天完成数量转成图表使用的数值。
  • sorted 让图表按日期顺序显示。
struct ProductivityChart: View {
       
    let data = ChartData.createData(
        ListItem.chartSampleData)
             
    var body: some View {
        Chart(data) { dataPoint in
            BarMark(
                x: .value("Date", dataPoint.date),
                y: .value("Completed", dataPoint.itemsComplete)
            )
            .foregroundStyle(Color.accentColor)
        }
        .navigationTitle("Productivity")
        .navigationBarTitleDisplayMode(.inline)
    }
}

关键点:

  • Chart(data) 把聚合数组交给 Swift Charts。
  • BarMark 选择柱状图,适合单一数据系列和离散值。
  • x 使用日期,y 使用完成数量。
  • .foregroundStyle(Color.accentColor) 使用 App 的强调色绘制柱子。
  • .navigationBarTitleDisplayMode(.inline) 减少导航标题占用的空间。

用 Digital Crown 滚动图表

18:50)Watch 屏幕一次不适合显示太多柱子。演讲用 digitalCrownRotation 监听表冠,把当前索引、表冠偏移和 idle 状态存进 SwiftUI state。

struct ProductivityChart: View {
       
    let data = ChartData.createData(
        ListItem.chartSampleData)

    /// The index of the highlighted chart value. This is for crown scrolling.
    @State private var highlightedDateIndex: Int = 0

    /// The current offset of the crown while it's rotating. This sample sets the offset with
    /// the value in the DigitalCrownEvent and uses it to show an intermediate
    /// (between detents) chart value in the view.
    @State private var crownOffset: Double = 0.0

    @State private var isCrownIdle = true
  
    var body: some View {
        chart
            .focusable()
            .digitalCrownRotation(
                detent: $highlightedDateIndex,
                from: 0,
                through: data.count - 1,
                by: 1,
                sensitivity: .medium
            ) { crownEvent in
                isCrownIdle = false
                crownOffset = crownEvent.offset
            } onIdle: {
                isCrownIdle = true
            }
            .navigationTitle("Productivity")
            .navigationBarTitleDisplayMode(.inline)
    }
}

关键点:

  • highlightedDateIndex 保存当前停靠的数据点索引。
  • crownOffset 保存表冠滚动过程中的中间偏移值。
  • isCrownIdle 区分正在滚动和已经停止。
  • .focusable() 让视图接收 Digital Crown 输入。
  • digitalCrownRotation(detent:from:through:by:sensitivity:) 把表冠停靠点映射到数据索引。
  • onChange 闭包内记录滚动偏移。
  • onIdle 闭包内标记滚动停止。

21:07)为了显示表冠当前位置,图表加入 RuleMark。它是一条直线,适合标示阈值或当前选择位置。

/// The date value that corresponds to the crown offset.
private var crownOffsetDate: Date {
    let dateDistance = data[0].date.distance(
        to: data[data.count - 1].date) * (crownOffset / Double(data.count - 1))
    return data[0].date.addingTimeInterval(dateDistance)
}

private var chart: some View {
    Chart(data) { dataPoint in
        BarMark(
            x: .value("Date", dataPoint.date),
            y: .value(
                "Completed", 
                dataPoint.itemsComplete)
        )
        .foregroundStyle(Color.accentColor)
             
        RuleMark(x: .value("Date", crownOffsetDate))
            .foregroundStyle(Color.appYellow)
    }
    .chartXAxis {
        AxisMarks(format: shortDateFormatStyle)
    }
}

关键点:

  • crownOffsetDate 把表冠偏移换算成日期。
  • data[0].date.distance(to:) 取得图表数据的时间跨度。
  • addingTimeInterval 从起始日期推导当前滚动位置。
  • RuleMark(x:) 在当前日期位置画一条竖线。
  • AxisMarks(format:) 用自定义日期格式显示 x 轴标签。

22:44)最后一步是根据当前索引移动可见数据范围。这样用户转动表冠时,图表窗口会跟着滚动。

@State var chartDataRange = (0...6)

private func updateChartDataRange() {
    if (highlightedDateIndex - chartDataRange.lowerBound) < 2, chartDataRange.lowerBound > 0 {
        let newLowerBound = max(0, chartDataRange.lowerBound - 1)
        let newUpperBound = min(newLowerBound + 6, data.count - 1)
        chartDataRange = (newLowerBound...newUpperBound)
        return
    }
    if (chartDataRange.upperBound - highlightedDateIndex) < 2, chartDataRange.upperBound < data.count - 1 {
        let newUpperBound = min(chartDataRange.upperBound + 1, data.count - 1)
        let newLowerBound = max(0, newUpperBound - 6)
        chartDataRange = (newLowerBound...newUpperBound)
        return
    }
}

private var chartData: [ChartData.DataElement] {
    Array(data[chartDataRange.clamped(to: (0...data.count - 1))])
}

关键点:

  • chartDataRange 保存当前显示的 7 个数据点。
  • 当高亮索引靠近左侧边界时,窗口向左移动一位。
  • 当高亮索引靠近右侧边界时,窗口向右移动一位。
  • maxmin 防止范围越过数组边界。
  • chartData 只把当前范围的数据传给 Chart

核心启发

  • 做一个 Watch-only 打卡清单:把每天必须完成的 3 到 5 件事放在手表上。为什么值得做:session 说明 Watch-only App 适合功能聚焦、交互快速、数据有限的场景。怎么开始:用 ListItemObservableObjectListTextFieldLink 搭出添加和显示流程。

  • 做一个抬腕即可更新的工作量估算器:每个任务只记录名称、预计天数和完成状态。为什么值得做:Stepper 让连续数值在小屏幕上可控,避免键盘输入数字。怎么开始:在 Form 里绑定 Stepper(value:in:step:format:)Toggle(isOn:)

  • 做一个向同伴求助的任务分享按钮:当某个任务卡住时,从详情页直接发给朋友。为什么值得做:ShareLink 在 watchOS 上接入系统分享表单,用户不用离开当前 App。怎么开始:在详情表单中添加 ShareLink(item:subject:message:preview:)

  • 做一个只显示最近一周的完成趋势图:每天完成数量用柱状图显示,默认只显示 7 天。为什么值得做:演讲指出 Watch 屏幕要限制一次展示的数据量,柱状图适合单一数据系列。怎么开始:把完成日期聚合成 ChartData.DataElement,再用 ChartBarMark 绘制。

  • 做一个可用表冠浏览历史数据的图表:用户转动 Digital Crown 时移动高亮线和可见数据范围。为什么值得做:digitalCrownRotation 是手表上的自然输入方式,适合在小屏幕中浏览更长时间范围。怎么开始:用 highlightedDateIndexcrownOffsetRuleMarkchartDataRange 保存滚动状态。

关联 Session

评论

GitHub Issues · utterances