Chapters

Hide chapters

SwiftUI Apprentice

Second Edition · iOS 16 · Swift 5.7 · Xcode 14.2

Section I: Your First App: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your Second App: Cards

Section 2: 9 chapters
Show chapters Hide chapters

25. Widgets
Written by Audrey Tam

Ever since Apple showed off its new home screen widgets in the 2020 WWDC Platforms State of the Union, everyone has been creating them. It’s definitely a useful addition to TheMet, providing convenient and quick access to objects listed in your app.

Note: The WidgetKit API continues to evolve at the moment, which may result in changes that break your code. Apple’s template code has changed a few times since the 2020 WWDC demos. You might still experience some instability. That said, Widgets are cool and a ton of fun!

Getting Started

▸ Open the starter project or continue with your app from the previous chapter.

WidgetKit

WidgetKit is Apple’s API for adding widgets to your app. The widget extension template helps you create a timeline of entries. You decide what app data you want to display and the time interval between entries.

You also define a view for each size of widget — small, medium, large, extra large — you want to support. The extra large size is available only in iPadOS.

Widget timeline
Widget timeline

Here’s a typical workflow for creating a widget:

  1. Add a widget extension to your app. Configure the widget’s display name and description.

  2. Select or adapt a data model type from your app to display in the widget. Create a timeline entry structure — a Date plus your data model type. Create sample data for snapshot and placeholder entries.

  3. Decide which widget sizes to support: Create small, medium or large views to display one or more data model values. In iOS 16, you can also create accessory views to display on the device’s lock screen.

  4. Create a timeline to deliver timeline entries. Decide on the refresh policy.

Adding a Widget Extension

➤ Start by adding a widget extension with File ▸ New ▸ Target….

Create a new target.
Create a new target.

➤ Search for widget, select Widget Extension and click Next:

Search for 'widget'.
Search for 'widget'.

➤ Name it TheMetWidget, select your team and make sure Include Live Activity and Include Configuration Intent are not checked:

Don't select Include Live Activity or Include Configuration Intent.
Don't select Include Live Activity or Include Configuration Intent.

A Live Activity display shows an app’s most current data on the iPhone Lock Screen and in the Dynamic Island. This chapter doesn’t implement Live Activity.

There are two widget configurations: Static and Intent. A widget with IntentConfiguration uses Siri intents to let the user customize widget parameters. Your widget for TheMet will be static.

Note: IntentConfiguration is covered in our tutorial Getting Started With Widgets

➤ Click Finish and agree to the activate-scheme dialog:

Activate scheme for new widget extension.
Activate scheme for new widget extension.

Configuring Your Widget

A new target group named TheMetWidget appears in the Project navigator. It contains two Swift files.

TheMetWidgetBundle.swift is like TheMetApp.swift, but it instantiates your widget instead of the first view of your app. The @main attribute means this is the widget’s entry point.

@main
struct TheMetWidgetBundle: WidgetBundle {
  var body: some Widget {
    TheMetWidget()
  }
}

➤ Open TheMetWidget.swift, then find TheMetWidget and edit the last two modifiers: configurationDisplayName(_:) and description(_:).

struct TheMetWidget: Widget {  // 1
  let kind: String = "TheMetWidget"

  var body: some WidgetConfiguration {
    StaticConfiguration(
      kind: kind,
      provider: Provider()  // 2
    ) { entry in
      TheMetWidgetEntryView(entry: entry)  // 3
    }
    // 4
    .configurationDisplayName("The Met")
    .description("View objects from the Metropolitan Museum.")
  }
}

Here’s what the template code does:

  1. The structure’s name and its kind property are the name you gave it when you created it.
  2. You define your widget’s timeline, snapshot and placeholder entries in Provider.
  3. You create your widget view(s) in TheMetWidgetEntryView.
  4. In this structure, you only need to customize the name to The Met and the description to View objects from the Metropolitan Museum. Your users will see these in the widget gallery.

Doing a Trial Run

The widget template provides a lot of boilerplate code you simply have to customize. It works right out of the box, so you can try it out now to make sure everything runs smoothly when you’re ready to test your code.

➤ You can try out your widget in a simulator. If you want to install your app on your iOS device, you need to sign both targets. In the Project navigator, select the top level TheMet folder. Use your organization instead of “com.yourcompany” in the bundle identifiers and set the team for each target.

Note: Your widget’s bundle ID prefix must be the same as your app’s. This isn’t a problem with TheMet but, if your project has different bundle IDs for Debug, Release and Beta, you’ll need to edit your widget’s bundle ID prefix to match.

➤ TheMetWidget is a second target, and it’s probably the currently selected scheme. Make sure you select the TheMet scheme, then build and run. Tap the Home button in the simulator tool bar to close the app, then press on some empty area of your home window until you see delete buttons on the app icons.

Note: The app’s scheme TheMet might disappear from the scheme menu. If this happens, select Manage Schemes… from the menu, then click Autocreate Schemes Now:

If necessary, autocreate TheMet scheme.
If necessary, autocreate TheMet scheme.

➤ Tap + in the upper left corner. If you’ve installed the app on a device, your gallery looks something like this:

Widget gallery on iPhone
Widget gallery on iPhone

➤ The quickest way to find your widget is to start typing TheMet in the Search Widgets field:

Search for your widget.
Search for your widget.

➤ Select TheMet to see snapshots of the three sizes:

Snapshots of the three widget sizes.
Snapshots of the three widget sizes.

➤ Tap Add Widget to see your widget on the screen:

Your widget on the home screen.
Your widget on the home screen.

➤ Tap Done in the upper right corner.

➤ Tap the widget to reopen TheMet.

Your widget works! Now, you simply have to make it display information from TheMet.

➤ Close the app then long-press the widget to open its menu and select Remove Widget. Get into the habit of removing the widget after you’ve confirmed it’s working. This is especially important if you’ve installed the app on your device. While you’re developing your widget, it will display a new view every three seconds, and that’s a real drain on your battery.

Creating Entries From Your App’s Data

It makes sense for your widget to display some of the information your app shows for each object, using the properties in Object.swift.

Adding App Files to the Widget Target

➤ In TheMetWidget.swift, find SimpleEntry. Add this line below date:

let object: Object

Your widget will display an object from the app’s list.

An Xcode error appears, because the widget doesn’t know about Object. You need to add Object.swift to the widget target.

➤ In Project navigator, select Object.swift. Show the File inspector and check the Target Membership box for TheMetWidgetExtension:

Add Object.swift to widget target.
Add Object.swift to widget target.

Provider Methods

Adding the object property to SimpleEntry causes errors in Provider because it creates SimpleEntry instances in its methods placeholder(in:), getSnapshot(in:completion:), getTimeline(in:completion:) and also in the preview. Provider methods are called by WidgetKit, not by any code you write.

  • To display your widget for the first time, WidgetKit calls placeholder(in:) and applies a redacted(reason: .placeholder) modifier to mask the view’s contents. This method is synchronous: Nothing else can run on its queue until it finishes, so don’t do any network downloads or complex calculations in this method.
  • WidgetKit calls getSnapshot(in:completion:) whenever the widget is in a transient state, waiting for data or appearing in the widget gallery.
  • WidgetKit calls getTimeline(in:completion:) to get an array of time-stamped entries to display.

Creating Sample Objects

First, you need a sample Object for the parameter value.

➤ In Object.swift, add this extension to Object:

extension Object {
  static func sample(isPublicDomain: Bool) -> Object {
    if isPublicDomain {
      return Object(
        objectID: 452174,
        title: "Bahram Gur Slays the Rhino-Wolf",
        creditLine: "Gift of Arthur A. Houghton Jr., 1970",
        objectURL: "https://www.metmuseum.org/art/collection/search/452174",
        isPublicDomain: true,
        primaryImageSmall: "https://images.metmuseum.org/CRDImages/is/original/DP107178.jpg")
    } else {
      return Object(
        objectID: 828444,
        title: "Hexagonal flower vase",
        creditLine: "Gift of Samuel and Gabrielle Lurie, 2019",
        objectURL: "https://www.metmuseum.org/art/collection/search/828444",
        isPublicDomain: false,
        primaryImageSmall: "")
    }
  }
}

This method returns a sample object, either in the public domain or not. The method is static, so you can call it with Object.sample(isPublicDomain: true).

➤ Now, in TheMetWidget.swift, fix the errors one by one, or use the handy shortcut Control-Option-Command-F for Editor ▸ Fix All Issues to insert the missing argument. Replace all the Object placeholders in TheMetWidget.swift with:

Object.sample(isPublicDomain: true)

Then, in getSnapshot(in:completion:), change true to false so you’ll be able to see which one appears in the widget gallery.

Creating Widget Views

When you’ve decided what data to display, you need to define a widget view to display it. It would be nice to display the primary image of an object in your widget view, but AsyncImage(url:) doesn’t work in a widget, so you’ll simply display the object’s title.

➤ Create a new SwiftUI View file named WidgetView.swift, making sure you set TheMetWidgetExtension as its target:

Create new SwiftUI view file for widget view.
Create new SwiftUI view file for widget view.

➤ Replace the content of WidgetView with this:

let entry: Provider.Entry

var body: some View {
  Text(entry.object.title)
}

The widget needs an Entry to display and, to start, you’ll display the object’s title.

➤ Now, fix the preview: Copy the content of TheMetWidget_Previews from TheMetWidget.swift, then change TheMetWidgetEntryView to WidgetView:

WidgetView(
  entry: SimpleEntry(
    date: Date(),
    object: Object.sample(isPublicDomain: true)))
  .previewContext(WidgetPreviewContext(family: .systemSmall))

➤ To use WidgetPreviewContext, you need to import WidgetKit:

import WidgetKit

➤ Refresh the preview to see what you’ve got:

Simplest small widget
Simplest small widget

Note: Xcode might fail to build, complaining that “Embedded binary is not signed with the same certificate as the parent app” or “Reference to invalid associated type ‘Entry’ of type ‘Provider’”. Changing Timeline<Entry> to Timeline<SimpleEntry> in the getTimeline(in:completion:) signature in TheMetWidget.swift can get rid of this problem.

It works! Now, to make it look more like the app’s list view, you’ll need the WebIndicatorView from ContentView.swift and the metBackground color defined in Assets and ColorExtension.swift.

➤ From TheMet group, add Assets.xcassets and ColorExtension.swift to the widget target.

Instead of adding the whole ContentView.swift to the widget target, you’ll move WebIndicatorView to a separate SwiftUI View file, along with PlaceholderView.

➤ In TheMet group, add a new SwiftUI View file named SupportingViews.swift, delete its structures, then move WebIndicatorView from ContentView.swift and PlaceholderView from ObjectView.swift into your new file. Add SupportingViews.swift to the widget target.

➤ Back in WidgetView.swift, add this structure:

struct DetailIndicatorView: View {
  let title: String

  var body: some View {
    HStack(alignment: .firstTextBaseline) {
      Text(title)
      Spacer()
      Image(systemName: "doc.text.image.fill")
    }
  }
}

The app displays a detail view for public-domain objects, with some text and an image. By the end of this chapter, you’ll implement a deep-link to the object’s detail view, so here you include a little system image to suggest what the user will see.

➤ Now, in WidgetView, replace Text(entry.object.title) with the following:

VStack {
  Text("The Met")  // 1
    .font(.headline)
    .padding(.top)
  Divider()  // 2

  if !entry.object.isPublicDomain {  // 3
    WebIndicatorView(title: entry.object.title)
      .padding()
      .background(Color.metBackground)
      .foregroundColor(.white)
  } else {
    DetailIndicatorView(title: entry.object.title)
      .padding()
      .background(Color.metForeground)
  }
}
.truncationMode(.middle)  // 4
.fontWeight(.semibold)

Here’s what you’re doing:

  1. You can’t use NavigationStack in a widget view, so you create your own title with headline font size and top padding to push it away from the top edge.
  2. You add a divider line, to make it look more like a title.
  3. You display the object’s title so it looks similar to how it appears in the app’s list.
  4. You apply truncationMode and fontWeight to the VStack so it works for both WebIndicatorView and DetailIndicatorView.

➤ Refresh the preview to see your improved widget:

Small widget: public-domain object
Small widget: public-domain object

A Group of Previews

You can preview both sample objects by creating a Group:

➤ In WidgetView_Previews, replace the contents of previews with:

Group {
  WidgetView(
    entry: SimpleEntry(
      date: Date(),
      object: Object.sample(isPublicDomain: true)))
  .previewContext(WidgetPreviewContext(family: .systemSmall))
  // non-public-domain sample object
  WidgetView(
    entry: SimpleEntry(
      date: Date(),
      object: Object.sample(isPublicDomain: false)))
  .previewContext(WidgetPreviewContext(family: .systemSmall))
}

This embeds WidgetView in a Group, duplicates it and its .previewContext modifier, and changes true to false.

➤ In the preview canvas, select the second Widget View to see the WebIndicator view:

Small widget: non-public-domain object
Small widget: non-public-domain object

➤ Now, in the second WidgetView, change .systemSmall to .systemMedium:

Medium widget: non-public-domain object
Medium widget: non-public-domain object

This is how your WidgetView layout looks in the medium size widget.

➤ To see how the medium size might be useful, go to Object.swift and replace the title of the public-domain object:

title: "\"Bahram Gur Slays the Rhino-Wolf\", Folio 586r from the Shahnama (Book of Kings) of Shah Tahmasp",

This is the full title that the app downloads.

➤ And back to WidgetView.swift to change .systemSmall to .systemMedium in the first WidgetView:

Medium widget: public-domain object with very long title
Medium widget: public-domain object with very long title

➤ Finally, in the first WidgetView, change .systemMedium to .systemLarge:

Large widget: public-domain object with very long title
Large widget: public-domain object with very long title

Supporting Widget Sizes

If you think one of the sizes looks best, or if you definitely don’t want to support one of the sizes, you can restrict your widget to specific size(s). For TheMet, long titles look better in the medium or large size.

➤ In TheMetWidget, add this modifier to StaticConfiguration, below description(_:):

.supportedFamilies([.systemMedium, .systemLarge])

Finally, you need to set up TheMetWidgetEntryView to use WidgetView. Replace the body contents with this code:

WidgetView(entry: entry)

Here, you set WidgetView as the view to use when you want to display content.

➤ And in previews, change .systemSmall to .systemMedium or .systemLarge:

.previewContext(WidgetPreviewContext(family: .systemMedium))

➤ Make sure the scheme is TheMet, then build and run the app. After it launches, close the app.

If you had a small widget installed before this, it’s now gone. And, when you add a widget, the small size isn’t an option:

Widget gallery: medium or large
Widget gallery: medium or large

Notice the gallery uses the non-public-domain sample object, which means it’s calling getSnapshot(in:completion:).

Note: If your widget doesn’t appear in the gallery, or doesn’t work correctly, delete the app then build and run again. If the problem persists, restart the simulator or device.

➤ Add a widget, then tap the screen or the Done button to exit screen-editing mode.

Widget displays timeline entry.
Widget displays timeline entry.

The widget view displays the SimpleEntry you set up in getTimeline(in:completion:).

Providing a Timeline Of Entries

The heart of your widget is the Provider method getTimeline(in:completion:). It delivers an array of time-stamped entries for WidgetKit to display. The template code creates an array of five entries one hour apart:

let currentDate = Date()
for hourOffset in 0 ..< 5 {
  let entryDate = Calendar.current.date(
    byAdding: .hour,
    value: hourOffset,
    to: currentDate)!
  let entry = SimpleEntry(
    date: entryDate,
    object: Object.sample(isPublicDomain: true))
  entries.append(entry)
}

This code creates each entry with the same Object.sample. You’ll modify the method so it displays items in the app’s objects array. Waiting an hour between entries is no good for testing purposes, so you’ll shorten the interval to a few seconds.

First, you must populate your objects array.

Creating a Local TheMetStore

The quickest way — fewest lines of code — to get objects is to create an instance of TheMetStore in the widget.

➤ In TheMetWidget.swift, add these properties to Provider:

let store = TheMetStore(6)
let query = "persimmon"

While debugging, you limit the number of downloaded objects to a small number. You set the query term to something that returns objects with distinct titles.

➤ To get rid of the error flags, add TheMetStore.swift, TheMetService.swift and URLComponentsExtension.swift from TheMet group to the widget target.

➤ Now, in getTimeline(in:completion:), replace the for loop with the following code:

let interval = 3

Task {  // 1
  do {
    try await store.fetchObjects(for: query)
  } catch {
    store.objects = [
      Object.sample(isPublicDomain: true),
      Object.sample(isPublicDomain: false)
    ]
  }
}

for index in 0 ..< store.objects.count {
  let entryDate = Calendar.current.date(
    byAdding: .second,  // 2
    value: index * interval,
    to: currentDate)!
  let entry = SimpleEntry(
    date: entryDate,
    object: store.objects[index])  // 3
  entries.append(entry)
}
  1. You call fetchObjects(for:) to fill the objects array and use this to create an array of SimpleEntry values, three seconds apart. If fetchObjects(for:) fails, you fill the array with the two sample objects.
  2. You change the interval between entries to 3 seconds.
  3. You display an object from store.objects.

➤ At the top of ContentView, change query to “persimmon”: You download the same objects as the widget, so you can compare the app’s list with what your widget displays.

➤ Build and run, then close the app. Look for your widget and add it. Then watch it display the first six persimmon objects:

Widget showing persimmon objects
Widget showing persimmon objects

Note: If you already had a Widget added in the home screen and it isn’t showing the objects, remove it and add it again.

The widget might take a while to start displaying. In the meantime, it displays the placeholder view. If nothing happens after a couple of minutes, build and run the app again. After the sixth object, there’s a longer interval while the widget re-fetches the same six objects.

Note: At the time of writing, the widget doesn’t work correctly on my iPhone. It displays the first object, but doesn’t update.

➤ Tap the widget to reopen your app. Set a new query term, wait for the list to reload, then close the app. Your widget is still displaying persimmon objects:

Widget still showing persimmon objects
Widget still showing persimmon objects

Your widget’s TheMetStore is separate from your app’s TheMetStore, so it’s still using persimmon as the query term. You need to decide between these two design options:

  1. Keep the widget’s array in sync with the app’s.
  2. Allow the user to set a different query term for the widget.

Later in this chapter, you’ll implement a deep link from the widget into your app to open the detail view of the widget’s entry. This won’t make sense if the widget’s array could be different from the app’s array. So this chapter chooses the first design option.

Note: The second option requires you to create a widget with an IntentConfiguration, covered in our tutorial Getting Started With Widgets.

Creating an App Group

Xcode Tip: App group containers allow apps and targets to share resources.

Whenever the user changes the query term in your app, fetchObjects(for:) downloads and decodes a new objects array. To share this array with your widget, you’ll create an app group. Then, in TheMetStore.swift, you’ll write a file to this app group, which you’ll read from in TheMetWidget.swift.

➤ If you haven’t signed the targets yet, do it now: In the Project navigator, select the top level TheMet folder. For each target, change the bundle identifier prefix to your organization instead of com.yourcompany and set the team.

➤ Now select the TheMet target. In the Signing & Capabilities tab, click + Capability, then drag App Groups into the window. Click + to add a new container.

Add new app group.
Add new app group.

➤ Name your container group.your.prefix.TheMet.objects. Be sure to replace your.prefix with your bundle identifier prefix. Click the reload button if the color of your group doesn’t change from red to black.

➤ Now select the TheMetWidgetExtension target and add the App Groups capability. If necessary, scroll through the App Groups to find and select group.your.prefix.TheMet.objects.

Reloading the Widget’s Timeline

Next, you’ll set up TheMetStore so it tells the widget to reload its timeline whenever fetchObjects(for:) finishes downloading and decoding an array of objects.

➤ Back in TheMetStore.swift, add this import statement:

import WidgetKit

fetchObjects(for:) needs to call a WidgetCenter method to reload your widget’s timeline.

➤ In fetchObjects(for:), add this line after the for-loop:

WidgetCenter.shared.reloadTimelines(ofKind: "TheMetWidget")

When the navigation stack in ContentView first loads, it calls fetchObjects(for:) to create the objects array, but this is an asynchronous task, so a user might install the widget while its objects array is empty. You tell the widget to reload its timeline when the array is ready.

Writing the App Group File

➤ At the top of TheMetStore.swift, just below the import WidgetKit statement, add this code:

extension FileManager {
  static func sharedContainerURL() -> URL {
    return FileManager.default.containerURL(
      forSecurityApplicationGroupIdentifier:
        "group.your.prefix.TheMet.objects")!
  }
}

This is simply some standard code for getting the app group container’s URL. Be sure to substitute your bundle identifier prefix.

It makes sense to write this app group file just after you’ve decoded the data into the objects array. To write an array to a file, you JSON-encode it. Then the widget JSON-decodes the file contents.

➤ To write the file, add this helper method to TheMetStore:

func writeObjects() {
  let archiveURL = FileManager.sharedContainerURL()
    .appendingPathComponent("objects.json")
  print(">>> \(archiveURL)")

  if let dataToSave = try? JSONEncoder().encode(objects) {
    do {
      try dataToSave.write(to: archiveURL)
    } catch {
      print("Error: Can't write objects")
    }
  }
}

Here, you convert your array of Object values to JSON and save it to the app group’s container.

➤ In fetchObjects(for:), add the following to call your new helper method before the call to WidgetCenter:

writeObjects()

The existing call to WidgetCenter now tells the widget to reload its timeline whenever your app has written a new array of objects into a file in the app group.

Next, go and set up the widget to read this file.

Reading the Objects File

➤ Open TheMetWidget.swift.

Now, you can read your objects array from the app group file.

➤ Add this helper method to Provider:

func readObjects() -> [Object] {
  var objects: [Object] = []
  let archiveURL =
    FileManager.sharedContainerURL()
    .appendingPathComponent("objects.json")
  print(">>> \(archiveURL)")

  if let codeData = try? Data(contentsOf: archiveURL) {
    do {
      objects = try JSONDecoder()
        .decode([Object].self, from: codeData)
    } catch {
      print("Error: Can't decode contents")
    }
  }
  return objects
}

This reads the Object values from the file that fetchObjects(for:) saved into the app group’s container.

➤ Delete these lines from Provider:

let store = TheMetStore(6)
let query = "persimmon"

You won’t be using a local TheMetStore anymore.

➤ Also delete the Task in getTimeline(in:completion:) that calls fetchObjects(for:). Then, in getTimeline(in:completion:), replace the for loop with the following code:

let objects = readObjects()
for index in 0 ..< objects.count {
  let entryDate = Calendar.current.date(
    byAdding: .second,
    value: index * interval,
    to: currentDate)!
  let entry = SimpleEntry(
    date: entryDate,
    object: objects[index])
  entries.append(entry)
}

You read the objects array from the app group file and use it instead of store.objects to create entries.

Note: If you changed the bundle identifier, you’ll end up having two apps. Delete the old one before running the project.

➤ Build and run, then close the app. Look for your widget and add it. Watch it display a few persimmon objects, then tap the widget to reopen your app. Set query to giraffe, wait for the list to reload, then close the app. After a while, your widget will start displaying giraffe objects:

Widget reloaded with giraffe objects
Widget reloaded with giraffe objects

Note: If the widget keeps displaying persimmon objects, tap the widget or the app’s icon to reopen the app, then close the app again.

Your widget is working well, and you could happily install it on your device now. If you want to do so, skip down to the end of this chapter to change the timeline back to one-hour intervals.

The next section adds a feature many users expect: When you tap the widget, the app should open in the ObjectView for the current widget entry, if it’s public domain. Tapping a non-public-domain object should open the metmuseum.org page for the object.

Deep-Linking Into Your App

You can set up your widget with a deep link to activate a NavigationLink that opens the ObjectView or SafariView of the widget entry object. Here’s your workflow:

  1. Create a URL scheme.
  2. Modify a suitable view in WidgetView with widgetURL(_:).
  3. In your app, implement onOpenURL(perform:) to activate the correct .navigationDestination modifier.

Note: When you install the app on a device, deep-linking works when the app is running in the background. If the app isn’t running at all, tapping the widget opens the app and shows the list.

Creating a URL Scheme

“URL scheme” sounds very grand and a little scary but, because it’s just between your widget and your app, it can be quite simple. You’re basically creating a tiny API between widget and app. The widget needs to send enough information to the app so the app knows which view to display. Formatting this information as a URL lets you use URL or URLComponents properties to extract the necessary values.

For this app, the objectID property of Object uniquely identifies it. So the URL to open “Hexagonal flower vase” is simply:

URL(string: "TheMet://828444")

And you can access this objectID value as the host property of the URL. So simple!

In Your Widget

➤ In WidgetView.swift, add this modifier to the top-level VStack, where you set truncationMode and fontWeight:

.widgetURL(URL(string: "themet://\(entry.object.objectID)"))

Note: In the medium and large widget sizes, you can use Link(_:destination:) to attach links to different parts of the view.

In Your App

In your app, you implement .onOpenURL(perform:) to process the widget URL. You attach this modifier to either the root view, in TheMetApp, or to the top level view of the root view. For TheMet, you’ll attach this to the NavigationStack in ContentView, because the perform closure must assign a value to a @State property of ContentView.

You need to trigger navigation programmatically: You’ll add the widget’s object to a navigation path to make the app open the correct navigation destination.

➤ In ContentView.swift, add this @State property to ContentView:

@State private var path = NavigationPath()

When the widget sends a widgetURL to the app, you’ll check whether the object is in the public domain or not. Then, you’ll append either the object or its URL to path, and NavigationStack will use this to select the correct navigationDestination.

Note: If your NavigationStack presents only one type of view, path can be an array of the data type you pass to that view: [Object] for ObjectView or [URL] for SafariView. You’ll still need to use NavigationLink(value:) with the .navigationDestination(for:) modifier.

➤ To use this path, replace NavigationStack { with this:

NavigationStack(path: $path) {

You pass a binding to path to the navigation stack. Now, you can observe the current state of the stack or modify path to specify where to navigate.

➤ Now, add this modifier to NavigationStack, at the same level as the task that calls fetchObjects(for:):

.onOpenURL { url in
  if let id = url.host,
    let object = store.objects.first(
      where: { String($0.objectID) == id }) {  // 1
    if object.isPublicDomain {  // 2
      path.append(object)
    } else {
      if let url = URL(string: object.objectURL) {
        path.append(url)
      }
    }
  }
}

Here’s what this does:

  1. Extract an id value from the widget URL, then find the first object whose objectID matches this id value. Because url.host is a String, convert the objectID value to String before comparing.
  2. If the object is in the public domain, append it to path. Otherwise, append the URL created from its objectURL.

➤ At the top of ContentView, change query to peony: This query returns more non-public-domain objects, so you’ll be able to test that tapping these objects opens the app in SafariView.

➤ Build and run, wait for the list to load, then close the app and add your widget. Tap a public-domain entry to see it open the ObjectView for that object:

Deep link opens widget entry's ObjectView.
Deep link opens widget entry's ObjectView.

➤ Tap the app’s back button to return to the list, then close the app and tap a non-public-domain entry to see it open the SafariView for that object:

Deep link opens widget entry's SafariView.
Deep link opens widget entry's SafariView.

Well done!

A Few Last Things

A couple of housekeeping items before you go.

Organizing TheMet Group

➤ Organize your app files by grouping them into Views, Model and Networking:

Views, Model and Networking groups
Views, Model and Networking groups

Using Normal Timing

You’ve been using a three-second interval in your timeline to make testing simpler. You definitely don’t want to release your widget with such a short interval. If you want to use TheMet on your device as a real app, set up the timeline to change every hour instead of every three seconds.

Note: The project in the final folder still displays every three seconds.

➤ In TheMetWidget.swift, in the for-loop of getTimeline(in:completion:), change the entryDate code to this:

let entryDate = Calendar.current.date(
  byAdding: .hour,
  value: index,
  to: currentDate)!

You’re restoring the template code’s original timing. Now, your widget will add entries one hour apart from each other. You can add it to your device’s home screen with no worries about excessive battery use.

➤ Also remove the declaration of interval, as Xcode so helpfully suggests, since you’re no longer using it.

Refresh Policy

In getTimeline(in:completion:), after the for loop, you create a Timeline(entries:policy:) instance. The template sets policy to .atEnd, so WidgetKit creates a new timeline after the last date in the current timeline. As you saw when the widget was downloading a small number of its own objects, the new timeline doesn’t start immediately.

Of course, your current timeline fires at 3-second intervals, which is far from normal. With a more normal interval, like one hour, you probably won’t notice any delay.

There are two other TimelineReloadPolicy options:

  • after(_:) : Specify a Date when you want WidgetKit to refresh the timeline. Like atEnd, this is more a suggestion to WidgetKit than a hard deadline.
  • never: Use this policy if your app uses WidgetCenter to tell WidgetKit when to reload the timeline. This is a good option for TheMet. You’ve already seen the timeline reload almost immediately when you change a query option in your app. You could add code to your app to call fetchObjects(for:) at the same time every day, and this would also refresh your widget’s timeline.

Key Points

  • WidgetKit is still a relatively new API. You might experience some instability. You can fix many problems by deleting the app or by restarting the simulator or device.
  • To add a widget to your app, decide what app data you want to display and the time interval between entries. Then, define a view for each size of widget — small, medium, large — you want to support.
  • Add app files to the widget target and adapt your app’s data structures and views to fit your widgets.
  • Create an app group to share data between your app and your widget.
  • Deep-linking from your widget into your app is easy to do.
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.
© 2026 Kodeco Inc.