Streaming is available in most browsers,
and in the Developer app.
-
Build widgets for the Smart Stack on Apple Watch
Follow along as we build a widget for the Smart Stack on watchOS 10 using the latest SwiftUI and WidgetKit APIs. Learn tips, techniques, and best practices for creating widgets that show relevant information on Apple Watch.
Chapters
- 0:58 - Get started
- 2:03 - Configure the widget
- 3:51 - Set up the timeline
- 10:16 - Build widget views
- 16:27 - Finish building timeline
- 19:58 - Provide relevant intents
Resources
Related Videos
WWDC23
-
Download
♪ ♪ Calvin: Hi. I'm Calvin Gaisford, an engineer on the watchOS team. In this code-along session, we'll build a widget for the new Smart Stack on Apple Watch. We'll walk you through the whole process of building a Widget using an AppIntent configuration. In the process, we'll be using the latest WidgetKit and SwiftUI updates. We'll use the Backyard Birds app for our code-along. Backyard Birds is an app that lets you create and manage backyards that attract visiting birds. Our widget will show a backyard's status. If a bird is visiting, it will show the bird and also include the backyard's status. The widget will provide relevant dates to the Smart Stack so it can prioritize our widget when it's most relevant. If you want to follow along, you can download the sample code associated with this session and open the Backyard Birds Xcode project. We've already added a Widget Extension to the project which generated some files, including the BackyardVisitorsWidget file. We'll spend most of our time updating this file. Here's what we'll cover. First, we'll take a look at the widget structure which defines our widget. We'll also do a quick overview of the widget configuration intent. Then, we'll customize our TimelineEntry structure to hold data for our widget views, and we'll build out our timeline. Once our timeline has the data we need to enable previews, we'll build out our widget's views. After our views are built, we'll return to finish up the timeline. Finally, we'll look at the Relevant Intent Manager and set intents for dates when our widget should be prioritized on the watchOS Smart Stack. Let's get started with the widget configuration by looking at the widget structure in our code.
The widget structure is where a widget's configuration is defined. New in watchOS are AppIntentConfigurations. We'll be using an AppIntentConfiguration in our widget. The configuration intent, provider, and view were all stubbed out when we created the WidgetExtension. We'll be looking at each one and implementing them for our Backyard Birds widget. This widget definition looks good, so let's move on and look at the WidgetConfigurationIntent. Our widget is using an App Intent Configuration to allow it to do two things. First, our widget can provide a set of pre-configured widgets in the watchOS widget gallery. In the case of Backyard Birds, we'll provide a configuration for each yard in our app. Second, the WidgetConfigurationIntent will be used to specify dates when our widget is most relevant. The Smart Stack will use this information to prioritize our widget in the Smart Stack. Let's look at our widget's Configuration App Intent. Each yard in Backyard Birds has a unique ID. I've already added a parameter named backyardID, so this can be used to create a set of widget intents, one for each yard identified by their backyard ID. For the widgets we're building, that's the only parameter we need. To learn more about App Intents and how to further use WidgetConfigurationIntent, check out these sessions about App Intents. We have our widget structure defined and a WidgetConfigurationIntent that can hold a backyard ID. Let's move on to our widget timeline and take a look at our TimelineEntry structure. The TimelineEntry structure will hold all of the data our widget views will need to render themselves for a particular date. Back in the BackyardVisitorsWidget file, locate the generated SimpleEntry structure.
The date and configuration properties were added when this file was generated. We need to define any additional properties our widget views will need. Our widget is going to show a backyard's status with its name, food, and water status. If a bird is visiting, it's going to show the visiting bird and the bird's name. If there isn't a bird, it will show how many birds have visited the yard. To display information about the yard, we'll use a structure from the Backyard Birds app that holds all of the information about a backyard for a given point in time. This is important since the TimelineEntry may have a future date. Let's add the backyard property to our TimelineEntry.
Now, let's add a few computed properties based on the backyard property we just added. First, let's add a bird property so the widget views can check if a bird is visiting and display it.
Now let's add two more properties that our views will use to show more information about the yard.
waterDuration and foodDuration will be used in the view to show how long the water and food will last. These are calculated from the date property in the TimelineEntry.
A TimelineEntry also has a property named relevance that, if implemented, can tell the watchOS Smart Stack which timeline entries are most important. Let's add the relevance property to our TimelineEntry.
Inside, let's check to see if the backyard has a visitor for the TimelineEntry's date.
If there is a visiting bird, we'll return a TimelineEntryRelevance structure.
The TimelineEntryRelevance structure takes two parameters, a score and a duration. The score is used to prioritize an entry against other entries in the same timeline. We'll set the score to 10 to rank an entry with a visitor higher than an entry without a visitor. This value is arbitrary and can have any range of values needed to rank entries in your timeline. The duration is used to tell the Smart Stack how long this relevance entry is valid. We've set the duration to last until the visitor's endDate. If there isn't a visitor, let's return a Relevance structure with a score of zero This will tell the Smart Stack which timeline entries are most important. Depending on what else is happening at the time, our Widget may be raised in priority on the watchOS Smart Stack.
Our TimelineEntry looks great and has everything our widget views need to render properly. Let's move on and build out our TimelineProvider. There are four functions we need to complete for our TimelineProvider: placeholder, snapshot, timeline, and recommendations. The placeholder function is used when the widget displays for the first time and should return quickly. Since we updated our TimelineEntry to take a backyard, we need to supply one. Let's fix that by adding a random backyard from the app's data model.
That's good. Let's move on. The snapshot function is used when a widget is in transient situations. This function should return quickly, so using sample data is fine as long as it doesn't take more than a few seconds to fetch. We can do that same thing we did in the placeholder function and pass a random yard.
That looks good, but we could do better. The snapshot function is passed a configuration intent which has the backyardID property that we added earlier. Our data is all local, so we can quickly look up and return the proper backyard rather than using a random backyard. Let's get the configured backyard from the backyardID in the configuration.
Now let's check the backyard and see if we can get a visitorEvent from it.
Let's return an entry configured with a visitor's date, and if we don't have a visitor, we'll return the configured yard with the current date.
This will provide a better preview for the user since it will show the configured backyard. Before we dive into the timeline function, I want to turn on the Xcode canvas preview. Let's fix the last SimpleEntry and give it a backyard so we can see the preview.
Now let's turn on the canvas.
New in Xcode is the ability to preview a widget timeline. The canvas is showing a preview of the rectangular widget, and at the bottom is a series of TimelineEntries that make up the widget's timeline. The canvas preview is using the default view that was generated when we added the widget. Before we finish the timeline provider, let's go build out our view so we can better visualize our timelines as we build it. Locate the BackyardBirdsWidgetEntryView. Let's add an environment property for the widgetFamily so we can build views specifically for each family.
Let's replace the body with a switch statement so we can implement a view for each accessoryWidget family.
Now, let's create a case for accessoryRectangular with a view that takes an entry as a parameter. We'll implement this view below.
The rectangular view is unique, in that it will be the view of our widget shown in the watchOS Smart Stack. For our rectangular view, we'll follow a common pattern where we'll have an image on the left and three lines of text on the right. Let's go to the bottom of the file and we'll create the RectangularBackyardView.
The view uses the TimelineEntry we modified earlier to contain the backyard data. Before we continue, let's switch our canvas view to the Smart Stack Rectangular view.
This will let us visualize the widget as we build it. Now let's put an image and three lines of text in our view's HStack.
Look at the preview. That's not exactly it. Let's put the lines of text in their own VStack.
Okay, that's close. Let's put actual data into the view from the entry. First, we'll use a ComposedBird view from the Backyard Birds app that can display a bird.
The bird is optional, so we need to unwrap it. Let's put the ComposedBird view and VStack inside an if-let check to see if there is a bird in the entry.
If there isn't a bird, let's put an image of a fountain for the yard and text that shows no bird.
We can now go through the timeline and see entries that show a bird with three lines of text and entries that show no bird.
Let's fill out the details for the case where there is a bird first. For the first line, we'll show the bird's name, the second line, the backyard's name, and the third line, we'll add information about the yard's food and water.
For the case where there is no bird, let's show the yard name, the food and water information, and then the number of visitors to this yard.
Let's see what our entries look like.
That's great, but let's fix up the layout a bit. First, let's update the ComposedBird. Let's make the view scaledToFit and make it widgetAccentable so it will tint when used on a watch face that is tinted. On the bird's name, let's add the headline font, make it scale, and also make it widgetAccentable so it tints with the watch face. We'll also make the text take on the color of the bird's wing using a foregroundStyle.
Let's add the scale factor to both of our other views in case the names go long.
Let's set the foregroundStyle of the last line to secondary.
Finally, let's make our stack leading aligned so our three text views are aligned.
Our view looks pretty good. Let's apply all of these same updates to the views in the else statement when there isn't a bird.
Now our widget is looking better.
Notice our widget has different spacing when showing a bird or a yard. Let's make those match by adding a frame to the bird view and the image view.
We'll also add frames to the VStacks so they align properly.
There's one more option we need to add to finish our widget for the watchOS Smart Stack. New in SwiftUI is the containerBackground. Let's replace the containerBackground with a gradient from the backyard. We'll set the containersBackground placement to be a widget.
The containerBackground is selectively used by the system and here will only appear in the watchOS Smart Stack and not on the watch face.
Now our view is ready for the watchOS Smart Stack. The view looks great, so let's go back to our TimelineProvider and finish building out the timeline. The timeline function is where a widget generates a collection of timeline entries that contain data to render the widget's view. This is the workhorse-function of a widget. Right now it's generating five entries with random backyard data. Let's replace that with a timeline full of bird visits. At the top of the function is an array of TimelineEntries. We'll use this to build our timeline. First, let's remove the generated timeline code.
Now, let's get the configured yard using the backyardID from the ConfigurationAppIntent.
The backyard structure has a property on it that contains all of the visitorEvents for that yard. Let's iterate on the visitorEvents for the retrieved yard. For each event, let's create a TimelineEntry that contains the startDate of the visitorEvent and pass in the configured backyard.
Our timeline preview updated. Let's see how it changed. Now as we select our timeline entries, we see birds appearing. That's what we expected. However, every entry has a visitor. We need to add entries for when the birds leave too. Let's create a second entry and use the visitorEvent's endDate. We'll use the same backyard and append the entry to the entries array.
Let's look at the timeline now.
Okay, we have entries for when the birds visit and when they leave. Our widget timeline looks great, and this new timeline preview is amazing. It's going to make building widgets and timelines a lot easier.
Finally, let's implement the timeline provider's recommendations function. Here, we need to return an array of AppIntentRecommendations which will contain our WidgetConfigurationIntent, which holds a backyardID. Let's remove the default implementation.
Let's create an array of recommendations to return.
Next, we want to create a recommendation for each backyard in our app, so let's iterate through all of the backyards.
For each backyard, we'll create a ConfigurationAppIntent and set the backyardID.
And finally, let's create an AppIntentRecommendation using the ConfigurationIntent and add it to our array. We'll us the backyard's name as a description.
The recommendations function will now provide a list of widget configurations, one for each backyard, in the widget gallery when a person is selecting a Backyard Birds widget. Congratulations. You've now built a widget on watchOS that will surface as a watch face complication and the watchOS Smart Stack. Earlier, we talked a little bit about relevance when we implemented the relevance property on our TimelineEntry, but there is more we can do. Each yard in Backyard Birds app keeps track of the water and food available for birds. Our new widget is also gonna show that information. We can provide the system a list of relevant intents during the time periods when we know the water or food is running low. Our widget will be prioritized during those times, letting people know their yards need attention. Back in our code, let's create a new function that will build relevant intents for any of our possible widgets and then update the RelevantIntentManager with those intents. We'll create a new function named updateBackyardRelevantIntents.
In the function, we need an array of relevantIntents.
And we'll update the RelevantIntentManager with that array.
To fill out the relevantIntents array, we'll loop through all of the backyards in the app. Next, we'll create a configurationIntent for the backyard and set the backyardID to the current backyard. We'll create a RelevantContext based on dates. In this case, we'll use the backyard's future low food date and the future empty food date.
Finally, we'll create a relevantIntent. We'll use the configurationIntent for our widget, our widget's kind, and the relevantDateContext we just created and append this to our array.
Now, let's do the same thing for the backyard's lowWater and emptyWater dates.
That looks good. Now the RelevantIntentManager has the date ranges when each possible configuration of our widget has a higher relevance. Let's go add this function into key components so the relevantIntents are updated when appropriate. First, let's go back to the timeline function in the timeline provider. Let's call our function just before we return the timeline.
This will keep the relevantIntents up-to-date every time we update our widget timeline. Let's also go over to the Backyard Birds app. The Backyard Birds app has a detail view for each yard and provides a page where a person can refill the food and water. This is also an ideal place to update the relevantIntents since the food and water supply can change. In the BackyardContentTab, we'll add a Task with our updateBackyardRelevantIntents function when the Refill button is tapped. Since we know the food and water has just been updated, we should also make a call into WidgetKit and reload our widget's timeline.
Now our relevant intents will be updated and our widget's timeline will reload when a person refills the water and food in a yard.
We've now built a widget for the watchOS Smart Stack and we've updated the RelevantIntentManager with date intents to prioritize our widget when it's most relevant. Thanks for following along. We look forward to seeing widgets you build for the watchOS Smart Stack. For more information about widgets, the Smart Stack, and App Intents, check out these sessions. Be adventurous and never stop coding.
-
-
4:15 - TimelineEntry
struct SimpleEntry: TimelineEntry { var date: Date var configuration: ConfigurationAppIntent var backyard: Backyard var bird: Bird? { return backyard.visitorEventForDate(date: date)?.bird } var waterDuration: Duration { return Duration.seconds(abs(self.date.distance(to: self.backyard.waterRefillDate))) } var foodDuration: Duration { return Duration.seconds(abs(self.date.distance(to: self.backyard.foodRefillDate))) } var relevance: TimelineEntryRelevance? { if let visitor = backyard.visitorEventForDate(date: date) { return TimelineEntryRelevance(score: 10, duration: visitor.endDate.timeIntervalSince(date)) } return TimelineEntryRelevance(score: 0) } }
-
7:50 - placeholder function
func placeholder(in context: Context) -> SimpleEntry { return SimpleEntry(date: Date(), configuration: ConfigurationAppIntent(), backyard: Backyard.anyBackyard(modelContext: modelContext)) }
-
8:15 - snapshot function
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { if let backyard = Backyard.backyardForID(modelContext: modelContext, backyardID: configuration.backyardID) { if let event = backyard.visitorEvents.first { return SimpleEntry(date: event.startDate, configuration: configuration, backyard: backyard) } else { return SimpleEntry(date: Date(), configuration: configuration, backyard: backyard) } } let yard = Backyard.anyBackyard(modelContext: modelContext) return SimpleEntry(date: Date(), configuration: ConfigurationAppIntent(), backyard: yard) }
-
10:26 - Widget Entry View
struct BackyardBirdsWidgetEntryView: View { @Environment(\.widgetFamily) private var family var entry: SimpleEntry var body: some View { switch family { case .accessoryRectangular: RectangularBackyardView(entry: entry) default: Text(entry.date, style: .time) } } }
-
11:23 - Backyard Rectangular View
struct RectangularBackyardView: View { var entry: SimpleEntry var body: some View { HStack { if let bird = entry.bird { ComposedBird(bird: bird) .scaledToFit() .widgetAccentable() .frame(width: 50, height: 50) VStack(alignment: .leading) { Text(bird.speciesName) .font(.headline) .foregroundStyle(bird.colors.wing.color) .widgetAccentable() .minimumScaleFactor(0.75) Text(entry.backyard.name) .minimumScaleFactor(0.75) HStack { Image(systemName: "drop.fill") Text(entry.waterDuration, format: remainingHoursFormatter) Image(systemName: "fork.knife") Text(entry.foodDuration, format: remainingHoursFormatter) } .imageScale(.small) .minimumScaleFactor(0.75) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) } else { Image(.fountainFill) .foregroundStyle(entry.backyard.backgroundColor) .imageScale(.large) .scaledToFit() .widgetAccentable() .frame(width: 50, height: 50) VStack(alignment: .leading) { Text(entry.backyard.name) .font(.headline) .foregroundStyle(entry.backyard.backgroundColor) .widgetAccentable() .minimumScaleFactor(0.75) HStack { Image(systemName: "drop.fill") Text(entry.waterDuration, format: remainingHoursFormatter) Image(systemName: "fork.knife") Text(entry.foodDuration, format: remainingHoursFormatter) } .imageScale(.small) .minimumScaleFactor(0.75) Text("\(entry.backyard.historicalEvents.count) visitors") .minimumScaleFactor(0.75) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) } } .containerBackground(entry.backyard.backgroundColor.gradient, for: .widget) } }
-
16:30 - Timeline Function
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> { var entries: [SimpleEntry] = [] if let backyard = Backyard.backyardForID(modelContext: modelContext, backyardID: configuration.backyardID) { for event in backyard.visitorEvents { let entry = SimpleEntry(date: event.startDate, configuration: configuration, backyard: backyard) entries.append(entry) let afterEntry = SimpleEntry(date: event.endDate, configuration: configuration, backyard: backyard) entries.append(afterEntry) } } return Timeline(entries: entries, policy: .atEnd) }
-
18:35 - Recommendations Function
func recommendations() -> [AppIntentRecommendation<ConfigurationAppIntent>] { var recs = [AppIntentRecommendation<ConfigurationAppIntent>]() for backyard in Backyard.allBackyards(modelContext: modelContext) { let configIntent = ConfigurationAppIntent() configIntent.backyardID = backyard.id.uuidString let gardenRecommendation = AppIntentRecommendation(intent: configIntent, description: backyard.name) recs.append(gardenRecommendation) } return recs }
-
20:47 - Relevant Intents Function
func updateBackyardRelevantIntents() async { let modelContext = ModelContext(DataGeneration.container) var relevantIntents = [RelevantIntent]() for backyard in Backyard.allBackyards(modelContext: modelContext) { let configIntent = ConfigurationAppIntent() configIntent.backyardID = backyard.id.uuidString let relevantFoodDateContext = RelevantContext.date(from: backyard.lowSuppliesDate(for: .food), to: backyard.expectedEmptyDate(for: .food)) let relevantFoodIntent = RelevantIntent(configIntent, widgetKind: "BackyardVisitorsWidget", relevance: relevantFoodDateContext) relevantIntents.append(relevantFoodIntent) let relevantWaterDateContext = RelevantContext.date(from: backyard.lowSuppliesDate(for: .water), to: backyard.expectedEmptyDate(for: .water)) let relevantWaterIntent = RelevantIntent(configIntent, widgetKind: "BackyardVisitorsWidget", relevance: relevantWaterDateContext) relevantIntents.append(relevantWaterIntent) } do { try await RelevantIntentManager.shared.updateRelevantIntents(relevantIntents) } catch { } }
-
23:00 - Update Relevant Intents
Task { await updateBackyardRelevantIntents() WidgetCenter.shared.reloadTimelines(ofKind: "BackyardVisitorsWidget") }
-
-
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.