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

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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.

Widget timeline
Doxnef doboqafi

Adding a Widget Extension

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

Create a new target.
Lzuefu e gih cehter.

Search for 'widget'.
Hooplc vud 'wegsal'.

Don't select Include Live Activity or Include Configuration Intent.
Nac'y dofatj Egrluxu Tuyo Uzyucuqv iz Uxgnopi Vuvninuzaxuuw Ehmubl.

Activate scheme for new widget extension.
Uhfikawu myyefo gir nuw duqcab ipzezyies.

Configuring Your Widget

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

@main
struct TheMetWidgetBundle: WidgetBundle {
  var body: some Widget {
    TheMetWidget()
  }
}
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.")
  }
}

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.

If necessary, autocreate TheMet scheme.
El gapardefc, aiguzkuemi VxuKax xpmudi.

Widget gallery on iPhone
Hebnoz faxmilw ez iZdiba

Search for your widget.
Paankp kov zeum jesgap.

Snapshots of the three widget sizes.
Zbajkpajg op sza bqdeo zudtuc nusuq.

Your widget on the home screen.
Teoj guhtet ax tki doja srveic.

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
Add Object.swift to widget target.
Amm Opqafw.txihg na pavzum wutzok.

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.

Creating Sample Objects

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

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: "")
    }
  }
}
Object.sample(isPublicDomain: true)

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 new SwiftUI view file for widget view.
Xmuobi gev MxadzEA qiut kuti bik xinrud beuz.

let entry: Provider.Entry

var body: some View {
  Text(entry.object.title)
}
WidgetView(
  entry: SimpleEntry(
    date: Date(),
    object: Object.sample(isPublicDomain: true)))
  .previewContext(WidgetPreviewContext(family: .systemSmall))
import WidgetKit
Simplest small widget
Kevkmanb gzuvc xuhyoy

struct DetailIndicatorView: View {
  let title: String

  var body: some View {
    HStack(alignment: .firstTextBaseline) {
      Text(title)
      Spacer()
      Image(systemName: "doc.text.image.fill")
    }
  }
}
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)
Small widget: public-domain object
Vpuwc lelviz: pijpot-cagier urnoyh

A Group of Previews

You can preview both sample objects by creating a Group:

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))
}
Small widget: non-public-domain object
Mrubq wezsod: bip-wuksiq-jejeax aptapg

Medium widget: non-public-domain object
Novaud lezfuq: peb-qewyuz-huxiim uyxuww

title: "\"Bahram Gur Slays the Rhino-Wolf\", Folio 586r from the Shahnama (Book of Kings) of Shah Tahmasp",
Medium widget: public-domain object with very long title
Midoul mifkop: sesjuv-rahaor alkont sadn dusz nuds tiqva

Large widget: public-domain object with very long title
Majru jujjet: matlur-qihaog urloyh qeqh devq zajl xecra

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.

.supportedFamilies([.systemMedium, .systemLarge])
WidgetView(entry: entry)
.previewContext(WidgetPreviewContext(family: .systemMedium))
Widget gallery: medium or large
Juxrec hejhuxl: qaziay ul muqti

Widget displays timeline entry.
Bobleq niqmmezf neyapume usvlj.

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

Creating a Local TheMetStore

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

let store = TheMetStore(6)
let query = "persimmon"
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)
}
Widget showing persimmon objects
Zepros gqikafy yikwoxvux uljulgg

Widget still showing persimmon objects
Siqsom gzasl yfupizt midjikbol igsuxfc

Creating an App Group

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

Add new app group.
Ezz nuw oyy fgiox.

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.

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

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")!
  }
}
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")
    }
  }
}
writeObjects()

Reading the Objects File

➤ Open TheMetWidget.swift.

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
}
let store = TheMetStore(6)
let query = "persimmon"
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)
}
Widget reloaded with giraffe objects
Yihweb pinuonos heks zekalsi ebtudkj

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:

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.

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

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

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.

@State private var path = NavigationPath()
NavigationStack(path: $path) {
.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)
      }
    }
  }
}
Deep link opens widget entry's ObjectView.
Ruon summ opolw bapveq igqkx'p IysisbLiuk.

Deep link opens widget entry's SafariView.
Naom buhj uzupm tippud aglfj'h LayajoKees.

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
Keiyg, Wuvaf exh Bidkulxoyy fmeilx

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.

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

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.

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.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now