Daily coverage of WWDC20.
A Swift by Sundell spin-off.

Getting started with WidgetKit

Published at 22:25 GMT, 25 Jun 2020
Written by: John Sundell

Arguably one of the most exciting new features introduced in iOS 14 and macOS Big Sur is the new Widget system, which enables us third party developers to create small pieces of UI that users can then place directly on their home screens.

Let’s take a first look at the framework that enables those widgets to be created, WidgetKit, and how to get started building a simple first version of a widget that displays dynamic content.

What’s a widget?

”Widgets are not mini-apps” is a phrase that has been repeated a few times throughout the various Widget-related WWDC sessions, and for good reason, since that’s perhaps the most important thing to realize before building a widget at all.

What that means in practice is that, while widgets can display dynamically loaded content and be updated based on various conditions, they have very limited capabilities when it comes to user interactions and other types of events. From a development perspective, it’s perhaps easiest to think of widgets as the static end result of a dynamic rendering process.

A widget’s UI is built exclusively using SwiftUI. There are no view controllers, no app delegates, and no navigation stacks — again, these are not mini-apps — instead, the system renders each widget on a user’s home screen by unarchiving a previously archived SwiftUI view hierarchy, which an app provides by implementing a Widget Extension.

Configuring a widget

Widgets can be configured in two different ways: statically, and dynamically using intents (which is the same system that powers both Siri and Shortcuts). Whether a widget is static or dynamic doesn’t actually influence its ability to display dynamic data — but rather determines whether the user is able to configure the widget itself, or whether its presentation should be statically determined by our implementation.

Since this article is all about getting started with widgets, we’ll stick with the static configuration variant for now.

Let’s say that we’re building a widget that’ll let our users show a set of articles on their home screen, for example from an RSS feed. To configure our widget, we’ll create a type that conforms to the Widget protocol, and we’ll then return an instance of StaticConfiguration from its body — like this:

@main
struct ArticleWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(
            kind: "ArticleWidget",
            provider: Provider(),
            placeholder: Text("Article Reader"),
            content: { entry in
                WidgetEntryView(entry: entry)
            }
        )
        .configurationDisplayName("Article Widget")
        .description("Show the latest articles on your home screen.")
    }
}

Note how widgets are incredibly similar to SwiftUI views in terms of their API design. Looking closer at the above initialization of StaticConfiguration, we can see that a widget’s configuration is made up of four core parts:

  • Its kind, which acts as its identifier when performing updates and when querying the system for information about it.
  • A provider, which acts as the widgets controller or data source, in that it’ll be responsible for determining what data that our widget should display at various points in time.
  • A placeholder view that the system can display while our widget is loading its initial set of data.
  • And finally, a content closure, which acts as a factory for the content views that our widget will display.

Let’s start by taking a look at how we can build our widget’s main UI.

Building a widget’s UI

The main views that will be displayed within a widget can be built more or less the same way as any other SwiftUI view. We can use Xcode previews, stacks, texts, images, and all other kinds of views that SwiftUI offers. For example, here’s how we could implement the WidgetEntryView that we returned from our widget’s content closure above:

struct WidgetEntryView: View {
    var entry: ArticleEntry

    var body: some View {
        VStack(alignment: .leading, spacing: 5) {
            Text(entry.article.title)
                .bold()
            Text(entry.article.description)
                .foregroundColor(.secondary)
                .font(.footnote)
        }
        .padding()
    }
}

The ArticleEntry that both our content closure and our WidgetEntryView uses is a data model that we’ve defined ourselves. It’s called an entry because widgets use a timeline metaphor that consists of entries along the axis of time — where each entry essentially represents an instance of our widget’s state. So we don’t use bindings like @State and @ObservedObject when building a widget’s UI, since the system will take care of managing our entire view state for us.

Here’s what that ArticleEntry model looks like:

struct ArticleEntry: TimelineEntry {
    var date: Date
    var article: Article
}

Our entry type is required to conform to the TimelineEntry protocol (which in turn requires the above date property), but other than that, we’re free to define our entries however we’d like.

Creating a provider

Next, let’s take a look at how to create a widget’s provider, which is done by defining a type that conforms to the TimelineProvider protocol. That in turn requires us to implement two methods — one for creating a snapshot of our widget, and one for returning its current timeline:

struct Provider: TimelineProvider {
    var loader = ArticleLoader()

    func snapshot(with context: Context,
                  completion: @escaping (ArticleEntry) -> ()) {
        ...
    }

    func timeline(with context: Context,
                  completion: @escaping (Timeline<ArticleEntry>) -> ()) {
        ...
    }
}

The above snapshot method is called by the system when it wants us to return an entry as quickly as possible — without performing any heavy operations, such as network calls or other types of asynchronous work. A snapshot is, for example, used by the system within the Widget gallery, which enables a user to get a real preview of our widget before choosing to add it to their home screen.

One way to implement that snapshot method is to create a completely static version of our widget by returning an entry populated with mocked data — like this:

struct Provider: TimelineProvider {
    ...

    func snapshot(with context: Context,
                  completion: @escaping (ArticleEntry) -> ()) {
        let entry = ArticleEntry(
            date: Date(),
            article: Article(
                id: "placeholder",
                title: "An amazing article reader",
                description: "See the latest articles using this widget",
                url: URL(string: "https://wwdcbysundell.com")!
            )
        )

        completion(entry)
    }
    
    ...
}

When it comes to constructing our widget’s timeline, we don’t have the same type of constraints on execution time, and can choose to load the data that will actually be displayed within a running instance of our widgets in many different ways. For example, we could fetch some form of data from a container that is shared with our main host app, or we could perform a network call to have our widget download its own data from our server.

In this case, we’re going to use an ArticleLoader that’s also used by our main app, and call a method on it for loading the latest available articles. Then, we’ll map over those articles in order to create a timeline on which our articles are distributed one hour apart — like this:

struct Provider: TimelineProvider {
    var loader = ArticleLoader()

    ...

    func timeline(with context: Context,
                  completion: @escaping (Timeline<ArticleEntry>) -> ()) {
        loader.loadLatestArticles { articles in
            let date = Date()
            let calendar = Calendar.current

            let entries = articles.enumerated().map { offset, article in
                ArticleEntry(
                    date: calendar.date(byAdding: .hour, value: offset, to: date)!,
                    article: article
                )
            }

            let timeline = Timeline(entries: entries, policy: .atEnd)
            completion(timeline)
        }
    }
}

When creating our Timeline above, we’re not only giving it our entries, but also a TimelineReloadPolicy, which the system will use to determine when our widget’s data should be reloaded. Above we’ve specified .atEnd, which means that the system will reload our timeline once no entries remain, but we could also use a specific Date as our reload policy, or tell the system to never automatically reload our widget.

And with that, we’ve now built a very basic widget that’s capable of displaying articles right on the user’s home screen.

A screenshot of our finished widget

Conclusion

The new widget system is not only a really cool user-facing feature, it’s also incredibly interesting from a technical point of view. From how it uses archived SwiftUI views to render each widget in a highly efficient manner, to how widgets can achieve a fair level of dynamism without turning into mini-apps, to how each widget is configured using a SwiftUI-like DSL — there’s a lot to unpack here.

While this article just scratched the surface of what widgets are capable of, I hope you found it interesting and useful as you might start exploring this exciting new API yourself. Here’s a few WWDC sessions that I recommend watching on the topic of widgets:

Thanks for reading! 🚀

Written by: John Sundell