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 struct
s 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 aTimelineProvider
and a closure that is invoked when a timeline event occurs.The configuration’s
TimelineProvider
is called upon to produce aTimeline
. This is an asynchronous call which gives theTimelineProvider
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 theDate
(which is day AND time) given. Once that’s done the next one is run on itsDate
.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 theTimelineProvider
and the process repeats.If the policy is
.after
that provides aDate
on which a new timeline will be requested from theTimelineProvider
which starts the process again.
Each time a
TimelineEvent
is requested, the closure attached to the configuration is called to provide a newView
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:
Select the file you want to share from the Project Navigator.
Open the File Inspector (Option+Cmd+1).
Look for Target Membership. You should see
PetesQuotes
already selected.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 View
s in a stack. At the top of the stack is the QuoteView
which is shared with the widget. Below that are a couple of ColorPicker
s 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 theSigning & Capabilities
tab. OpenApp Groups
and you will see a group calledgroup.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 thesuiteName
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 ofUserDefaults.standard
in both the main app and the widget. This is easy because its all encapsulated inSettings
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:
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.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.
Finally, the
body
uses theQuoteView
, passing to it values fromSettings
and the text size determined by the widgetfamily
.
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 TimelineEvent
s - 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) } }
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 theQuoteService
will cache the results so if there are already a cache of quotes, thegetQuote
function immediately calls the closure.Inside the closure a single
TimelineEntry
orSimpleEntry
for this example, is created. It is given the current date/time and a random quote from theQuoteService
. This is placed into an array of entries.Because of the nature of how I want this quote to work, rather than use the
.atEnd
policy, I calculate the next time aTimeline
is needed based on the refresh rate stored in theSettings
. This is passed as the.after
policy when creating theTimeline
.Finally, the
completion
handler of thegetTimeline
function is called to pass back theTimeline
.
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.