Build WidgetKit With SwiftUI in iOS 14
Let’s learn how to build some widgets for our home screen in a few minutes
WWDC 2020 gave us a lot of enhancements and updates, but the introduction of the WidgetKit framework unarguably stands out.
iOS 14 has introduced a redesigned home screen, with the inclusion of widgets being a huge addition. Widgets are not just an eye-pleasing UI shortcut for our apps. They also aid in providing useful information to the user from time to time.
In some ways, they’re a calmer form of notification that provides you with the latest information from the apps (if the developer has opted to do so) without intruding. Additionally, there’s a new Smart Stack feature in iOS 14 that groups a set of widgets that you can swipe through. Smart Stacks tend to provide the relevant widget at the top by using on-device intelligence that takes into account the time of the day, location, and some other attributes.
WidgetKit is built purely using SwiftUI, which opens endless opportunities for building beautiful widgets. It’s important to note than WidgetKit isn’t meant to build mini-apps.
Besides providing a Link
button that lets you set a deep-link URL to navigate to a particular part of your application, you can’t add any animations or other interactions in widgets.
Our Goal
Understanding the anatomy of WidgetKit. We’ll explore the framework and see how widgets are created and updated.
Build a Joke of the Hour widget in SwiftUI.
Bundle our SwiftUI application with two widgets by using
WidgetBuilder
.
Without further ado, let’s get started.
WidgetKit Framework: Under the Hood
To understand the anatomy of WidgetKit, let’s create a new Xcode 12 SwiftUI project and ensure that the SwiftUI App Lifecycle is chosen.
To create our first widget, Go to File → New → Target and select the Widget Extension template. Make sure you uncheck “Include Configuration Intent,” as we’ll only cover static configurations in this article.
Upon adding the Widget Extension Target, you’ll be greeted with a file containing a bunch of SwiftUI code that might seem new to you. Don’t worry, we’ll walk through all of it.
The following snippet is the starting point of your widget:
These are the inferences drawn from the code above:
kind
is an identifier used to distinguish the widget from others in the WidgetCenter.Inside
WidgetConfiguration
, we set a Placeholder view that’s displayed while the widget loads. The contents of the widget are set inside theFirstWidgetEntryView
that we’ll see shortly.Provider
is a struct of typeTimelineProvider
that’s the core engine of the widget. It’s responsible for feeding the widget with data and setting intervals for updating the data. Again, we’ll come to this shortly.The view modifiers
configurationDisplayName
anddescription
display respective information in the Widget Gallery — a place that hosts all the widgets on your device.
There’s another new modifier for the WidgetKit framework (supportedFamilies
) inside which we can pass the different sizes of widgets we want for our application. For example, .supportedFamilies([.systemLarge])
allows only large-sized widgets.
WidgetKit Timeline Provider
As the name suggests, the Provider
struct looks to provide data for the widget’s content. It conforms to a TimelineProvider
protocol that requires the implementation of two methods (snapshot
and timeline
), as shown in the default example given below:
The
snapshot
function is used to immediately present a widget view, while the Timeline Provider looks to load the data. It’s important that you set the snapshot method with dummy data only, as this view would be displayed in Widget Gallery too.The
SimpleEntry
struct is what actually holds the data for a singleTimelineEntry
and is eventually displayed in the widget’s content.The
timeline
function, on the other hand, is used to create one or more entries. We can set the time interval after which theTimelineEntry
needs to be updated.The code above basically adds five timeline entries that would be used to update the widget’s content hourly. The data set in the default example is the date text.
The
Timeline
instance also contains aTimelineReloadPolicy
. The system would use this policy to determine when to invoke thetimeline
function again — for loading the next set of timeline entries. In the code above, the policy is set asatEnd
, which means after the fifthSimpleEntry
is displayed on the widget, the system would trigger thetimeline
function for the next batch.Besides
atEnd
, we also have aafter(Date:)
property. It is used to set a specific date at which we want the next timeline to be fetched. Do note that system may not fire thetimeline
function at the exact date. Also, we can set anever
as the reload policy to ensure the timeline is not fired again.
Additionally, if you want to trigger a reload of widget entries, you can use the WidgetCenter
API. It lets us reload a particular timeline or all timelines.
The TimelineProvider
struct is incredibly important, as your dynamic content would be fetched in it to update the widget’s content on specific intervals of your choice.
SwiftUI Widget View
Now that we’re clear on how WidgetKit operates under the hood, let’s look at the SwiftUI view that’ll display the widget on the screen.
The struct above populates the widget’s view with the data set in the Provider.Entry
. Provider.Entry
is the data source for our widget’s view, and if you look back at the TimelineProvider
, you’ll see SimpleEntry
struct was set as the type alias for Entry
.
Now that we’ve seen how WidgetKit operates — from displaying Placeholder to populating the widget views with Timeline entries specified in the TimelineProvider
— we’re ready to build our own custom widget: a jokes tracker that updates every hour (you can customize the time interval).
Build a Jokes Widget Tracker in iOS 14
The TimelineProvider
lets us fetch data from network requests or a database while the PlaceholderView
is displayed. The approach to fetching data from an API for widgets is the same as you’d do for the application.
The following code uses a Combine-powered URLSession
to decode the API response and pass it through a completion block:
public class DataFetcher : ObservableObject{ | |
var cancellable : Set<AnyCancellable> = Set() | |
static let shared = DataFetcher() | |
func getJokes(completion: @escaping ([ChuckValue]?) -> Void){ | |
let urlComponents = URLComponents(string: "http://api.icndb.com/jokes/random/10/")! | |
URLSession.shared.dataTaskPublisher(for: urlComponents.url!) | |
.map{$0.data} | |
.decode(type: ChuckJokes.self, decoder: JSONDecoder()) | |
.eraseToAnyPublisher() | |
.receive(on: DispatchQueue.main) | |
.sink(receiveCompletion: { completion in | |
}) { response in | |
completion(response.value) | |
} | |
.store(in: &cancellable) | |
} | |
} |
Do note that SwiftUI widget views cannot and should not be updated using property wrappers like Published
or State
.
The response is decoded using the following Codable struct:
struct ChuckJokes : Decodable { | |
let type : String? | |
let value : [ChuckValue]? | |
enum CodingKeys: String, CodingKey { | |
case type = "type" | |
case value = "value" | |
} | |
init(from decoder: Decoder) throws { | |
let values = try decoder.container(keyedBy: CodingKeys.self) | |
type = try values.decodeIfPresent(String.self, forKey: .type) | |
value = try values.decodeIfPresent([ChuckValue].self, forKey: .value) | |
} | |
} | |
struct ChuckValue : Decodable { | |
let id : Int? | |
let joke : String? | |
enum CodingKeys: String, CodingKey { | |
case id = "id" | |
case joke = "joke" | |
} | |
init(from decoder: Decoder) throws { | |
let values = try decoder.container(keyedBy: CodingKeys.self) | |
id = try values.decodeIfPresent(Int.self, forKey: .id) | |
joke = try values.decodeIfPresent(String.self, forKey: .joke) | |
} | |
} |
Creating Our Data Source
Our widget would display the jokes as a string. So, let's add the field in our SimpleEntry
struct as shown below:
struct JokesEntry: TimelineEntry { | |
public let date: Date | |
public let joke : String | |
} |
Creating a Timeline Provider
The following timeline provider structure fetches the data from DataFetcher
and sets them on an array of TimelineEntries
configured at a given time interval:
struct JokeProvider: TimelineProvider { | |
public func snapshot(with context: Context, completion: @escaping (JokesEntry) -> ()) { | |
let entry = JokesEntry(date: Date(), joke: "...") | |
completion(entry) | |
} | |
public func timeline(with context: Context, | |
completion: @escaping (Timeline<Entry>) -> ()) { | |
DataFetcher.shared.getJokes{ | |
response in | |
let date = Date() | |
let calendar = Calendar.current | |
let entries = response?.enumerated().map { offset, currentJoke in | |
JokesEntry(date: calendar.date(byAdding: .second, value: offset*2, to: date)!, joke: currentJoke.joke ?? "...") | |
} | |
let timeLine = Timeline(entries: entries ?? [], policy: .atEnd) | |
completion(timeLine) | |
} | |
} | |
} |
Note: Setting a time interval of seconds isn’t recommended in production applications. The code snippet above does that for demonstration purposes only.
Creating Widget SwiftUI View
The following piece of code represents our SwiftUI view with the SimpleEntry
struct set as its data source:
struct JokesWidgetEntryView : View { | |
var entry: JokeProvider.Entry | |
@Environment(\.widgetFamily) var family | |
@ViewBuilder | |
var body: some View { | |
switch family { | |
case .systemSmall: | |
Text(entry.joke) | |
.minimumScaleFactor(0.3) | |
.padding(.all, 5) | |
default: | |
VStack{ | |
Text("Chuck Norris Cracks:") | |
.font(.system(.headline, design: .rounded)) | |
.padding() | |
Text(entry.joke) | |
.minimumScaleFactor(0.3) | |
.padding(.all, 5) | |
} | |
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) | |
.background(Color.pink) | |
} | |
} | |
} |
In the code above, by using the SwiftUI @ViewBuilder
and using the widgetFamily
enum type as an EnvironmentObject, we’ve set different SwiftUI views for various widget shapes.
Finally, let’s set our WidgetConfiguration
and launch the application on an iOS 14 device:
struct JokesWidget: Widget { | |
private let kind: String = "JokesWidget" | |
public var body: some WidgetConfiguration { | |
StaticConfiguration(kind: kind, provider: JokeProvider(), placeholder: PlaceholderView()) { entry in | |
JokesWidgetEntryView(entry: entry) | |
} | |
.configurationDisplayName("Jokes Widget") | |
.description("This is a widget ") | |
} | |
} |
Here is the output of the widget in action:
As you can see, we have two widgets of different sizes and different layouts. You can further customize them with your own beautiful SwiftUI views.
For demonstration purposes, I’ve set the TimelineProvider
interval to one second (though it isn’t ideal in widgets and can eat a lot of battery).
Using Widget Builder for Creating Multiple Widgets
We can create different widgets and bundle them together using a WidgetBundleBuilder
:
@main | |
struct MyWidgetBundle: WidgetBundle { | |
@WidgetBundleBuilder | |
var body: some Widget { | |
AnotherJokesWidget() | |
JokesWidget() | |
} | |
} |
Conclusion
Widgets are handy and pleasing to the eyes. As Apple has introduced a new WidgetKit framework and widgets on the home screen on iOS, a lot of applications will look to leverage it. However, it’ll be interesting to see how much of an impact it has on user privacy. The full source code is available in the GitHub repository.
In the next part, we’ll use intent configurations and links to build some handy widgets.
That’s it for this one. Thanks for reading.