How the Upcoming Widget was Built

Ray Kim
ClassPass Engineering
11 min readJan 29, 2021

--

When iOS 14 launched this past fall, one of the most anticipated features was the ability to add widgets to your home screen. These widgets can replace an app’s icon and add interactivity and dynamic content in a larger visual footprint. For example, a weather widget can show you today’s high and low temperatures; a stocks widget can show you your portfolio’s return for the day. When this feature came out, I felt this would be the perfect opportunity for ClassPass to showcase its own widget. A natural use case would be displaying your upcoming class reservations.

Widgets are built using a new UI framework by Apple called SwiftUI. Announced in 2019, SwiftUI took the iOS developer community by storm with its radically simpler approach to building UI components for iOS apps. Gone are the days of Storyboards (and their merge conflicts) and IBOutlets. Now, you can use the new Canvas Editor in Xcode to build UI components declaratively and see the code built in real-time–React developers will feel right at home!

Canvas Editor in Xcode 12

This new syntax is powered by core language changes introduced in Swift 5.1 such as opaque return types, function builders, and property wrappers (formerly known as property delegates). For more, check out this great overview by John Sundell.

Ever since SwiftUI came out, our iOS team had been trying to find an opportunity to actually build something with it. Unfortunately, SwiftUI doesn’t support iOS 12 or below so we had to wait until we dropped support for iOS 12. Now that iOS 14 came out and the vast majority of users are on iOS 13 and up, we were able to drop support and finally see how SwiftUI works within our codebase! The widget was the perfect use case because it was net-new and could easily be worked on without interruptions from other developers.

In the rest of this article, I’ll dive deeper into widget specifics–how I fetched and displayed data and how SwiftUI allows for powerful widgets right out of the box with features such as Dark Mode and localization support.

MVP: StaticConfiguration and App Groups

In order to implement a widget, you need to use both WidgetKit and SwiftUI. Apple has great documentation on how to build a basic widget so I won’t go over every step here. The first major decision to make is whether you want your widget to use a StaticConfiguration or an IntentConfiguration.

StaticConfiguration is used for widgets that display data without direct user input (see example widget below).

Apple’s simply fetches publicly available data

An IntentConfiguration lets you build a widget that can be customized by the user. For example, an airline widget can take your flight and confirmation numbers as inputs to show you your upcoming flight. For our widget, I decided to use a StaticConfiguration for simplicity and leveraged the ClassPass app to pass upcoming reservations data.

First, we setup our configuration inside the main struct UpcomingWidget which acts as the skeleton:

@mainstruct UpcomingWidget: Widget {   let kind: String = “UpcomingWidget”   var body: some WidgetConfiguration {      StaticConfiguration(kind: kind, provider: UpcomingProvider()) { entry in        UpcomingWidgetEntryView(entry: entry)      }      .configurationDisplayName(“tab-upcoming”)      .description(“upcoming-widget.description”)      .supportedFamilies([.systemSmall])   }}

A few things to note. UpcomingProvider conforms to TimelineProvider which controls when to display each snapshot, or entry, of our widget over time. UpcomingWidgetEntryView contains the UI components of our widget which I’ll go into more detail later in the article.
.configurationDisplayName() is a modifier that controls what the title within the widget selection screen will say and .description() controls the subtitle.

.supportedFamilies() is an important modifier to determine what widget sizes your widget code supports. There are three sizes: .systemSmall, .systemMedium, and .systemLarge. On an iPhone, .systemSmall will show up as a square that takes up about the same size as four app icons. Below are examples of .systemMedium and .systemLarge. One important distinction between small and medium/large widgets is that small widgets only have one tap target–the entire widget–whereas the medium and large widgets allow you to have multiple tap targets. For example, Spotify’s medium-sized widget can display your recent albums and the Weather app’s large-sized widget can display highs and lows for multiple days:

Using App Groups Part 1: Passing JSON to the Widget

There are a number of ways to load data into your widget. You can make network calls directly within your TimelineProvider or you can pass data from your main app using App Groups. In the upcoming reservations widget, I used both App Groups to pass reservation data and made a separate network call to load an image from a given image url. Passing data via App Groups is an easy way to get most of the data you’ll need for your widget. If you haven’t setup your app to use the App Group entitlement then I suggest reading Apple’s documentation. You’ll need to create an App Group name such as group.com.yourapp which you’ll then use in both your app and your widget.

In order to pass data from your app to your widget, you’ll need to use WidgetCenter–an object that gives you access to all widgets for your app. For example, we want to update our widget when a user books a new class. In our UpcomingClassesViewController we make a network call to fetch the latest reservations for the user and then call our helper method writeToSharedContainer(_:[Reservation]). We then update our widget as follows (edited for brevity):

// 1
@available(iOS 14.0, *)
func writeToSharedContainer(_ reservations: [Reservation]) { // 2
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourapp") else {
return } let reservationUrl = url.appendingPathComponent("upcoming.json") do { let encoder = JSONEncoder() let dataToSave = try JSONEncoder().encode(reservations) try dataToSave.write(to: reservationUrl)
// 3
WidgetCenter.shared.reloadTimelines(ofKind: “UpcomingWidget”) } catch { return }}
  1. Since widgets are only supported in iOS 14, if your app also supports iOS 13 or lower, then you’ll need to wrap any code that uses WidgetCenter in an API availability attribute.
  2. This is how you access data that’s stored for a particular App Group. It’s very similar to using UserDefaults for local storage.
  3. Call reloadTimelines(ofKind: String)to update a specific widget. In the earlier code snippet for the widget, we had set kind to be "UpcomingWidget" so that it can be accessed here.

Using App Groups Part 2: Loading JSON from the Widget

Now that we’ve written JSON to our cache, we can now load the contents within our TimelineProvider. In order to conform to TimelineProvider, you need to implement three methods. Here’s what my implementation looked like:


func placeholder(in context: Context) -> UpcomingEntry {
UpcomingEntry(date: Date(), reservations: nil, image: nil)}func getSnapshot(in context: Context, completion: @escaping (UpcomingEntry) -> Void) { let entry = UpcomingEntry(date: Date(), reservations: nil, image: nil) completion(entry)}func getTimeline(in context: Context, completion: @escaping (Timeline<UpcomingEntry>) -> Void) { fetchReservationsAndImage { upcomingEntry in let timeline = Timeline(entries: [upcomingEntry], policy: .atEnd) completion(timeline) }}

The first method is used as a default template that Apple uses for certain situations such as first loading your widget. It’s similar to a placeholder asset for a watch complication in watchOS. It’s different in that for a widget we use a SwiftUI view to represent the placeholder.

The second method controls what you want your widget to look like during certain “transient moments” as defined by Apple. For example, Apple will fetch a snapshot of your widget when a user is viewing it in the widget selection menu. For the purposes of our widget, we don’t need to support multiple transient states–matching the placeholder state is sufficient.

The third method is where things get interesting. Here we can create our timeline, basically an array of timeline entries, to control when to show snapshots of our widget over time and with what data. This is where you want to make asynchronous network calls such as fetching an image. Above I have a helper function fetchReservationsAndImage(completion:) that encapsulates the logic for piecing together all the data we need. At a high level, we decode the JSON data from the app, fetch an image from the imageUrl field in the first reservation object, and then call the completion block with an UpcomingEntry object.

UpcomingEntry: Managing State

UpcomingEntry is a struct that conforms to TimelineEntry and represents the state of the widget at a given date. This is also where you can optionally specify the entry’s relevance which is useful for when you have widgets organized in a stack. For the purposes of our MVP, I opted not to implement this but if you’re interested you should take a look at Apple’s documentation.

A TimelineEntry serves as the view model for your SwiftUI views. This is where I represent discrete states that I want to map to specific views. The MVP version of the widget supports three states: logged out, nothing upcoming, and upcoming reservation:

Left to right: Logged out, nothing upcoming, upcoming reservation

Given we get back an optional array of reservations (retrieval from App Group container could fail) , we need to map all cases into three.

enum UpcomingEntryState {   case upcomingReservation(startTime: Date, name: String, venueName: String, imageUrl: URL)   case nothingUpcoming   case loggedOut}

Using associated values, we can succinctly pass specific data to the view using a lightweight enum. Let’s take a look at UpcomingWidgetEntryView to see how we then map our data-driven state to UI components.

UpcomingWidgetEntryView: Mapping State to UI in SwiftUI

Using our custom TimelineEntry as a view model, we can simply lay out our views. SwiftUI is incredibly modular and encourages reusable views to avoid excessive nesting. Given the designs for our MVP, I was able to use a shared container view and simply swap out its child view for one of three views: UpcomingReservationView(startTime:name:venueName:) , NothingUpcomingView(), and LoggedOutView() (edited for brevity):

struct UpcomingWidgetEntryView: View {   var entry: UpcomingEntry   var body: some View {      VStack(alignment: .leading) {         HStack(alignment: .top) {
// 1
if let image = entry.image { Image(uiImage: image) .resizable() // and other modifiers } else { Image(systemName: "square.fill") .resizable() // and other modifiers .redacted(reason: .placeholder) } Spacer() Image("logo") } VStack(alignment: .leading) {
// 2
switch entry.state { case .upcomingReservation(let startTime, let name, let venueName, _): UpcomingReservationView(startTime: startTime, name: name, venueName: venueName) case .nothingUpcoming: NothingUpcomingView() case .loggedOut: LoggedOutView() } } } .padding(16) // and other modifiers }}
  1. If our entry contains an image that we fetched from our timeline provider, then we use the Image(uiImage:) initializer to load the image; if it’s nil (either because it’s a placeholder, an error occurred, or the current entry state doesn’t display an image), we show a placeholder image using the handy .redacted(reason: .placeholder) modifier which will automatically display a placeholder courtesy of Apple. Note this also works for Text() views and can be a convenient way to have a built-in loading state for your UI. Since the designs for the widget show a square in the top left of the widget for all states, we can separate this logic out from each individual child view.
  2. You can have switch statements inside VStack views to conditionally show one view over another. The only view we inject data into is UpcomingReservationView(startTime:name:venueName:) .

SwiftUI Freebies

If you’ve never tried out SwiftUI, building a widget is a great way to get your feet wet. I won’t go into too much detail about the basics of SwiftUI syntax but I’ll point out two interesting features that make SwiftUI worth learning.

Dark Mode

First, Dark Mode support is built right in. When setting color for, say, the background of your widget, you use a special struct called Color() that roughly maps to UIColor in UIKit . The nice thing about Color() is that you can specify a custom color from Assets.xcassets where you can set custom color sets. For example, I have a color set called "WidgetBackground" that looks like this in Xcode:

You can specify what the particular color will look like in Dark Mode simply by modifying the color above where it says “Dark Appearance” just like any other color. Then in your view you can use it like this:

var body: some View {   VStack {   }   .padding(16)   .background(Color(“WidgetBackground”))}

There are also system colors such as Color(.systemRed) and even Color(.systemBackground) if you don’t want to deal with custom color sets.

Localization

Adding support for multiple locales for your widget is incredibly straightforward thanks to built-in SwiftUI and WidgetKit components that support localization out of the box. If your app already supports multiple languages, then it’s the same process to use localizable strings in your widget: add localizable string key-value pairs to your existing Localizable.strings files and they’ll be accessible in the widget whenever you use Text() or modifiers that support localizable strings by default. For example, as mentioned earlier, .configurationDisplayName() and .description() support localizable strings. Here are some of the key-value pairs in our Localizable.strings (English) file:

Localizable strings

Add translations to all of your other Localizable.strings files and when used for .configurationDisplayName() and .description() it will show up as follows:

Left to right: Dutch, French, Portuguese (Portugal)

Conclusion

That was a whirlwind tour of how I got our first iOS 14 widget up and running while experimenting with SwiftUI in our codebase for the first time. Though there was a learning curve to the intricacies of how WidgetKit and SwiftUI work in tandem (I’ll spare you the codesigning horrors and other Xcode pitfalls), the enterprise was well worth it. There’s so much more we can do of course, such as automatically updating the widget for future reservations if there are multiple, supporting multiple widget sizes, and much more.

Thank you Sanjay for the iOS support and Tom for creating beautiful designs to work off of. If you’re on iOS version 6.0.0 and up, try adding the widget (make sure to open the app first) and book a class! And if you’re an iOS developer, I hope this article helped you start on your own widgets!

You’re reading the ClassPass Engineering Blog, a publication written by the engineers at ClassPass where we’re sharing how we work and our discoveries along the way. You can also find us on Twitter at @ClassPassEng.

If you like what you’re reading, you can learn more on our careers website.

--

--