跳转至

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.

img

Tap the station name to pick a new location:

img

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:

img

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:

img

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:

img

Tap Edit, then swipe left three times so you can pick the complication you want to replace:

img

Tap one of the corner locations to see the list of complications you may choose from:

img

Finally, scroll to the bottom to see your complication:

img

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:

  1. Your complication will always take some data model. For the sample project, that’s a Tide.
  2. The type of tide is represented with an image. That image is what will appear in the corner of the complication’s display.
  3. 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.
  4. The provided Tide model includes a convenient placeholder function to generate fake data.
  5. 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:

img

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:

img

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 widgetLabelmodifier, 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:

img

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:

img

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.

img

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:

img

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:

img

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.

img

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:

  1. You’re returning data from the MeasurementStation class that knows about the various locations you can display data against.
  2. Each of the MeasurementStation objects needs to be transformed to a new type, so you’re passing the array through a map.
  3. The method signature says you’ll be returning objects of type ConfigurationIntent, so create one :]
  4. Xcode created a StationChoice intent object for you based on adding the StationChoice type in the intent configuration.
  5. 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:

img

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:

  1. onOpenURL triggers when the view receives a URL.
  2. The URL you’re sending expects the last element to be the station ID.
  3. Validate that the URL scheme and the host are correct, and that you’ve actually received a station identifier.
  4. 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.