Widgets with SwiftUI

I thought I’d look into widgets, the latest craze on iOS. I intended to read up on them, do some experimental code, craft a clever example, and help you get a jumpstart. They say the road to Hell is paved with good intentions. And while widgets did not turn into fire and brimstone, it turned out to be more challenging than I thought.

When you first read about them, you think, “that’s seems reasonable and straightforward.” Then you try an example which isn’t too complex. That works, kind of. It seems to do what the documentation says. So you go, “OK, fine. I’ll extend this and try out some of the other aspects of widgetery.” And then comes trouble.

Here’s the TLDR; part: there’s a bug, actual two bugs, with widgets in iOS 14. The first is that when you run them from Xcode, some strange things might happen. The second is during runtime when things don’t happen exactly as they said they would.

I spent a few hair pulling hours trying to figure out how I could have misunderstood something that seemed “fairly clean” (in Apple-speak, that means it has at least two interpretations of how its supposed to work).

Let’s jump into widgets using my example and I’ll explain. In the end it works nicely, albeit not as perfectly as you would like. I’m sure Apple is fixing it right now.

A widget is supposed to be a mini-app or a mini version of an app. For my “Pete’s Journal” app, I’m making the widget find an event from the previous year so you can say “wow, it’s been a year already!”.

In this example, the tables are turned - the widget is the star and the app just supports it. The widget is “Pete’s Quotes” (not quotes actually from me) which uses a free quotation (famous sayings) service and presents a quote every-so-often (this is the point of trouble). Take a look at the three screen shots and you will see the three different possible sizes for widgets.

The app that supports the widget lets you change the background and text colors as well as how frequently the quotation changes. So of a quote-a-day type of thing.

This example shows you:

  • How to write a widget.

  • How to use a remote API with a widget.

  • How to address the different widget sizes.

  • One way to share data between your widget and your app.

You can find the source code to this project on my GitHub Repository.

Creating the Widget

A widget is another build target in your Xcode project. You typically share some code between your app and your widget. In this case, the code shared includes:

  • The QuoteService which is what fetches the quotations from the free remote system and caches them.

  • The QuoteView which shows the quotation against the selected color.

  • The Settings which houses the background color, text color, and refresh rate.

  • The Assets which contain icons and color sets.

If you design your views with intention, you can make good use of reusability. In this case, QuoteView, is used by the app as an example to show that the color combination looks like as well as by the widget itself to display the quotation.

Widgets work using Timelines. Apple has a good scenario where the widget is part of a game app and shows a character. Once a character does a battle, its power level drops. The widget shows its charge and it takes 4 hours to bring the character to full capacity. The Timeline for the widget is set at hour intervals where each hour the character is given a 25% boost in power. Once 100% is reached, the Timeline stops and the widget remains static. If the app is opened and the character played, the app sends a message to the widget to restart its Timeline.

Timelines can be specified in one of three ways:

  • Automatically refreshed once the last entry of the timeline has been used.

  • Stops refreshing after the timeline has been played through. This is Apple’s example.

  • Refreshed again at some point in the future. Again, Apple has an example of a Stock widget the works Monday through Friday, but on Friday, the Timeline is set to be refreshed the following Monday morning.

My quotation widget uses the last refresh type. Let’s say the quotation should update every morning at 8am. The Timeline will have a single entry for 8am and then signal that it wants to be reactivated at 8am the following morning.

Here is where the bugs came into play. I did not want to wait until 8am every morning to test this. Instead, I had the Timeline refresh every minute or two. The first entry in the Timeline was “now” and then was given “now+1 minute” as when to refresh again. Seemed reasonable to me.

When I ran this from Xcode, I saw multiple Timelines getting created! And then while it was running, I’d see the Timeline refresh around when I wanted, then suddenly refresh again, then maybe two or three times longer. When I finally looked for help, I saw entries in stackoverflow.com that pointed to known bugs. Once I kicked off my widget from Xcode and then disconnected, the Timelines behaved much more predictively, but still, they didn’t always run when I expected (bug number two).

So, when you develop your widgets, just launch them from Xcode, then disconnect, unless Apple has fixed this by the time your start to explore widgets.

Widget Target

The first thing you want to do is add a new target to your project. In Xcode, do File->New Target and pick Widget as the target. In the dialog that appears, make sure Intents is NOT checked (this is for Siri integration which I am not covering here).

When done, your project will have a new build target. If you’ve never worked with multiple build targets before, here are some tips:

  • A target is something that can be built. You can have a target be for a completely different app, but mostly targets are for libraries or accessories to your main app like a watch app or a widget.

  • A target can have its own set of code, completely independent of the main or first target. More likely however, is that you will want to share code between targets. In this example, a couple of the files are shared and its something you want to think about in your architecture.

  • You share code between targets by selecting the file you want to share, opening the File Inspector in Xcode, and checking all the targets that should include the file. This article will cover that below. The files being shared remain in their original location, but you might want to make an Xcode group or folder for shared files if that makes more sense for your project.

In the Widget target there are some files of particular importance:

PetesQuotes_Widget.swift - This is the main file for the widget. You can split its contents into multiple files, of course, but Apple packed it all into one place.

Assets - This contains assets specific to the widget. You will probably also share the Assets from your main project if you have color sets or images you want to use.

info.plist - The projects settings. Depending on what your widget does, you may need settings similar to ones in your main project. For example, in Pete’s Journal, the widget needs permission to access the calendar database.

If you open the widget file (PetesQuotes_Widget.swift) you will see that it has a number of structs in it. Briefly,

struct Provider: TimelineProvider - I mentioned above that Widgets work on a timeline. This struct is used to build the timeline. More about its content below.

struct SimpleEntry: TimelineEntry - Think of a TimelineEntry as a data model. The TimelineProvider creates instances of these TimelineEntry structures to be used as data to the widget’s UI.

struct PetesQuotes_WidgetEntryView : View - This is the View of the widget. It is given a TimelineEntry to present.

struct PetesQuotes_Widget: Widget - This is the widget’s main application entry point.

The lifecycle goes like this:

  • The widget’s main app (PetesQuotes_Widget) is launched.

  • Its body is a widget configuration that consists of a TimelineProvider and a closure that is invoked when a timeline event occurs.

  • The configuration’s TimelineProvider is called upon to produce a Timeline. This is an asynchronous call which gives the TimelineProvider implementation the ability to itself make asynchronous calls to remote services.

  • Once a timeline has been received, the OS runs it according to the TimelineEntry events in the timeline array. Each event is run on the Date (which is day AND time) given. Once that’s done the next one is run on its Date.

  • Once all events in the timeline have been run, what to do next is determine by timeline’s policy.

    • If the policy is .never then the whole thing stops and the widget just sits there looking like it looks from the last event. Only the app can trigger a new timeline sequence.

    • If the policy is .atEnd then a new timeline is requested from the TimelineProvider and the process repeats.

    • If the policy is .after that provides a Date on which a new timeline will be requested from the TimelineProvider which starts the process again.

  • Each time a TimelineEvent is requested, the closure attached to the configuration is called to provide a new View to be displayed by the widget.

That’s how it’s supposed to work. And it does largely, given the caveats above. But even if all goes as it should, iOS does not guarantee that a TimelineEvent will occur exactly at the date and time specified; just thereabouts, and always at or after that date and time.

So that’s how a widget lives. Now let’s get to this specific example.

Sharing Files

In this example project, some files need to be shared between the main target and the widget target. The QuoteService.swift file is one of them. Follow these steps to share a file:

  1. Select the file you want to share from the Project Navigator.

  2. Open the File Inspector (Option+Cmd+1).

  3. Look for Target Membership. You should see PetesQuotes already selected.

  4. Select PetesQuotes_WidgetExtension to add the file to that target (it is already added for you, but you get the idea).

And that’s it! The files shared between the targets are:

  • QuoteService

  • QuoteView

  • Settings

  • Assets.xcassets

When making a widget for your own app, keep in mind dependencies in the files. You may need to bring in a lot more files or maybe there is a way to engineer the code to reduce the dependencies. Keeping the widget small is a recommendation. I don’t know what the limitations to this are, but its always safe to err on the side on smallness.

Quote Service

We start with the app, even though that is not the star of this show. If you open the ContentView of the app you will see that it’s just a bunch of Views in a stack. At the top of the stack is the QuoteView which is shared with the widget. Below that are a couple of ColorPickers and a standard Picker to set the refresh rate.

The quotations come from a free data source. This is handled by QuoteService. I use Alamofire to make the one and only remote call because it’s easy to use. If you want to use URLSession go right ahead.

QuoteService does two things: fetches the quotes from the remote API and provides a random quote from the result. The result of the API is stored in an array of Quote objects. Its pretty simple stuff.

Back in ContentView you will find an onAppear modifier that triggers the QuoteService to fetch the quotes. The fetchQuotes function invokes the service and provides a callback closure to get a random quote and stuff the result into the @State vars passed to QuoteView.

Settings

Along with the quotation (and author), there is also the matter of the appearance and frequency of updating the quotation in the widget. The Pickers let you change the values. The values are stored in Settings.

Take a look at Settings and you’ll find functions to load and save the settings. Each time a Picker’s value changes it tells Settings to make a save.

If you have used UserDefaults before you most likely used UserDefaults.standard. That’s fine for the app itself, but none of its accessories (watch, widget) can access it. They have their own defaults. To enable sharing data between targets, you need to do two things:

  • Add Groups to your project. Go to your project file and tap on a target (eg, PetesQuotes). Tap on the Signing & Capabilities tab. Open App Groups and you will see a group called group.PetesQuotes and it is checked. If you do the same for the widget target you will see the same group. The group’s name will be passed as the suiteName in the next step. In your own app would use an appropriate group name and maybe even several if that meets your needs.

  • Use UserDefaults(suiteName:) instead of UserDefaults.standard in both the main app and the widget. This is easy because its all encapsulated in Settings which is shared between the targets.

QuoteView

Take a quick look at QuoteView. It’s not really that interesting. It gets all of the information it needs via its parameters. It uses a ZStack to place a color below and images below the quotation and author Text views. And that’s it. You can change it however you like. The point is that it relies on nothing outside of itself.

The app’s only purpose is to put values into Settings or rather into UserDefaults(suiteName: “group.PetesQuotes”) so it can be used by the widget.

The Widget Itself

Take a look at struct PetesQuotesWidgetEntryView inside of PetesQuotes_Widget.swift. Here is the content of that file, annotated.

struct PetesQuotes_WidgetEntryView : View {
  // 1 - environment
    @Environment(\.widgetFamily) var family: WidgetFamily
    var entry: SimpleEntry
    var backgroundColor: Color
    var textColor: Color
    
    // 2 - widget sizes
    // use the widget's size to determine how large the
    // text should be
    func textSize() -> CGFloat {
        switch family {
        case .systemSmall:
            return 10
        case .systemLarge:
            return 25
        default:
            return 17
        }
    }
    
  // 3 - display the quote
    var body: some View {
        QuoteView(
            quotation: entry.quote?.text ?? "Computers are hard to use and unreliable.",
            author: entry.quote?.author ?? "Unknown",
            textSize: textSize(),
            background: backgroundColor,
            textColor: textColor)
    }
}

The points of interest are:

  1. This widget supports different sizes: small, medium, and large. The widget configuration specifies which sizes you want to display. The default is small. The Environment family is set to the size this particular widget should use.

  2. For this widget, the size is used to determine how large the text in the quotation should be. Your own widget might display more or less information or even display completely different looks depending on the size.

  3. Finally, the body uses the QuoteView, passing to it values from Settings and the text size determined by the widget family.

Quote Service and the Timeline

There’s an important part that I have glossed over. I mentioned the TimelineProvider and that its job is to provide a set of TimelineEvents and what to do once the last event has been activated.

In this widget though, the data to build the TimelineEvents - SimpleEvent in this example - comes from a remote service. There’s only one place you can safely make a remote call from a widget. Now I have experimented with putting a remote call in different places, and wasn’t really getting what I wanted. To be honest, I was trying to figure out what was going on from Xcode debugger and, there’s that bug I didn’t know about. However, Apple’s documentation alludes to putting remote, asynchronous calls, into the TimelineProvider - QuoteProvider in this example.

If you open the Swift widget file, you’ll find the TimelineProvider. Look for the getTimeline function:

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    var entries: [SimpleEntry] = []
                
    // refresh the settings before generating a new timeline 
    // so the display syncs with the data.
    settings.loadSettings()
        
    // 1 - get a quote
    // ask the QuoteService for a quote. this will return 
    // immediately because its cache has one or
    // it will make a remote call, fill its cache, then return
    quoteService.getQuote {
        // 2 - set up an entry for immediate use
        let currentDate = Date()
        entries.append(SimpleEntry(date: currentDate, quote: quoteService.randomQuote()))
         
        // 3 - set up for next one
        // we want a new timeline once the refreshRate expires (eg, 10 minutes from now).
        let nextTime = Calendar.current.date(byAdding: .minute, 
                                             value: settings.refreshRate.rawValue, 
                                             to: currentDate)!
            
        let timeline = Timeline(entries: entries, policy: .after(nextTime))
        
        // 4 - return the finished timeline
        completion(timeline)
    }
}
  1. After the Settings are loaded, the quoteService is called to get a quote. What this function is doing is making a remote, asynchronous call, to the quote API. The function takes a closure and calls this closure when the API finally returns a list of quotes. A side effect is that the QuoteService will cache the results so if there are already a cache of quotes, the getQuote function immediately calls the closure.

  2. Inside the closure a single TimelineEntry or SimpleEntry for this example, is created. It is given the current date/time and a random quote from the QuoteService. This is placed into an array of entries.

  3. Because of the nature of how I want this quote to work, rather than use the .atEnd policy, I calculate the next time a Timeline is needed based on the refresh rate stored in the Settings. This is passed as the .after policy when creating the Timeline.

  4. Finally, the completion handler of the getTimeline function is called to pass back the Timeline.

What is happening is that whenever a Timeline for this widget is needed, it first asks the QuoteService to get quotes. That either invokes the closure immediately or after all of the quotes have been fetched. The Timeline created has a single entry - what to display “now” and is told a new Timeline isn’t needed until refreshRate minutes have passed. If you wanted the quote to be once a day, then the nextTime should be set to the currentDate + 1 day at say, 1am.

Placeholder and Snapshot

One thing I’ve ignored up to this point is the TimelineProvider functions placeholder() and getSnapshot. These functions are used to display the widget in their previews when the user has decided to add a widget to their home screen. I haven’t figured out which one is used when, so the best thing I can tell you is to provide your widget view with a default look. In my case I use a nil Quote which tells the QuoteView to use a default saying and author.

Launching from Xcode

Now that you’ve got something a widget put together, you probably want to try it out. Go to the target bar in Xcode and select the widget target rather than the app target.

Theoretically you can debug widgets. I’ve had marginal success with this. Sometimes my breakpoints and print statements work, most of the time they are ignored.

When you do launch the widget from Xcode you may see that your TimelineProvider’s getTimeline() function is called multiple times. That’s the bug. It may even cause a crash. Just disconnect Xcode and the widget should (eventually) begin behaving like you think it should.

If by the time you read this you think I’m crazy and it all works well, then Apple have fixed it.

The debugging experience isn’t what I would call “great”. So don’t get discouraged if you are trying to hit breakpoints and things are not working out. Just run it without the debugger and see if behaves close to what you want.

Summary

Widgets are fun. While it was frustrating for a bit, I think they can add a new dimension to your app. I chose this quotation app/widget because it focuses nicely on the widget. But in most apps the widget is supplemental and gives your user an at-a-glance indication of something. Maybe that’s the latest mortgage rates or a user’s current net worth. Or just a bit of inspirational text for the day.


Previous
Previous

Sheets with SwiftUI

Next
Next

A SwiftUI Sidebar