-
Build a productivity app for Apple Watch
Your wrist has never been more productive. Discover how you can use SwiftUI and system features to build a great productivity app for Apple Watch. We'll show you how you can design great work experiences for the wrist, and explore how you can get text input, display a basic chart, and share content with friends.
Recursos
Vídeos relacionados
WWDC22
-
Buscar neste vídeo...
-
-
6:12 - Initial ListItem struct
struct ListItem: Identifiable, Hashable { let id = UUID() var description: String init(_ description: String) { self.description = description } } -
6:24 - ItemListModel
class ItemListModel: NSObject, ObservableObject { @Published var items = [ListItem]() } -
6:30 - Add the ItemListModel as an EnvironmentObject
@main struct WatchTaskListSampleApp: App { @StateObject var itemListModel = ItemListModel() @SceneBuilder var body: some Scene { WindowGroup { ContentView() .environmentObject(itemListModel) } } } -
6:37 - Create a simple SwiftUI List
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") } } -
7:11 - TextFieldLink with a simple String
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink("Add") { model.items.append(ListItem($0)) } } .navigationTitle("Tasks") } } -
7:16 - TextFieldLink with a Label
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink { Label( "Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } } .navigationTitle("Tasks") } } -
7:20 - TextFieldLink with foregroundStyle modifier
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink { Label( "Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } .foregroundStyle(.tint) } .navigationTitle("Tasks") } } -
7:27 - TextFieldLink with buttonStyle modifier
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink { Label( "Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } .buttonStyle(.borderedProminent) } .navigationTitle("Tasks") } } -
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)) } } } -
8:38 - Add a toolbar item to allow people to add new list items
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") } } -
11:40 - Display a modal 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) } } } -
11:58 - Display a modal sheet with custom toolbar items
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 } } } } } } -
12:36 - Add more properties to the ListItem
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 } } } } -
12:48 - Create the ItemDetail View with the 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") } } } } -
13:29 - A Stepper with Emoji
// Use a Stepper to edit the stress level of an item struct StressStepper: View { private let stressLevels = [ "😱", "😡", "😳", "🙁", "🫤", "🙂", "🥳" ] @State private var stressLevelIndex = 5 var body: some View { VStack { Text("Stress Level") .font(.system(.footnote, weight: .bold)) .foregroundStyle(.tint) Stepper(value: $stressLevelIndex, in: (0...stressLevels.count-1)) { Text(stressLevels[stressLevelIndex]) } } } } -
14:43 - Add a ShareLink to the ItemDetail View
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) ) } } } -
16:39 - Page-style TabView with navigation titles for each page
struct ContentView: View { var body: some View { TabView { NavigationStack { ItemList() } NavigationStack { ProductivityChart() } }.tabViewStyle(.page) } } -
17:20 - ChartData struct for aggregate data
/// 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 } } } -
17:36 - Static sample data for chart and basic bar chart
extension ChartData { /// Some static sample data for displaying a `Chart`. static var chartSampleData: [DataElement] { let calendar = Calendar.autoupdatingCurrent var startDateComponents = calendar.dateComponents( [.year, .month, .day], from: Date()) startDateComponents.setValue(22, for: .day) startDateComponents.setValue(5, for: .month) startDateComponents.setValue(2022, for: .year) startDateComponents.setValue(0, for: .hour) startDateComponents.setValue(0, for: .minute) startDateComponents.setValue(0, for: .second) let startDate = calendar.date(from: startDateComponents)! let itemsToAdd = [ 6, 3, 1, 4, 1, 2, 7, 5, 2, 0, 5, 2, 3, 9 ] var items = [DataElement]() for dayOffset in (0..<itemsToAdd.count) { items.append(DataElement( date: calendar.date(byAdding: .day, value: dayOffset, to: startDate)!, itemsComplete: Double(itemsToAdd[dayOffset]))) } return items } } 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) } } -
17:50 - Chart with chartXAxis modifier
struct ProductivityChart: View { let data = ChartData.createData( ListItem.chartSampleData) private var shortDateFormatStyle = DateFormatStyle(dateFormatTemplate: "Md") var body: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } } /// `ProductivityChart` uses this type to format the dates on the x-axis. struct DateFormatStyle: FormatStyle { enum CodingKeys: CodingKey { case dateFormatTemplate } private var dateFormatTemplate: String private var formatter: DateFormatter init(dateFormatTemplate: String) { self.dateFormatTemplate = dateFormatTemplate formatter = DateFormatter() formatter.locale = Locale.autoupdatingCurrent formatter.setLocalizedDateFormatFromTemplate(dateFormatTemplate) } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) dateFormatTemplate = try container.decode(String.self, forKey: .dateFormatTemplate) formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate(dateFormatTemplate) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(dateFormatTemplate, forKey: .dateFormatTemplate) } func format(_ value: Date) -> String { formatter.string(from: value) } } -
19:05 - Add the digitalCrownRotation modifier
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 private var chart: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } 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) } } -
21:07 - Add a RuleMark to the Chart to show the current Digital Crown position
/// 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) } } -
21:37 - Add animation to dim the crown position line when the scrolling idle state changes
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 @State var crownPositionOpacity: CGFloat = 0.2 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.opacity(crownPositionOpacity)) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } 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 } .onChange(of: isCrownIdle) { newValue in withAnimation(newValue ? .easeOut : .easeIn) { crownPositionOpacity = newValue ? 0.2 : 1.0 } } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } } -
22:14 - Add an annotation to the bar chart to display the current value
private func isLastDataPoint(_ dataPoint: ChartData.DataElement) -> Bool { data[chartDataRange.upperBound].id == dataPoint.id } private var chart: some View { Chart(chartData) { dataPoint in BarMark(x: .value("Date", dataPoint.date, unit: .day), y: .value("Completed", dataPoint.itemsComplete)) .foregroundStyle(Color.accentColor) .annotation( position: isLastDataPoint(dataPoint) ? .topLeading : .topTrailing, spacing: 0 ) { Text("\(dataPoint.itemsComplete, format: .number)") .foregroundStyle(dataPoint.date == crownOffsetDate ? Color.appYellow : Color.clear) } RuleMark(x: .value("Date", crownOffsetDate, unit: .day)) .foregroundStyle(Color.appYellow.opacity(crownPositionOpacity)) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } -
22:44 - Make the chart data range scrollable
@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))]) } private var chart: some View { Chart(chartData) { dataPoint in BarMark(x: .value("Date", dataPoint.date, unit: .day), y: .value("Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) .annotation( position: isLastDataPoint(dataPoint) ? .topLeading : .topTrailing, spacing: 0 ) { Text("\(dataPoint.itemsComplete, format: .number)") .foregroundStyle(dataPoint.date == crownOffsetDate ? Color.appYellow : Color.clear) } RuleMark(x: .value("Date", crownOffsetDate, unit: .day)) .foregroundStyle(Color.appYellow.opacity(crownPositionOpacity)) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } 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 } .onChange(of: isCrownIdle) { newValue in withAnimation(newValue ? .easeOut : .easeIn) { crownPositionOpacity = newValue ? 0.2 : 1.0 } } .onChange(of: highlightedDateIndex) { newValue in withAnimation { updateChartDataRange() } } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) }
-