Chapter 15: Mastering Widgets

What you’ll learn in this chapter:

  • How to extend your app’s functionality using Widgets
  • The theory behind Widgets: Providing and refreshing Widgets
  • How to offer customization options using IntentConfigurations
  • CoreData Integration and Networking Requests

Here is a preview of the Widgets we’re going to create:

The basics: How widgets work 💡

One of the major innovations of iOS 14 was the introduction of Widgets. What has been available on Android for years is now also possible on Apple devices. Widgets can be used to individualize the home screen.

Widgets are available in three different sizes: .systemSmall, .systemMedium and .systemLarge. You can see an example of this when you open the Widget Gallery and open the Widgets for the pre-installed “Clock” app, for example. Depending on the selected size of the Widget, a different view will be presented.

It is important to note that widgets are not mini-apps, i.e. they cannot be interacted with (with the exception that tapping on the Widget opens the app). Rather, they act as mere views that display information from the associated app that is relevant for the user. 

But how does the Widget exactly know when to display which information? We will go into this in detail in a moment, but here is a rough outline: When a Widget is added to the home screen, the system first asks our app for one or more entries, which are arranged along a so-called timeline. 

For example, imagine a Widget that always counts up from 0 to 40 in steps of ten, then start again from 0 (to be honest, a quite impractical example). In this example, we give the system five entries (the values from 0, 10, 20, 30, and 40) and align them on the timeline at an interval of ten seconds each. 

Note: In case you’re wondering why we don’t just update the Widget every second, Apple doesn’t allow us to refresh the Widget too often. In my experience, a 10-second interval has proven to be a reasonable minimum.

Look at the following illustration for a better understanding:

But what happens now when all given entries have been used? That is our decision: For example, we could not provide any new entries, but then the Widget would not be updated anymore (i.e., in our example, it would permanently show 40).

But we could also, for example, start over and provide five new entries again for the next 50 seconds on the timeline and so on.

Finally, we can specify a completely different time when new entries are played out, which would result in a “gap” on the timeline.

This all sounds very abstract now, but once we actually implement a Widget, it should be easier to imagine.

One more thing: By default, Widgets have a so-called StaticConfiguration, where we as developers configure all settings for our Widget in advance. But there are also so-called IntentConfigurations possible, with which we can allow the user to set their own preferences for the Widget to customize the Widget accordingly. But more about this more advanced topic later.

Creating our first Widget 🔨

Let’s start with creating our own Widget. Each widget is part of a SwiftUI app. For this, we create a new Xcode project, select „iOS App“, and name the project, e.g. “MyDemo”. 

We can leave our MyDemo app as it is. To add a Widget, we go to File – New – Target in the Xcode menubar and search for “Widget Extension”. Click on “Next” and name the widget “MyDemoWidget”, for example. Also, make sure you select a “Team” (regularly that’s your Apple Developer account). We won’t check the “Include Configuration Intent” box since we’re just using a StaticConfiguration for this example.

After you click on “Finish”, a window should pop up asking you if you want to activate the Extension Scheme. Click on “Activate”. 

In your Xcode project, there is now a new group that contains the file MyDemoWidget.swift.

Let’s take a closer look at this one.

How the Provider works

Basically, our MyDemoWidget is structured quite similarly to the corresponding MyDemoApp. In the MyDemoWidget.swift file, you will find a @main MyDemoWidget struct. 

@main
struct MyDemoWidget: Widget {
    let kind: String = "MyDemoWidget"

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

This initializes the Widget when the user adds it to the home screen. Here we can specify, among other things, whether it is a static or intent configuration and initialize the Provider. More about this in a moment.

As you can see, the MyDemoWidget struct initializes the MyDemoWidgetEntryView. We can find the corresponding one above.

struct MyDemoWidgetEntryView: View {
     
    var entry: Provider.Entry
     
    var body: some View {
        Text(entry.date, style: .time)
    }
}

As already explained, there are one or more entries on the so-called timeline. The MyDemoWidgetEntryView takes the date contained in the respective entry and represents it in a Text view.

Our Widget gets the entries from the so-called Provider, whose implementation we see above.

struct Provider: TimelineProvider {
    //...
}

The Provider contains three different functions:

  1. func placeholder
func placeholder(in context: Context) -> SimpleEntry {
    SimpleEntry(date: Date())
}

This placeholder function provides our view with a sample SimpleEntry instance. The iOS device then displays the resulting instance in a much-reduced version. This is needed when the Widget has not yet finished loading.

You’ll see how this looks like in a moment.

2. func getSnapshot:

func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    let entry = SimpleEntry(date: Date())
    completion(entry)
}

Similar to the placeholder function, the getSnapshot function also returns a sample SimpleEntry. The resulting view is used, for example, when the Widget is selected in the Widget Gallery of the device.

3. func getTimeline:

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    var entries: [SimpleEntry] = []
     
    // Generate a timeline consisting of five entries an hour apart, starting from the current date.
    let currentDate = Date()
    for hourOffset in 0 ..< 5 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        let entry = SimpleEntry(date: entryDate)
        entries.append(entry)
    }
     
     
    let timeline = Timeline(entries: entries, policy: .atEnd)
    completion(timeline)
}

This function is the heart of every Widget. As shown above, it provides the timeline with a set of SimpleEntry instances, which are then played out on the respective date.

We will look into this in more detail in a moment.

Let’s now take another look at the preview simulator. This is generated by the MyDemoWidget_Previews struct and represents a simple SimpleEntry instance.

You can see how your placeholder looks like by applying the .redacted modifier to the MyDemoWidgetEntry view in the PreviewProvider:

Setting up our widget 🧑🏽‍💻

Let’s talk about the getTimeline function of the Provider in more detail. As said before, it is responsible for providing the given entries at the right time.

Let’s delete the pre-generated code.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
     
}

As discussed in our example above, we want our Widget to display a new view every ten seconds showing the current elapsed time. For this purpose, the corresponding SimpleEntry instances must have a property to know at what time they are used and a property to hold the elapsed seconds. Our SimpleEntry already contains a corresponding date property, so we only need to add a “secondsElapsed” property:

struct SimpleEntry: TimelineEntry {
    let date: Date
    let secondsElapsed: Int
}

Next, we need to update our placeholder and getSnapshot function accordingly. 

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), secondsElapsed: 10)
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), secondsElapsed: 10)
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        
    }
}

We also need to update the PreviewProvider struct:

struct MyDemoWidget_Previews: PreviewProvider {
    static var previews: some View {
        MyDemoWidgetEntryView(entry: SimpleEntry(date: Date(), secondsElapsed: 10))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

When our Widget requests the timeline (the first time this happens is when the Widget is added to the home screen), the getTimeline function is called. When this happens, we create an empty array first:

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    var entries = [SimpleEntry]()
     
}

Starting from the current Date, we now create five different entries covering the next five 10-second intervals and add them to the entries array:

var entries = [SimpleEntry]()
let currentDate = Date()
for secondOffset in 0 ..< 5 {
    let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset*10, to: currentDate)!
    let entry = SimpleEntry(date: entryDate, secondsElapsed: secondOffset*10)
    entries.append(entry)
}

From the entries array, we can now create a Timeline and then provide it to the Widget.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    var entries = [SimpleEntry]()
    let currentDate = Date()
    for secondOffset in 0 ..< 5 {
        let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset*10, to: currentDate)!
        let entry = SimpleEntry(date: entryDate, secondsElapsed: secondOffset*10)
        entries.append(entry)
    }
     
    let timeline = Timeline(entries: entries, policy: .never)
    completion(timeline)
}

The policy parameter determines after which rule a new timeline should be asked for, i.e. the getTimeline function should be called the next time. We have the choice between .never (self-explanatory), .atEnd (i.e. immediately when the last SimpleEntry has been played on the timeline) or .after (here we specify an individual Date when the getTimeline function should get called again).

To display the elapsed seconds, we update our MyWidgetEntry view accordingly.

struct MyDemoWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text("\(entry.secondsElapsed) seconds elapsed" )
    }
}

To test our Widget we need to run the “MyWidgetDemoExtension” scheme in the regular simulator.

After the Widget has been installed and added to the simulator’s home screen, we call the getTimeline function for the first time and add the five different entries to the timeline at intervals of 10 seconds each. Accordingly, the MyDemoWidgetEntryView should refresh every ten seconds. 

Note: Even if the date of a SimplyEntry instance is reached on the timeline, this is no guarantee that the Widget will be refreshed at exactly this time. We are just setting the earliest time for the Widget to be refreshed. For our example, this is not so important, but you should definitely keep it in mind!

Since we have currently chosen .never as the policy, after playing the last SimplyEntry instance of the timeline with a secondsElapsed value of 40, the Widget is not updated anymore. If we change the policy to .atEnd, the getTimeline method is called again immediately and five new entries are added to the timeline and so on.

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

We can create a “gap” in the timeline by using the .after policy to select a specific time when the getTimeline function should be called again.

let timeline = Timeline(entries: entries, policy: .after(Calendar.current.date(byAdding: .second, value: 90, to: currentDate)!))

Now that we have familiarized ourselves with the theoretical functionality of Widgets, we will deepen our knowledge with practical examples. For this purpose, we will create Widgets for our already existing StockX and To-Do app.

Leave a Reply

Your email address will not be published. Required fields are marked *