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遵守Identifiable,List和ForEach能稳定区分每一行。Hashable让任务值能参与 SwiftUI 的数据比较和集合操作。ItemListModel遵守ObservableObject,@Published的items变化会驱动界面刷新。@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")让手表顶部清楚显示当前位置。
用 TextFieldLink 添加任务
(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)通过计算属性改变完成状态。
用 ShareLink 从 Watch 发起求助
(14:41)ShareLink 是 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 个数据点。- 当高亮索引靠近左侧边界时,窗口向左移动一位。
- 当高亮索引靠近右侧边界时,窗口向右移动一位。
max和min防止范围越过数组边界。chartData只把当前范围的数据传给Chart。
核心启发
-
做一个 Watch-only 打卡清单:把每天必须完成的 3 到 5 件事放在手表上。为什么值得做:session 说明 Watch-only App 适合功能聚焦、交互快速、数据有限的场景。怎么开始:用
ListItem、ObservableObject、List和TextFieldLink搭出添加和显示流程。 -
做一个抬腕即可更新的工作量估算器:每个任务只记录名称、预计天数和完成状态。为什么值得做:
Stepper让连续数值在小屏幕上可控,避免键盘输入数字。怎么开始:在Form里绑定Stepper(value:in:step:format:)和Toggle(isOn:)。 -
做一个向同伴求助的任务分享按钮:当某个任务卡住时,从详情页直接发给朋友。为什么值得做:
ShareLink在 watchOS 上接入系统分享表单,用户不用离开当前 App。怎么开始:在详情表单中添加ShareLink(item:subject:message:preview:)。 -
做一个只显示最近一周的完成趋势图:每天完成数量用柱状图显示,默认只显示 7 天。为什么值得做:演讲指出 Watch 屏幕要限制一次展示的数据量,柱状图适合单一数据系列。怎么开始:把完成日期聚合成
ChartData.DataElement,再用Chart和BarMark绘制。 -
做一个可用表冠浏览历史数据的图表:用户转动 Digital Crown 时移动高亮线和可见数据范围。为什么值得做:
digitalCrownRotation是手表上的自然输入方式,适合在小屏幕中浏览更长时间范围。怎么开始:用highlightedDateIndex、crownOffset、RuleMark和chartDataRange保存滚动状态。
关联 Session
- What’s new in SwiftUI — 了解 SwiftUI 在 WWDC 2022 新增的
ShareLink、Swift Charts、导航和布局能力。 - The SwiftUI cookbook for navigation — 深入学习演讲中提到的
NavigationStack和 SwiftUI 导航结构。 - Meet Transferable — 继续研究
ShareLink背后的分享数据模型。 - Hello Swift Charts — 从基础语法开始学习 Swift Charts。
- Swift Charts: Raise the bar — 学习图表坐标轴、标记和交互的进阶定制。
评论
GitHub Issues · utterances