Chapters

Hide chapters

SwiftUI Apprentice

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

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

26. 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 RWFreeView, providing convenient, but low-key, notification of free episodes at raywenderlich.com. And, it gives your users quick access to 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 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.

And, you define a view for each size of widget — small, medium, large — you want to support.

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 whether to support all three widget sizes. Create small, medium and/or large views to display one or more data model values.
  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 RWFreeViewWidget, select your team and make sure Include Configuration Intent is not checked:

Don’t select Include Configuration Intent.
Don’t select Include Configuration Intent.

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

Note: IntentConfiguration is covered in our tutorial Getting Started With Widgets bit.ly/2MS7K9U

➤ 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 RWFreeViewWidget appears in the Project navigator. It contains a single Swift file.

➤ Open RWFreeViewWidget.swift, then find RWFreeViewWidget and edit the last two modifiers: configuration display name and description.

@main  // 1
struct RWFreeViewWidget: Widget {
  let kind: String = "RWFreeViewWidget"

  var body: some WidgetConfiguration {
    StaticConfiguration(
      kind: kind,
      provider: Provider()  // 2
    ) { entry in
      RWFreeViewWidgetEntryView(entry: entry)  // 3
    }
    // 4
    .configurationDisplayName("RW Free View")
    .description("View free raywenderlich.com video episodes.")
  }
}
  1. The @main attribute means this is the widget’s entry point. The structure’s name and its kind property are the name you gave it when you created it.
  2. You’ll define your widget’s timeline, snapshot and placeholder entries in Provider.
  3. You’ll create your widget view(s) in RWFreeViewWidgetEntryView.
  4. In this structure, you only need to customize the name to RW Free View and the description to View free raywenderlich.com video episodes. 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 RWFreeView folder. Use your organization instead of “com.raywenderlich” 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 RWFreeView 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.

➤ RWFreeViewWidget is a second target and it’s probably the currently selected scheme. Make sure you select the RWFreeView 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 the icons start to jiggle.

➤ Tap + in the upper left corner, then scroll down to find RWFreeView:

Widget gallery in simulator
Widget gallery in simulator

If you’ve installed the app on a device, your gallery looks something like this:

Widget gallery on iPhone
Widget gallery on iPhone

➤ Select it 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 RWFreeView.

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

➤ 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 episode. These properties are in the Episode structure.

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

let episode: Episode

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

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

Add Episode.swift to widget target.
Add Episode.swift to widget target.

Episode has a property of type VideoURL and uses Formatter.iso8601 to create releaseData, so error messages might now appear in Episode.swift.

➤ Also add VideoURL.swift and FormatterExtension.swift to the widget target.

Now the error messages in RWFreeViewWidget.swift are the expected ones about Missing argument for parameter 'episode' in call. The missing Episode arguments are for creating SimpleEntry instances in placeholder(in:), getSnapshot(in:completion:), getTimeline(in:completion:) and down in the preview.

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

➤ In RWFreeViewWidget.swift, add this property to Provider:

let sampleEpisode = Episode(
  id: "5117655",
  uri: "rw://betamax/videos/3021",
  name: "SwiftUI vs. UIKit",
  parentName: nil,
  released: "Sept 2019",
  difficulty: "beginner",
  description: "Learn about the differences between SwiftUI and"
    + "UIKit, and whether you should learn SwiftUI, UIKit, or "
    + "both.\n" ,
  domain: "iOS & Swift")

The widget doesn’t actually need uri, but the default Episode initializer requires this parameter.

➤ Now fix the errors one by one, or use this handy shortcut for Editor ▸ Fix All Issues: Control-Option-Command-F. Then replace all the Episode placeholders in Provider with sampleEpisode and replace the one in RWFreeViewWidget_Previews with Provider().sampleEpisode.

Placeholder & snapshot

Adding the Episode property to SimpleEntry caused errors in the Provider structure, which creates two SimpleEntry instances. Its 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 the same redacted(reason: .placeholder) modifier you used at the end of the previous chapter 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.

Creating widget views

Now you’ve decided what data to display, you need to define views to display it.

➤ First, in RWFreeViewWidget.swift, in RWFreeViewWidgetEntryView, add this environment property:

@Environment(\.widgetFamily) var family

You’ll use this to customize the widget view for small, medium and large widget sizes.

➤ Still in RWFreeViewWidgetEntryView, replace the body contents with this code:

VStack(alignment: .leading, spacing: 6) {
  HStack {
    PlayButtonIcon(width: 50, height: 50, radius: 10)
      .unredacted()
    VStack(alignment: .leading) {
      Text(entry.episode.name)
        .font(.headline)
        .fontWeight(.bold)
      if family != .systemSmall {
        HStack {
          Text(entry.episode.released + "  ")
          Text(entry.episode.domain + "  ")
          Text(String(entry.episode.difficulty ?? "")
            .capitalized)
        }
      } else {
        Text(entry.episode.released + "  ")
      }
    }
  }
  .foregroundColor(Color(UIColor.label))

  if family != .systemSmall {
    Text(entry.episode.description)
      .lineLimit(2)
  }
}
.padding(.horizontal)
.background(Color.itemBkgd)
.font(.footnote)
.foregroundColor(Color(UIColor.systemGray))

This is just a mini-version of your app’s EpisodeView, allowing more space for the description. The small widget size doesn’t have much space, so you only display the episode name and released properties.

Again, you need to add some app files to your widget target, to get rid of the error messages.

➤ Add these files to the widget target: PlayButtonIcon.swift and, for Color.itemBkgd, ColorExtension.swift and Assets.xcassets. If the error messages don’t go away, press Command-B to rebuild the project.

Widget sizes

➤ Now preview your widget.

Preview of small size widget
Preview of small size widget

Note: Don’t worry if the playback button icon doesn’t look right. I experienced an intermittent preview bug that displayed just an orange gradient. It looked fine in a simulator or on a device.

Not bad, but it looks a little crowded, and a longer title wouldn’t fit at all. Try the medium size.

➤ In RWFreeViewWidget_Previews, replace the contents of previews with:

let view = RWFreeViewWidgetEntryView(
  entry: SimpleEntry(
    date: Date(), 
    episode: Provider().sampleEpisode))
view.previewContext(WidgetPreviewContext(family: .systemSmall))
view.previewContext(WidgetPreviewContext(family: .systemMedium))
view.previewContext(WidgetPreviewContext(family: .systemLarge))

Now you can preview all three sizes at once:

Preview all three widget sizes
Preview all three 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 RWFreeView, the medium size looks best, so you’ll only support that size.

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

.supportedFamilies([.systemMedium])

➤ Build and run, then close the app. If you had a small or large widget installed before this, it’s now gone. And when you add a widget, the only choice now is medium size.

Medium size widget in simulator
Medium size widget in simulator

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.

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, 
    episode: sampleEpisode)
  entries.append(entry)
}

This code creates each entry with the same sampleEpisode. You’ll modify the method so it displays items in the episodes 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 episodes array.

Creating a local EpisodeStore

The quickest way — fewest lines of code — to get episodes is to create an EpisodeStore in the widget.

➤ In RWFreeViewWidget.swift, add this property to Provider:

let store = EpisodeStore()

➤ Add EpisodeStore.swift and URLComponentsExtension.swift to the widget target.

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

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

You use the episodes array in EpisodeStore to create an array of SimpleEntry values, three seconds apart.

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

import WidgetKit

fetchContents() needs to call a WidgetCenter method to reload your widget’s timeline.

➤ In fetchContents(), add this line to the DispatchQueue.main.async closure

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

Initializing EpisodeStore calls fetchContents() to create the episodes array, but this is an asynchronous task, so a user might install the widget while its episodes array is empty. You tell the widget to reload its timeline when the array is ready.

➤ Build and run, then close the app. Look for your widget and add it. Then watch it display your 20 free popular episodes:

Widget showing Popular episodes
Widget showing Popular episodes

➤ Tap the widget to reopen your app. Select New, wait for the list to reload, then close the app. Your widget is still displaying popular episodes:

Widget still showing Popular episodes
Widget still showing Popular episodes

Your widget’s EpisodeStore is separate from your app’s EpisodeStore, so it’s still using the initial options. 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 different query options for the widget.

Later in this chapter, you’ll implement a deep link from the widget into your app to open the player 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 bit.ly/2MS7K9U

Creating an App Group

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

Whenever the user changes a query option in your app, fetchContents() downloads and decodes a new episodes array. To share this array with your widget, you’ll create an app group. Then, in EpisodeStore.swift, you’ll write a file to this app group, which you’ll read from in RWFreeViewWidget.swift.

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

➤ Now select the RWFreeView 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.RWFreeView.episodes. 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 RWFreeViewWidgetExtension target and add the App Groups capability. If necessary, scroll through the App Groups to find and select group.your.prefix.RWFreeView.episodes.

Writing the app group file

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

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

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 contents response into the episodes array. To write an array to a file, you JSON-encode it. Then the widget JSON-decodes the file contents. But you can’t reuse the JSON decoding code you’ve built into Episode because that’s expecting the nested JSON structure sent by the API server.

Your widget only needs a few Episode properties, so you’ll create a MiniEpisode type for it to use.

➤ In Episode.swift, add this code at the end of the file, outside of all other curly braces:

struct MiniEpisode: Codable {
  let id: String
  let name: String
  let released: String
  let domain: String
  let difficulty: String
  let description: String
}

Every property is a String so you don’t need any custom encoding or decoding code.

➤ In EpisodeStore.swift, add this property to EpisodeStore:

var miniEpisodes: [MiniEpisode] = []

➤ Also add this helper method to EpisodeStore:

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

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

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

➤ In fetchContents(), add this code to the DispatchQueue.main.async closure, before the call to WidgetCenter:

self.miniEpisodes = self.episodes.map {
  MiniEpisode(
    id: $0.id,
    name: $0.name,
    released: $0.released,
    domain: $0.domain,
    difficulty: $0.difficulty ?? "",
    description: $0.description)
}
self.writeEpisodes()

You map your array of Episode values into an array of MiniEpisode values, then write this array into your app group file. The existing call to WidgetCenter now tells the widget to reload its timeline whenever your app has downloaded and decoded a new array of episodes.

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

Reading the episodes file

➤ Open RWFreeViewWidget.swift.

You need to replace Episode with MiniEpisode.

➤ Replace the definition of sampleEpisode with this code

let sampleEpisode = MiniEpisode(
  id: "5117655",
  name: "SwiftUI vs. UIKit",
  released: "Sept 2019",
  domain: "iOS & Swift",
  difficulty: "beginner",
  description: "Learn about the differences between SwiftUI and"
    + "UIKit, and whether you should learn SwiftUI, UIKit, or "
    + "both.\n")

MiniEpisode contains only the parameters the widget needs, in a slightly different order.

➤ In SimpleEntry, replace let episode: Episode with the following:

let episode: MiniEpisode

➤ Now, in RWFreeViewWidgetEntryView, difficulty isn’t an optional anymore, so remove the nil coalescing operator:

Text(String(entry.episode.difficulty)
  .capitalized)

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

➤ Add this helper method to Provider:

func readEpisodes() -> [MiniEpisode] {
  var episodes: [MiniEpisode] = []
  let archiveURL =
    FileManager.sharedContainerURL()
    .appendingPathComponent("episodes.json")
  print(">>> \(archiveURL)")

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

This reads the MiniEpisode values from the file fetchContents() saved into the app group’s container.

➤ Delete this line from Provider:

let store = EpisodeStore()

You won’t be using a local EpisodeStore anymore.

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

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

You read the episodes array from the app group file and use it instead of store.episodes.

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 of your 20 free popular episodes, then tap the widget to reopen your app. Select New, wait for the list to reload, then close the app. Your widget is now displaying recent episodes:

Widget reloaded with New episodes
Widget reloaded with New episodes

Your widget’s 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 display the PlayerView for the current widget entry.

Deep-linking into your app

You can set up your widget with a deep link to activate a NavigationLink that opens a PlayerView with the widget entry’s episode. Here’s your workflow:

  1. Create a URL scheme.
  2. Modify the top level container view of your widget view with widgetURL(_:).
  3. In your app, implement onOpenURL(perform:) to activate a NavigationLink with the correct destination view.

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 id property of Episode uniquely identifies it. So the URL to open “SwiftUI vs. UIKit” is simply:

URL(string: "rwfreeview://5117655")

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

In your widget

➤ In RWFreeViewWidget.swift, in RWFreeViewWidgetEntryView, add this modifier to the top-level VStack:

.widgetURL(URL(string: "rwfreeview://\(entry.episode.id)"))

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 RWFreeViewApp, or to the top level view of the root view. For RWFreeView, you’ll attach this to the NavigationView in ContentView, because the perform closure must assign a value to a @State property of ContentView.

First, you need to trigger NavigationLink programmatically. You’ll use its tag-selection initializer to activate it when you set a value for the selection argument.

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

@State private var selectedEpisode: Episode?

This is the selection argument. You can activate NavigationLink by assigning a value to this property.

➤ Then, replace the ZStack in the ForEach closure with the following:

ZStack {
  NavigationLink(
    destination: PlayerView(episode: episode),
    tag: episode,
    selection: $selectedEpisode) {
    EmptyView()
  }
  .opacity(0)
  .buttonStyle(PlainButtonStyle())
  EpisodeView(episode: episode)
    .onTapGesture {
      selectedEpisode = episode
    }
}

This NavigationLink activates whenever you set selectedEpisode. But just tapping an item will no longer activate NavigationLink. So you modify EpisodeView with onTapGesture to set the value of selectedEpisode.

Xcode complains that Episode doesn’t conform to Hashable, so head over to Episode.swift to make it so.

➤ In Episode.swift, add this extension:

extension Episode: Hashable {
  static func == (lhs: Episode, rhs: Episode) -> Bool {
    lhs.id == rhs.id
  }

  func hash(into hasher: inout Hasher) {
    hasher.combine(id)
  }
}

Here you made Episode conform to Hashable by implementing the equatable static function, ==(_:_:), and hash(into:).

➤ Back in ContentView.swift, add this modifier to the NavigationView:

.onOpenURL { url in
  if let id = url.host,
    let widgetEpisode = store.episodes.first(
      where: { $0.id == id }) {
    selectedEpisode = widgetEpisode
  }
}

You extract the id value from the widget URL, then find the first episode with the same id value.

➤ Build and run, wait for the list to load, then close the app and add your widget. Tap an entry to see it open the PlayerView with that video:

Deep link opens widget entry’s episode.
Deep link opens widget entry’s episode.

Note: This doesn’t work every time. Often, when a deep link doesn’t open PlayerView, tapping the item in the app doesn’t open PlayerView either. This happens on a device as well as in the simulator. NavigationLink has a history of buggy behavior.

One last thing

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.

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. The new timeline doesn’t start immediately. See for yourself.

➤ In EpisodeStore.swift, set "page[size]": "5" in baseParams so the timeline ends soon after you install the widget. Build and run, then add your widget. When it reaches the fifth item, wait for the first item to reappear. In the simulator on my Mac, it took between one and two minutes.

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.

➤ In EpisodeStore.swift, set "page[size]" back to 20.

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 RWFreeView. 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 fetchContents() at the same time every day, and this would also refresh your widget’s timeline.

Using normal timing

If you want to use RWFreeView 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 RWFreeViewWidget.swift, in 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 display episodes one hour apart. You can add it to your device’s home screen with no worries about excessive battery use.

You can also remove the declaration of interval as Xcode so helpfully suggests since you’re no longer using it.

Key points

  • WidgetKit is a 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.