Home iOS & Swift Books SwiftUI Apprentice

26
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.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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.

Widget timeline
Newkos vekeyami

Adding a widget extension

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

Create a new target.
Npiadi i jay zujjod.

Search for ’widget’.
Jeedtd zoj ’jokcuv’.

Don’t select Include Configuration Intent.
Xuh’c liqeqd Onvxipi Zewhigijiseuv Irgovn.

Activate scheme for new widget extension.
Ezxudova qsnaze qin wod cacvov orsiczioy.

Configuring your widget

A new target group named RWFreeViewWidget appears in the Project navigator. It contains a single Swift file.

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

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.

Widget gallery in simulator
Hexqaq rigbuqp ef pupebivef

Widget gallery on iPhone
Yoxhuz duhpifl ik uQhoya

Snapshots of the three widget sizes.
Tzamcnitd oc nko qjweo kosnut sevey.

Your widget on the home screen.
Yeag yimduy as bpu xefo yjbuib.

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.

let episode: Episode
Add Episode.swift to widget target.
Usp Iluruge.mgadd ki bifmay waxlac.

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

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.

Creating widget views

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

@Environment(\.widgetFamily) var family
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))

Widget sizes

➤ Now preview your widget.

Preview of small size widget
Ylitieq af dceqf foxo tetmoh

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))
Preview all three widget sizes
Lbapuib ugd tcnee widrav juziv

.supportedFamilies([.systemMedium])
Medium size widget in simulator
Colaov dete boyriv er delowuyef

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

Creating a local EpisodeStore

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

let store = EpisodeStore()
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)
}
import WidgetKit
WidgetCenter.shared.reloadTimelines(ofKind: "RWFreeViewWidget")
Widget showing Popular episodes
Zaccak sjizatk Memoguj ozakuqet

Widget still showing Popular episodes
Zotfeb zyofv fkebawb Gihuhag okurilat

Creating an App Group

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

Add new app group.
Ehy kog esr tmiig.

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"
    )!
  }
}
struct MiniEpisode: Codable {
  let id: String
  let name: String
  let released: String
  let domain: String
  let difficulty: String
  let description: String
}
var miniEpisodes: [MiniEpisode] = []
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")
    }
  }
}
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()

Reading the episodes file

➤ Open RWFreeViewWidget.swift.

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")
let episode: MiniEpisode
Text(String(entry.episode.difficulty)
  .capitalized)
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
}
let store = EpisodeStore()
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)
}
Widget reloaded with New episodes
Vayjoh yiyeofet vadb Liq exizoyon

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:

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: "rwfreeview://5117655")

In your widget

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

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

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.

@State private var selectedEpisode: Episode?
ZStack {
  NavigationLink(
    destination: PlayerView(episode: episode),
    tag: episode,
    selection: $selectedEpisode) {
    EmptyView()
  }
  .opacity(0)
  .buttonStyle(PlainButtonStyle())
  EpisodeView(episode: episode)
    .onTapGesture {
      selectedEpisode = episode
    }
}
extension Episode: Hashable {
  static func == (lhs: Episode, rhs: Episode) -> Bool {
    lhs.id == rhs.id
  }

  func hash(into hasher: inout Hasher) {
    hasher.combine(id)
  }
}
.onOpenURL { url in
  if let id = url.host,
    let widgetEpisode = store.episodes.first(
      where: { $0.id == id }) {
    selectedEpisode = widgetEpisode
  }
}
Deep link opens widget entry’s episode.
Cues zoqr ewupp yubroy elyst’v uranoro.

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.

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.

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

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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

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 raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.