Updated for Xcode 12 beta 6

Home screen widgets were one of the flagship features introduced for iOS and MacOS at WWDC20, they're a powerful extension allowing users a window into your app when it's not open in the foreground.

In this article we're going to build a simple home screen widget that shows a movie to the user, it will periodically update with a new movie from a local source and refresh the UI.

Widgets come in three sizes, .systemSmall, .systemMedium and .systemLarge, and you can provide a different layout for each if you choose. In this article we're going to build a one size fits all layout, but i'll also show you how you can provide different SwiftUI views based on the size.

The full code for this article can be found at https://github.com/Swift-Compiled/homescreen-widgets

By the end of this article you'll have something that looks like this...


Introduction to WidgetKit

Although widgets have been available in previous versions of iOS, home screen widgets are new in iOS14 and are made available to developers with the introduction of WidgetKit, Apple's new framework providing lightweight, performant SwiftUI views for our home screen. According to Apple, the previous way of working with widgets (in the today view) had performance constraints that make them unsuitable for home screen use, home screen widgets have been made possible since the intrduction of SwiftUI at WWDC19.

The entry point to a Widget is the Widget protocol, at first glance it has a similar structure to a SwiftUI view, with a body being its only requirement that returns a type WidgetConfiguration, which in turn provides a SwiftUI view inside a closure. A WidgetConfiguration can either be of type StaticConfiguration or IntentConfiguration, to understand how they work you'll first need to know about the timeline...

The timeline is a new concept in WidgetKit, you can think of a timeline as a type who's job it is to provide the state of your widget over time.

source: Meet WidgetKit - WWDC20 session videos

Starting from the left in this image, the WidgetKit extension is providing an array of updates on the timeline that reflect your widget at any given time, these updates are controlled by the developer. For example, a calendar widget would provide an array of all events for the day as multiple timeline entries, and as one event passes the UI would update with the next event in the list. When a user taps your widget it will open your app, but that's where the user interaction ends, you can't add buttons, sliders or other interactive UI.


At the time of writing you'll need to be running the Xcode 12 beta to work with WidgetKit

Open Xcode 12 and start a new project, select the new App menu item in the Application section, select the Multiplatform app template in the top left, call the project MoviePlanner.

Once in Xcode, navigate to the project explorer by clicking on the MoviePlanner project file, under Targets click the plus button at the bottom and search for Widget in the search bar of the new target template screen, select Widget Extension and call it MoviePlannerWidget. Once finished Xcode will ask if you want to activate the new scheme, let's do that as we don't need to touch the core app code at all for this article.

We should now have a new directory in our project structure on the left called 'Movie Planner Widget', Xcode has conveniently added the boilerplate code for us!

Our app is going to store a short list of movies and cycle through them over time using a timer, as the movie updates so will our widget.

The first thing we need to do is define our model, for this example it's only going to need a title and an image name. Add a new file called Movie and add the following.

struct Movie {
    let name: String
    let movieImageName: String
    
    static var empty: Movie {
        Movie(name: "", movieImageName: "")
    }    
}

Make sure Movie has a target membership of MoviePlannerWidgetExtension by ticking the box in the File Inspector menu on the right hand side.

Next, download the three images below and add import (drag and drop) directly into Assets.xcassets inside the  MoviePlannerWidget directory, don't import them into the asset catalogue inside Shared as this is currently inaccessible to our widget.

From left to right these images should be named endgame, gog3 and infinity, if these names don't match rename them before importing them into your project.

Next create a new file called MovieUpdater, this will be responsible for storing an array of Movie objects. We're going to add three movies to start with, but you can add as many as you'd like.

class MovieUpdater {
    var movie: Movie = Movie.empty
    var movies: [Movie] = [
        Movie(name: "Avengers: Infinity War",
              movieImageName: "infinity"),
        Movie(name: "Avengers: End Game",
              movieImageName: "endgame"),
        Movie(name: "Guardians of the Galaxy Volume 3", 
                movieImageName: "gog3")
    ]
}

Ok, that's all the setup we need, now we can get going with the widget.

Open MoviePlayerWidget.swift and scroll down to the MoviePlannerWidget declaration, this is the entry point to our widget. It conforms to the Widget protocol who's only requirement is that it have a body that returns a WidgetConfiguration. There are two types of configurations available, IntentConfiguration and StaticConfiguration. The former allows the user to perform some configuration and takes a little more to setup, whereas the latter does not, we'll be focusing in StaticConfiguration in this article, i'll cover IntentConfiguration in part two (coming soon!).

Scroll up slightly and look at the SimpleEntry struct, let's change this to be more specific to our Movie Widget. Replace it with the following.

struct MovieEntry: TimelineEntry {
    public let date: Date
    public let movie: Movie
}

MovieEntry is of type TimelineEntry, this is what the Timeline uses to update the widget, it requires a date to conform to TimelineEntry and we're going to give it a Movie so our UI has access to the current movie.

By default Apple provides us with an InentConfiguration, let's change this to StaticConfiguration, replace the contents of MoviePlannerWidget with the following.

@main
struct MoviePlannerWidget: Widget {
    private let kind: String = "MoviePlannerwidget"

    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind,
                            provider: Provider()) { entry in
            MoviePlannerWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

the only change we've made here is switching out IntentConfiguration for StaticConfiguration, you'll notice we're still getting build errors though, so what gives??

Scroll up to Provider and you'll see a myriad or errors, we renamed SimpleEntry to MovieEntry and we changed IntentConfiguration to StaticConfiguration which means Provider now needs to conform to a different protocol. Replace IntentTimelineProvider in the declaration of Provider with plain old TimelineProvider, delete the contents of Provider and replace it with the following.

struct Provider: TimelineProvider {
    typealias Entry = MovieEntry
    var movieUpdater: MovieUpdater = MovieUpdater()
        
    func placeholder(in context: Context) -> MovieEntry {
        return MovieEntry(date: Date(), movie: Movie.empty)
    }
        
    func getTimeline(in context: Context, 
    		completion: @escaping (Timeline<Entry>) -> Void) {

        var entries = [MovieEntry]()
        var nextUpdateDate = Date()
        for (i, movie) in movieUpdater.movies.enumerated() {
            nextUpdateDate = Calendar.current.
                                date(byAdding: .minute, 
                                value: 1 * i, to: Date())!
            let entry = MovieEntry(date: nextUpdateDate, movie: movie)
            entries.append(entry)
        }

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

        completion(timeline)
    }
       
     func getSnapshot(in context: Context, 
          completion: @escaping (Entry) -> Void) {
                
        let date = Date()
        let entry = MovieEntry(date: date, movie: Movie.empty)
        
        completion(entry)
    }
}

This may look a little daunting, but all the logic we need to render our widget is in here, the Provider struct bridges the gap between our code and our UI by providing a timeline.

TimelineProvider requires us to define a type for Entry, in our case we need it to be of type MovieEntry so we set that up first with typealias Entry = MovieEntry.

Next we implement placeholder(in context:) and return a MovieEntry object with an empty movie. This tells WidgetKit what to render while the widget is loading.

The job of getTimeline(context:completion:) is to provide a Timeline object populated with entries, in this case we're providing a single entry for each movie using the MovieEntry type we setup earlier. We're constructing a date  for each entry that's 1 minute after the last. We then tell WidgetKit to ask for a new timeline once the last date has passed by using the .atEnd update policy. There are currently three update policies available in WidgetKit:

  • atEnd asks for a new timeline after the last timeline entry date passes.
  • .after(_) allows us to pass in a date at which the timeline should be refreshed.
  • never relies on the app to refresh the timeline manually by calling one of the built in reload functions. For example WidgetCenter.shared.reloadAllTimelines().
I've had mixed results working with reload policies, even now there seems to be a delay when the last date passes in the timeline and the reload, this will hopefully be fixed in future betas

The job of getSnapshot(context:completion:) is to return our view, we're using local data here but if we were querying a server we could check to see if the widget is in preview mode by interrogating  context.isPreview and provide sample data, this way our widget would load instantly. We don't need to do that here as our data is all local, but it's useful to know. Context can also provide you with the widget family, an emum denoting the current size, .systemSmall, .systemMedium or .systemLarge, we can use this to render a different UI for each if we choose.

The final step is the UI. Replace the contents of MoviePlannerWidgetEntryView with the following.

struct MoviePlannerWidgetEntryView : View {
    var entry: Provider.Entry

    var gradient: Gradient {
        Gradient(stops:
                    [Gradient.Stop(color: .clear, location: 0),
                     Gradient.Stop(color: Color.black.opacity(0.95),
                                   location: 0.6)])
    }
    
    var body: some View {
        ZStack {
            Image(entry.movie.movieImageName)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .mask(
                    RoundedRectangle(cornerRadius: 10)
                        .frame(minWidth: 0, maxWidth: .infinity)
                        .frame(minHeight: 0, maxHeight: .infinity)
                )
                .frame(minWidth: 0, maxWidth: .infinity)
                .frame(minHeight: 0, maxHeight: .infinity)
            
            VStack {
                Spacer()
                LinearGradient(gradient: gradient,
                               startPoint: .top,
                               endPoint: .bottom)
                    .frame(height: 100)
            }
            VStack(alignment: .leading) {
                Spacer()
                Text(entry.movie.name)
                    .padding(.all, 10)
                    .font(.headline)
                    .foregroundColor(.white)
            }
        }
    }
}

Here we're using a ZStack with an Image to display an image to cover the full widget background, then two VStacks, one to display a gradient so you can see the text on the image and the second to display the movie title. The Spacer views we use here make sure the gradient and text are pushed right to the bottom of the widget.

Now in Xcode make sure you select the MoviePlannerWidgetExtension target.

Run the app in the simulator, the widget will appear on your home screen and it will update with a new movie every 10 seconds.

Nice work! A lot of what we've covered in this article is the setup of our Movie and MovieUpdater, in a real app you'd likely have this up and running before you built your widget. It takes a surprisingly little amount of code to create home screen widgets with WidgetKit, and the results can be powerful extensions for your users.

Where to go from here

This was a quick introduction into the power of WidgetKit, in part two i'll use the same example to show how we can use IntentConfiguration so stat tuned for that!

As mentioned previously you can use Context to check the size of the widget, try adding different UI for different sizes, for example you could display all movies in a list for .systemLarge, or add a release date to the model and display it when in .systemMedium, it's fun to experiment and i'd love to see your results!


The full code for this article can be found at https://github.com/Swift-Compiled/homescreen-widgets

Thanks for reading ✌️