Streaming is available in most browsers,
and in the Developer app.
-
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.
Resources
Related Videos
WWDC22
-
Download
Anne: Hello, and welcome! I'm Anne Hitchcock, and I'm a watchOS software engineer. Today, I want to show you how to create a productivity app on watchOS. Since the introduction of SwiftUI and Independent Watch apps in watchOS 6, you've been able to do more in your Watch apps. Each year, SwiftUI on watchOS gets more capabilities. At the same time, watchOS has gotten new features, like the keyboard, that let you build whole new kinds of apps for Watch. I'd like to show you how to knit together some of those features to build an app to track a list of things to get done. We're going to create a new Watch app, add a simple list of items to display, Let people add items to the list, and then edit the items.
As we add these features, we'll talk about common app navigation strategies in Watch apps and how to pick the correct one.
We'll share items with a friend to share the load.
Then we'll add a chart to our app to help us spot productivity trends and keep us motivated.
And we'll use the Digital Crown to make our chart scrollable to show a larger data range.
Let's get started by creating a new app.
Create a new project in Xcode.
In the watchOS tab, choose App and click Next.
After choosing a product name, you have a couple of choices. The most important one is whether to create a Watch-only App or to create a Watch app with a companion iOS app. Let's talk about what makes a great Watch app and when you want a companion iOS app.
Great Watch apps enable quick interaction, like the interface in Workout that allows you to start your favorite workouts quickly. No one wants to stand around, holding up their arm, tapping through to try to find something. Great Watch apps make it easy to access important information and features.
Great Watch apps focus on the essential purpose of the app.
For example, the Weather app displays the forecast for today, relevant current conditions, and a simple 10-day forecast.
Focus on the essentials in your app so people can easily find the information and actions they need.
A great Watch app is designed to be used independently of the companion iPhone. The Contacts app, for example, synchronizes with your phone, but doesn't require your iPhone to be nearby to access contact information on your Apple Watch.
There are many reasons you might also want a companion iOS app for your Watch app, including providing a historical record of data captured by Apple Watch or detailed analysis of trends, as in the Fitness app.
Since our app has a focused feature set, quick interaction, and limited data, we're going to create a Watch-only app.
At this point, I want to spend a few minutes talking about the targets that are created.
If you've built a Watch app in the past, your project has two targets for Watch: a WatchKit App target with a storyboard, assets, and perhaps some localization-related files, and a WatchKit Extension target with all of your app code. These dual targets are a holdover from the early days of watchOS, and there really isn't a good reason for multiple Watch targets anymore.
Starting in Xcode 14, new Watch apps have a single Watch app target. All of the code, assets, localizations, and Siri Intent and Widget extensions associated with your Watch app belong in this target.
The great news is that single-target Watch apps are supported back to watchOS 7! You can simplify your project structure and reduce confusion and duplication while still supporting customers who aren't running the latest watchOS.
If you have an existing app with a WatchKit Extension target, it will continue to work, and you can continue to update your app using Xcode and publish your app through the App Store.
If you already have a Watch app that uses the SwiftUI lifecycle, transitioning to a single target is easy using the migration tool in Xcode 14. Select your target and choose Validate Settings from the Editor menu. The target collapsing option will be offered if your deployment target is watchOS 7 or later.
If you haven't already made the leap, now is a good time to start the process of converting your app to use the SwiftUI life cycle to enjoy the simplicity of a single-target Watch app and all of the features of SwiftUI.
The targets aren't the only thing we simplified in Xcode 14! We've also made it a lot easier to add an icon for your app by only requiring a single 1024x1024 pixel image.
The app icon image will be scaled for display on all Watch devices.
Be sure to test with your app icon on devices on the home screen, in notifications, and in the settings for your app in the Watch app on the iPhone.
You can add custom images for specific smaller sizes if necessary. For example, if your app icon has details in the image that get lost at smaller sizes, you can add specific icon images for those sizes with the image details removed. Now let's add some functionality to our app by adding a list of task items. We'll start by creating a data model for our list of tasks. The ListItem struct will be Identifiable and Hashable, and we'll give it a description to display.
Then, create a simple model to store our data and publish the array of list items.
And finally, add the model as an environment object so our views can access our model.
Now let's create a List in SwiftUI with our data model. Since there are no tasks yet, when we preview this, we get an empty list.
We need to do something about that. We should give people a way to add some tasks to their list.
We'd like to add a Button that people can tap to add a new item to the list. Text field link, new in watchOS 9, lets you invoke text input options from a button, and offers several styling options to make it feel right at home in your app.
You can create a TextFieldLink with a simple string or use a Label for a more custom button.
Modify the button's appearance with view modifiers, including foregroundColor, foregroundStyle, and buttonStyle.
We'll create an AddItemLink view to encapsulate the styling and behavior of the TextFieldLink we're using in our app.
We'll use a custom label for the button, and when someone enters text, we'll add the new item to our list.
Now that we've decided to use TextFieldLink to add a button to add a new list item, we need to think about where to put the TextFieldLink.
When adding actions to lists in Watch apps, we have a couple of options. Use a button, navigation link, or TextFieldLink at the end of the list for primary actions in short lists. Adding an action as an item at the end of a list is a good choice for a primary action in a short list of items like the list of cities in World Clock. However, if you anticipate a long list of items, people will have to keep scrolling to the end of the list each time they want to do the action. For commonly used actions with longer lists, use a toolbar item.
To add a toolbar item, add the toolbar modifier to the list, and use your action view as the content. This will add a single toolbar item to the list with automatic toolbar item placement. While I'd like to think I'll always keep my to-do list short, I'm fairly sure that I won't. So I'm going to put the text field links in a toolbar item to make it easy to access.
Let's take a moment to review what we've accomplished. We've created a model for our list items, stored it as an environment object, created a list to display the items, and added a text field link to add new items.
Creating an item with only a description is simple, but it isn't very useful. We're going to need to mark the item as complete, and we might want a way to set a priority or add an estimate of the amount of work for a task. To do this, we'll add a detail view. Before we do this, I want to review the options for app navigation structure in SwiftUI on Watch. Hierarchical navigation is used for views with a list-detail relationship. Starting in watchOS 9, use the SwiftUI NavigationStack to create interfaces with this type of navigation structure.
Page-based navigation is used for views with a flat structure, where all of the views are peers.
A great example of page-based navigation is the in-workout view of the Workout app, where people can easily swipe between the workout controls, metrics, and playback controls during a workout.
A full-screen app has a single view that uses the entire display. This is generally used for apps like games and other apps that have a single main view.
For a full-screen view, use the ignoresSafeArea modifier to extend your content to the edges of the display, and the toolbar modifier with a visibility value of hidden to hide the navigation bar.
A modal sheet is a full-screen view that slides over the current view. It should be used for important tasks that should be completed as part of the current workflow.
It's important to differentiate when to use a hierarchical flow versus when to use a modal sheet.
Mail uses a hierarchical style to display the list of messages and show each message or thread as a detail view. There are actions you can do from the message detail, but there is nothing you must do before returning to the list.
If you go back to the list, and tap New Message, Mail uses a modal sheet to show the New Message view.
A modal sheet is the right choice because you need to fill in the details of the new message, or cancel, before continuing.
To display a modal sheet, create a property to control the sheet presentation state. Set the property based on an action in the user interface, and use the sheet modifier to display the custom modal sheet content when the presentation state property is true.
To add custom toolbar items to the modal sheet, add a toolbar with your items. Note that your toolbar items should use modal placements like confirmationAction, cancellationAction, and destructiveAction.
We're going to use the modal sheet for our detail view because we're editing an item and we want to focus on this single task until we've finished and tapped Done.
To learn more about navigation in SwiftUI, including more details about NavigationStack and programmatic navigation, check out "The SwiftUI cookbook for navigation." Now that we've decided how to navigate to our detail view, we'll update our list item struct. We have new properties to store estimated work, creation date, and completion date.
Let's give people a way to view and edit these details.
We'll create a detail view with a TextField to edit the description and a toggle to mark the task as complete or not. But what should we do with the estimated work? We know the values will all be numbers, and we can specify a range of valid values.
Beginning in watchOS 9, we can use the Stepper. The Stepper is a great option when you want to provide granular control to edit sequential values.
You can specify a range of values and optionally provide a step.
You can also use the Stepper to edit logically sequential, but not necessarily numeric values.
For example, maybe we want to note the estimated stress level for an item.
We could create an array of emoji to indicate the stress level, then create a Stepper, binding the value to the selected index in the stress level emoji array and setting the range to the range of emoji indices. Stepping through the values increases or decreases the stress level we're estimating for the item.
Preparing a WWDC session is fun, but sharing great Watch app development with all of you is a party. When I have stressful items on my list, or just a lot of items on my list that are making me feel stressed, I'd like to share an item from my list with a friend to ask for help.
We're going to add a button to our detail view to allow people to share an item using the share sheet. I want to be able to tap a button on my detail view to share the item, pick from a list of friends to ask for help, edit my message, and send it.
To do this, we're going to use a new tool available to us in SwiftUI on watchOS 9: ShareLink. We can share our list item by creating a ShareLink with our item. We can optionally customize the initial text of the message with a subject and message. And provide a Preview to display in the Share Sheet when someone shares the item. You can use ShareLink to share from your SwiftUI app in iOS, macOS, and watchOS.
Be sure to check out "Meet Transferable" to learn more details and options for ShareLink. Now that I can track when I completed items and call for help to get things done, I'd also like to add a chart to see my productivity. I've chosen to use a bar chart because I have a single data series and distinct data values.
A bar chart will clearly show this data on a Watch display as long as I limit the amount of data I display at one time. We're going to start by adding the chart view to our app's navigation structure. I've chosen a page-based navigation strategy because there is no list-detail relationship between the item list and the chart. Someone can swipe between the list and the chart at any time.
To add the page-based navigation for our list and chart, let's start by creating an ItemList struct to encapsulate the list view.
I moved the entire content of the content view to this new item list. Encapsulating the item list here will allow us to have simple, easy-to-read tab view code in the content view.
We also need to create a struct for our chart view.
I'll temporarily put in a placeholder so we can focus on our navigation structure before we build our chart.
Now we'll set up a content view with a page-style tab view with 2 tabs: the item list and the chart.
Since we've set up our navigation structure, let's talk about how to build this chart. I know I could use a SwiftUI Canvas and draw a chart, but starting in watchOS 9, we have an easier answer: Swift Charts. Swift Charts are also available on iOS, macOS, and tvOS, so you can reuse your charts anywhere you're using SwiftUI.
We'll aggregate the data we want to chart and then let Swift Charts display it for us.
For our chart, we want to show the number of items completed by date. We'll create a struct to store the aggregate data for the chart.
Then we'll write a small method to aggregate our list item data into chart data elements.
Display a simple chart by specifying the data to display and defining the series from the data. We're using the date as the x-value and the number of items completed as the y-value.
To achieve the appearance I want on my Watch display, I'm customizing the x-axis using the Chart's chartXAxis modifier. I'm specifying a format style for the axis value labels. I also don't want vertical gridlines, so I omitted an AxisGridLine mark. I'm also customizing the y-axis using the chartYAxis modifier. I specify a gridline style that looks good with my chart on Watch. I'm formatting the axis value labels as integers and omitting the top label to prevent it from being clipped at the top of the chart. To learn more about the amazing things you can achieve with Swift Charts, check out "Hello Swift Charts" and "Swift Charts: Raise the bar." Our chart looks pretty good, but I'd like to show a little more data but still keep a great Watch experience, so I'm going to make it scrollable. To accomplish this, we're going to use a new digitalCrownRotation modifier that allows us to set a callback for digital crown events, and we're going to implement a custom scrolling behavior for our chart.
Let's get ready to add the digitalCrownRotation modifier by adding some properties to store the state as someone scrolls across the chart.
The highlightedDateIndex is the index of the data point for the current scroll position.
We'll store the crown offset so we can display the current crown position as the person is scrolling across the chart. This is an intermediate value, on or between data points, while the crown is moving.
To keep track of whether someone is actively scrolling, we'll store the the idle state. We'll use this information to add a little animation as crown scrolling stops and starts.
Now that we have the properties to store values, we can add the digitalCrownRotation modifier.
We'll bind the detent value to the highlightedDateIndex property.
In mechanical terms, a detent is a mechanism that holds something in a position until enough force is applied to move it. For instance, when I open my car door, there is a "stop" position where the door will settle. I can push a little harder and open the door wider to another "stop." To close it, I need to pull hard enough to overcome the resistance to pull it out of the "stop." Otherwise, it will spring back into that resting position. This is a detent. The stop for the car door helps us understand detent in this API. The detent is the resting notch position of the crown on your view.
In the handler for the onChange callback, we'll set the value for isCrownIdle to false, since we know that the crown is scrolling, and we'll set the crownOffset value to the current value to let us show the current position on the chart during scrolling.
In the handler for the onIdle callback, we'll set the value for isCrownIdle to true.
Now we can display the position of the crown as we scroll on the chart.
To do this, we can use the RuleMark from Swift Charts. A RuleMark is a straight line on your chart. You can use it to display a horizontal or vertical line, to display a threshold, for example, or to display a sloped line.
We're going to create a RuleMark with the crown offset date value to display the current location of crown scrolling.
Just to make this look a little better, I'd like to have the crown position line fade when the crown stops moving. It's simple to animate this using the isCrownIdle property we added.
We'll add a property to store the opacity for the color we're using in the foregroundStyle for the RuleMark.
And add an onChange modifier to the chart to animate the crownPositionOpacity value change when the isCrownIdle value changes.
Then update the foregroundStyle for the RuleMark to use the opacity.
To display the value next to the bar on the chart as we scroll, we can add an annotation to the BarMark. We'll position the annotation on the top leading side of the bar when it's the last bar. Otherwise, we'll position it on the top trailing side.
Let's take a moment to see what we've accomplished with just the digitalCrownRotation modifier, the RuleMark in Swift Charts, and a simple SwiftUI animation.
The final step to creating our custom scrollable chart is adjusting the data range for the chart as someone scrolls. Create a property to store the visible range.
Create the chartData variable to provide the data in the range to the chart. When the highlightedDateIndex changes, call a method to check the chartDataRange and update it if necessary.
As someone scrolls across the chart using the Digital Crown, the chart will scroll to display the available data.
Now we've finished implementing all the features we had planned.
To learn more about the new SwiftUI features available in watchOS 9, check out "What's New in SwiftUI." As you're planning your Watch app, or your new Watch app features, think about what makes a great Watch app experience.
While you're designing your app, consider your app navigation strategy to ensure that your app is easy and intuitive. And use SwiftUI for simpler and richer development options. Keep building great Watch apps. And remember, because of you, there's an app for that!
-
-
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) }
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.