Chapters

Hide chapters

watchOS With SwiftUI by Tutorials

Second Edition · watchOS 9 · Swift 5.8 · Xcode 14.3

Section I: watchOS With SwiftUI

Section 1: 13 chapters
Show chapters Hide chapters

8. Complications
Written by Scott Grosch

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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 watchOS tab. For the Product Name, enter TideWatch Widget. Be sure to select Include Configuration Intent if it’s not already selected:

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.

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 the recommendations 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.

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.")

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))
  }
}

ZStack {
  AccessoryWidgetBackground()
  tide.image()
    .font(.title.bold())
}

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))
  }
}

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))
  }
}

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())
}

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…

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))
  }
}

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
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")
  }
}
let tide: Tide
EntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), tide: Tide.placeholder()))
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.

.widgetAccentable()

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.

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.

// 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)
  }

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.

private func url() -> URL {
  guard let stationId = entry.configuration.station?.identifier else {
    return URL(string: "tidewatch://station/NOTASTATION")!
  }

  return URL(string: "tidewatch://station/\(stationId)")!
}
.widgetURL(url())
AccessoryCircularView(tide: entry.tide)
  .widgetURL(url())
// 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) }
  }
}

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.

Freshness

Great work! You implemented your first complication. Have you noticed the issue with the data? You never provided content.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now