8.Complications¶
Written by Scott Grosch
Exploring the Sample¶
Please build and run the TideWatch app from this chapter’s starter materials. After a moment, you’ll see current tide conditions at the Point Reyes tide station in California.
Tap the station name to pick a new location:
Though the app is useful as designed, your customers have to open the app to find the current water level. Wouldn’t it be better if they could see the information right on their watch face?
Adding a Widget Extension¶
In Xcode, choose File ▸ New ▸ Target…, then select Widget Extension from the watchOStab. For the Product Name, enter TideWatch Widget. Be sure to select Include Configuration Intent if it’s not already selected:
Xcode will place all of the widget-related code into a single file named TideWatch_Widget. The first thing you’ll want to do is separate the single file into individual files. The rest of this chapter will assume you’ve done so and refer to the files accordingly.
Note
The current version of Xcode gets confused and will make the target membership of your new files TideWatch Watch App instead of TideWatch WidgetExtension, resulting in numerous compiler errors.
You’ll end up with a structure that looks like this:
In Provider, edit the signature of getTimeline(for:in:completion:)
. The generic parameter Entry
needs to be updated to say SimpleEntry
to resolve the compiler error.****
Ensure your project builds without problems before moving on.
If, by chance, you decide to name your project Widget, you’ll get a confusing compiler error due to the existing protocol of the same name. If you wish to use “Widget” as the name of your project, you’ll need to explicitly include the full protocol name:
struct Widget: SwiftUI.Widget {
Timeline Provider¶
When watchOS wants to update the data displayed for your widget, it’ll look to your timeline provider. watchOS expects you to provide either an IntentTimelineProvider
or TimelineProvider
struct. The former should be used when you wish to allow configuration of your widgets, such as picking what tide station is displayed. The latter will be used when there are no options to choose.
Both protocols expect you to implement four methods.
placeholder(in:)¶
When the user selects your widget, watchOS needs some type of placeholder data to present. Use placeholder(in:)
to provide whatever is appropriate for your widget. Note that you should not perform any expensive operations in this method. Just generate some random data as quickly as possible.
getSnapshot(for:in:completion:)¶
When watchOS is transitioning between views, this method will be called. You can use this method to present real data based on the selection if you wish. If you can determine the “current” data promptly, then show real data. Otherwise, just return placeholder data. Remember, the person will only be on the edit screen for a few moments, so not having correct data isn’t a concern.
getTimeline(for:in:completion:)¶
Widgets will update the data presented at specific moments. You use the getTimeline(for:in:completion:)
method to let watchOS know precisely what data should be used at what time.
recommendations¶
When using widgets with iOS, a configuration is done through the intentdefinition file. Unfortunately, watchOS does not support the same level of configuration. Instead, use therecommendations
method to return each specific configuration that you support.
Shared Code¶
The starter project includes a Shared folder containing code used by both the primary app and the extension. Please select all files within the Shared folder and add the widget extension to their target membership.
Configure the Widget¶
Switch the active scheme to TideWatch WidgetExtension and then build and run again. Using the widget scheme will launch the simulator directly to the watch face.
Tap and hold on the watch face, and the editor will appear:
Tap Edit, then swipe left three times so you can pick the complication you want to replace:
Tap one of the corner locations to see the list of complications you may choose from:
Finally, scroll to the bottom to see your complication:
That doesn’t look great. Let’s make it useful!
Configuring the Display¶
Look in TideWatch_Widget and you’ll see the defaults that Xcode used when populating the body
. The configurationDisplayName
and description
modifiers are part of WatchKit, but appear to not have any effect with watchOS. However, that’ll likely change in a future version, so put better values there:
.configurationDisplayName("TideWatch")
.description("Show current tide conditions.")
This Widget
is just a wrapper around TideWatch_WidgetEntryView
. I feel that the name is too long, so open TideWatch_WidgetEntryView. That’s a long name, so I’ll rename it to just EntryView. Right-click on TideWatch_WidgetEntryView
, then choose Refactor ▸ Rename…. For the new name, use EntryView
. Switch to the EntryView file.
A complication can show four types of displays, known as a family. You’ll need to present different information depending on which family the user selects.
Accessory Corner¶
The Metropolitan watch face contains four corner locations that you can select, known as the accessory corner family. Create a new SwiftUI View file named AccessoryCornerView and paste the following contents:
import SwiftUI
import WidgetKit
struct AccessoryCornerView: View {
// 1
let tide: Tide
var body: some View {
// 2
tide.image()
// 3
.widgetLabel {
Text(tide.heightString(unitStyle: .long))
}
}
}
struct AccessoryCornerView_Previews: PreviewProvider {
static var previews: some View {
// 4
AccessoryCornerView(tide: Tide.placeholder())
// 5
.previewContext(WidgetPreviewContext(family: .accessoryCorner))
}
}
In the preceding code:
- Your complication will always take some data model. For the sample project, that’s a
Tide
. - The type of tide is represented with an image. That image is what will appear in the corner of the complication’s display.
- When using an accessory corner, you’ll want the text to curve with the display. You accomplish that by placing your text inside a
widgetLabel
modifier. - The provided
Tide
model includes a convenientplaceholder
function to generate fake data. - You specify that the preview is for a
WidgetPreviewContext
and explicitly state which family is being used.
Looking at the canvas in the top left, you’ll see what your complication will look like:
The image is tiny and is just floating in space. Replace the image line with this:
ZStack {
AccessoryWidgetBackground()
tide.image()
.font(.title.bold())
}
Now you end up with something nicer:
By setting a .title.bold()
font, you’ve increased the visibility of the image, and placing an AccessoryWidgetBackground
below it adds a nice circular backdrop.
You’ll create a few of these views, so in TideWatch Widget, create a group called Templatesto put them in. Move AccessoryCornerView into that group and add the rest as you create them.
Accessory Circular¶
Create another SwiftUI View named AccessoryCircularView, using the following contents:
import SwiftUI
import WidgetKit
struct AccessoryCircularView: View {
var tide: Tide
var body: some View {
VStack {
tide.image()
.font(.title.bold())
Text(tide.heightString())
.font(.headline)
.foregroundColor(.blue)
}
}
}
struct AccessoryCircularView_Previews: PreviewProvider {
static var previews: some View {
AccessoryCircularView(tide: Tide.placeholder())
.previewContext(WidgetPreviewContext(family: .accessoryCircular))
}
}
The circular view presents a small area to display your data. For the TideWatch app, it makes sense to put an image above the text. Using a headline
font makes for a nice size and spruces things up with a blue color. I hope your water is blue.
Notice that you’re specifying the accessoryCircular
value in the preview provider.
The accessory circular view is used in many watch faces. Some will allow a widgetLabel
modifier, like what you used in the accessory corner modifier. Others won’t. By using the .showsWidgetLabel
environment property, you can write conditional code to handle both cases.
Two families down, two to go.
Accessory Inline¶
New to watchOS 9 is the accessory inline family. Many of the watch faces provide an area for a single line of text. Depending on the watch face, the amount of area available differs. In a newly created AccessoryInlineView file, paste the following:
import SwiftUI
import WidgetKit
struct AccessoryInlineView: View {
let tide: Tide
var body: some View {
Text("\(tide.heightString()) and \(tide.type.rawValue) as of \(tide.date.formatted(date: .omitted, time: .shortened))")
}
}
struct AccessoryInline_Previews: PreviewProvider {
static var previews: some View {
AccessoryInlineView(tide: Tide.placeholder())
.previewContext(WidgetPreviewContext(family: .accessoryInline))
}
}
Xcode’s preview will display something like this:
Looks great. But on a different type of watch face, you won’t have that much space. SwiftUI provides a solution for that, available in watchOS 9. By wrapping text with the new ViewThatFits
keyword, Xcode will select the text that fully fits in the available space. Replace the contents of body
with the following:
ViewThatFits {
Text("\(tide.heightString()) and \(tide.type.rawValue) as of \(tide.date.formatted(date: .omitted, time: .shortened))")
Text("\(tide.heightString()), \(tide.type.rawValue), \(tide.date.formatted(date: .omitted, time: .shortened))")
Text("\(tide.heightString()), \(tide.type.rawValue)")
Text(tide.heightString())
}
Now, depending on the available space, you’ll provide appropriate information without the text being chopped.
Accessory Rectangular¶
The fourth and final complication family is accessory rectangular. The rectangular family provides a large rectangular space available for anything you might need to display, such as a chart, multiple lines of text, etc…
Paste the following into a newly created AccessoryRectangularView file:
import SwiftUI
import WidgetKit
struct AccessoryRectangularView: View {
let tide: Tide
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(tide.heightString(unitStyle: .long))
.font(.headline)
.foregroundColor(.blue)
Text(tide.date.formatted(date: .omitted, time: .shortened))
.font(.caption)
Text(tide.type.rawValue.capitalized)
.font(.caption)
}
tide.image()
.font(.headline.bold())
}
}
}
struct AccessoryRectangularView_Previews: PreviewProvider {
static var previews: some View {
AccessoryRectangularView(tide: Tide.placeholder())
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
}
}
The rectangular view accommodates about three lines of text. With this family, you can present the tide height, when the measurement was taken, the type of tide and then include the image representing the tide type:
Now that you’ve defined views for all of the complication families, you need to tell watchOS how to select the proper one.
Using the Defined Complications¶
Switch back to EntryView. Remember: This is the view the widget will display. watchOS will provide the current family being used through the widgetFamily
environment value. Add the following property:
@Environment(\.widgetFamily) private var family
Then, replace the contents of the body
with a switch
statement:
var body: some View {
switch family {
case .accessoryCircular:
AccessoryCircularView(tide: entry.tide)
case .accessoryCorner:
AccessoryCornerView(tide: entry.tide)
case .accessoryInline:
AccessoryInlineView(tide: entry.tide)
case .accessoryRectangular:
AccessoryRectangularView(tide: entry.tide)
@unknown default:
Text("Unsupported widget")
}
}
Now, when watchOS needs to display your complication, it’ll select the proper view based on the family.
Why the @unknown default
element? While you’ve implemented all of the options in your switch
statement, watchOS 10 might add another family. By having the unknown default you ensure your code doesn’t crash.
At this point, you’ll see multiple complication errors due to tide
not being defined. Edit SimpleEntry and add a new property:
let tide: Tide
Next, edit TideWatch_Widget and update the preview provider to include the new parameter:
EntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), tide: Tide.placeholder()))
Finally, in Provider, update all the calls to the SimpleEntry
constructor to include the tide:
SimpleEntry(date: entryDate, configuration: configuration, tide: Tide.placeholder())
The Fruits of Your Labor¶
Open TideWatch_Widget and you’ll see one watch face. At the bottom of the window, you’ll see an icon with six small squares. Click on that and then choose Widget Family Variants. Xcode will update the canvas to include four watch faces, one for each type of widget.
Tinting¶
You’ll notice that instead of picking Widget Family Variants you could have chosen Tint Variants. Remember that by default, the Apple Watch is set to full color mode. However, many of your users will instead switch to a tinted theme.
Switch back to the AccessoryRectangularView file and then select the Tint Variants option. Xcode will show you what your complication looks like for each color:
Right after the line that sets the foreground to blue, add a new modifier:
.widgetAccentable()
Xcode will now show the tide height in the user’s selected tint:
You can apply the modifier to as many items as you wish. You might want, for example, to also apply the tinting to the image.
Station Selection¶
Now, your users have no way to select which measurement station they’d like to use for the complication. Supporting that isn’t hard — it just requires multiple steps.
Configure the Intent¶
Open the TideWatch_Widget file that’s not a Swift file. It’s the one that looks somewhat like an infinity symbol inside of a circle. If you’re familiar with Siri and/or Intents, it’ll look familiar. If not, don’t fear.
At the bottom of the white part of the Xcode window, click the + button and choose New Type, naming the type StationChoice.
Next, click on the Configuration line and add a parameter named station. Set the type to Station Choice. Finally, uncheck the Siri can ask for value when run checkbox and check the Options are provided dynamically checkbox.
By performing the above steps, you have access to a station
property on the intent configuration passed to your Widget
.
Configure the Provider¶
Now edit the recommendations()
method, in Provider, that was mentioned at the start of the chapter. You must return an intent recommendation for every element you wish the user to be able to select when adding your complication. In this case, that means one item per measurement station.
Replace the body of the recommendations
method with the following code:
// 1
return MeasurementStation
.allStations
// 2
.map { station in
// 3
let intent = ConfigurationIntent()
// 4
intent.station = StationChoice(identifier: station.id, display: station.name)
// 5
return IntentRecommendation(intent: intent, description: station.name)
}
Here’s what’s happening:
- You’re returning data from the
MeasurementStation
class that knows about the various locations you can display data against. - Each of the
MeasurementStation
objects needs to be transformed to a new type, so you’re passing the array through amap
. - The method signature says you’ll be returning objects of type
ConfigurationIntent
, so create one :] - Xcode created a
StationChoice
intent object for you based on adding theStationChoice
type in the intent configuration. - Finally, you return the new recommendation, providing the intent. The descriptionparameter is what will be used for the name when selecting the complication.
If you build and run, look at the available complications again. You’ll see new choices:
watchOS will only display a few choices for your app, then will add a More button that allows you to pick the rest of the stations. Pick one of the stations and go back to the watch face to see the data for that station.
Starting Properly¶
You have one last piece to handle. When you tap on a complication, your app is launched. Ensure that the proper measurement station is selected at startup.
Edit EntryView and add the following method:
private func url() -> URL {
guard let stationId = entry.configuration.station?.identifier else {
return URL(string: "tidewatch://station/NOTASTATION")!
}
return URL(string: "tidewatch://station/\(stationId)")!
}
By looking at the entry.configuration
property, you have access to the selected station. By using the given identifier
you create a deep link URL for your app.
Next, tell the views to use that URL by adding the following modifier to each of the accessory views in EntryView
:
.widgetURL(url())
For example:
AccessoryCircularView(tide: entry.tide)
.widgetURL(url())
Build and run again. When you tap the complication, the widgetURL
call sets the URL to open in the containing app, which is your watchOS app.
Finally, edit ContentView in the TideWatch Watch App. At the end of the body, add one more modifier:
// 1
.onOpenURL { url in
// 2
let stationId = url.lastPathComponent
// 3
guard
url.scheme == "tidewatch",
url.host == "station",
stationId != "/",
!stationId.isEmpty
else {
return
}
// 4
if let station = MeasurementStation.station(for: stationId) {
Task { await model.fetch(newStation: station) }
}
}
It’s relatively straightforward:
onOpenURL
triggers when the view receives a URL.- The URL you’re sending expects the last element to be the station ID.
- Validate that the URL scheme and the host are correct, and that you’ve actually received a station identifier.
- If the provided station identifier is known, then call code to fetch the data for that station and update the display.
In this sample project, some lag occurs as the station data is downloaded again at open. In a production app, you’d likely have the current data cached somewhere, or maybe you pass it in as part of the URL configuration.
Privacy and Luminance¶
When the watch goes inactive on watches that have an always-on display, it switches to a low luminance mode. By default, the content isn’t redacted in a low luminance state. However, users can modify that setting.
Use the .isLuminanceReduced
environment variable to determine whether the Apple Watch has moved into an always-on state with the luminance reduced. Depending on what is displayed via your complication, you might wish to change what is visible in this state.
Remove any time-sensitive content, such as counters, when in low luminance. Updates will not happen as frequently, meaning your values will be wrong.
You’ll also want to redact sensitive information at times. By default, privacy mode will show a redacted version of your placeholder view. It’s possible, however, that not everything is sensitive content. If that’s the case, you might apply the .privacySensitive()
modifier to those elements that should not be shown. Other content on the complication will then still be visible when in redacted mode.
Freshness¶
Great work! You implemented your first complication. Have you noticed the issue with the data? You never provided content.
In the next chapter, you’ll learn how to provide data and keep it up-to-date.