Create widgets in iOS
In the WWDC20, Apple announced a disruptive new home screen experience in iOS14, one of them is the ability to add widgets on the iPhone/iPad home screen.
iOS already supported some kind of widgets using the Today extension, so if you already have that extension in your app we can reuse some pieces of code like using a shared group container to store and read the data, however in terms of UI the new widget extension for is purely SwiftUI, and this is an interesting feature to start doing the first views using SwiftUI even if your app is developed with UIKit. I think that Apple added this extension purely in SwiftUI to allow developers to do the first steps with SwiftUI in a productive app.
Docs
First of all, I strongly recommend start reading and bookmarking Apple developer documentation about WidgetKit, the Human Interface Guidelines for Widgets and if you have any question take a look Apple forum using Widget topic.
ProTip👨🏽💻: Even though there is a bunch of blog posts like this, I recommend reading Apple docs first.
Requisites
- Xcode 12.0 or above
- Basic understanding of SwiftUI
Let's get rolling
- Open the Xcode project (or create a new one) and choose File > New > Target.
- Select Widget Extension, and click next.
- Enter the product name of this new target.
- Select the Include Configuration Intent checkbox to allow user-customized config in the widget, otherwise, if you do not want to allow the user to configure the widget unselect
- Click Finish
Widget extension template walkthrough
Xcode automatically creates a Swift file with five structs, to have the target group clean I recommend to extract each struct in different groups and files, for instance, I share an example here
-
Main
HeadlinesWidget
: Let's start reviewing the starting point of the widget, implementingWidget
protocol we return in the body aWidgetConfiguration
specifying the provider, view, kind and depending on the type of widget can be a Static or Intent configuration. Let's continue with the data model of the widget
-
Model
SimpleEntry
: This struct that implementsTimelineEntry
protocol is the type that specifies the date and to display a widget and data needed in the content block of the configuration returned inHeadlinesWidget
. The responsibility of creating and returning this type is the Provider
-
ViewMode/Repository
Provider
: This struct that implements TimelineProvider protocol, if you uses aIntentConfiguration
the provider has to implement IntentTimelineProvider protocol , in the case ofStaticConfiguration
TimelineProvider protocol . In this example we will conform theIntentTimelineProvider
to support user-customized values.
-
View
-
HeadlinesWidgetEntryView
: Finally we have a default SwiftUI View as part of the content block of theWidgetConfiguration
defined at the beginning,HeadlinesWidget_Previews
: The view also includes a preview, to see the content in Xcode.
-
ProTip⚡️: I recommend to create a Group
of preview for each WidgetFamily that we want to support.
Group {
HeadlinesWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
.previewContext(WidgetPreviewContext(family: .systemSmall))
HeadlinesWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
.previewContext(WidgetPreviewContext(family: .systemMedium))
HeadlinesWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent()))
.previewContext(WidgetPreviewContext(family: .systemLarge))
}
Create the widget with the Latest News
Data model and API request task
Let's add a new struct to decode the latest stories using NewsAPI.org https://newsapi.org/v2/top-headlines?country=us&apiKey=
. In our case we are only interested in the title and the thumbnail URL of the news, so we'll create a new file TopHeadlines.swift
import Foundation
// MARK: - TopHeadlines
struct TopHeadlines: Codable {
let articles: [Article]
}
// MARK: - Article
struct Article: Codable {
let title: String
let url: String
let urlToImage: String
enum CodingKeys: String, CodingKey {
case title
case url, urlToImage
}
}
// MARK: - Helper functions for creating encoders and decoders
func newJSONDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) {
decoder.dateDecodingStrategy = .iso8601
}
return decoder
}
func newJSONEncoder() -> JSONEncoder {
let encoder = JSONEncoder()
if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) {
encoder.dateEncodingStrategy = .iso8601
}
return encoder
}
// MARK: - URLSession response handlers
extension URLSession {
fileprivate func codableTask<T: Codable>(with url: URL, completionHandler: @escaping (T?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
return self.dataTask(with: url) { data, response, error in
guard let data = data, error == nil else {
completionHandler(nil, response, error)
return
}
completionHandler(try? newJSONDecoder().decode(T.self, from: data), response, nil)
}
}
func topHeadlinesTask(with url: URL, completionHandler: @escaping (TopHeadlines?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
return self.codableTask(with: url, completionHandler: completionHandler)
}
}
We have the model, and a URLSession
task that would make the retrieve data easier for this guide, in the case you would use more services of the API, I recommend to have a networking/API layer.
Widget Entry
We add to the existing Entry, an array of Article
struct HeadlinesWidgetEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationIntent
let articles: [Article]
}
Widget Provider
Now we have to update our timeline provider to add an array of articles in the HeadlinesWidgetEntry
, first of all, we will update the placeholder
func placeholder(in context: Context) -> Entry {
HeadlinesWidgetEntry(date: Date(), configuration: ConfigurationIntent(), articles: [])
}
and the snapshot
func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Entry) -> ()) {
let entry = HeadlinesWidgetEntry(date: Date(), configuration: configuration, articles: [])
completion(entry)
}
finally we have the timeline
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
// Generate a timeline consisting of the latest news now and setting a update policy after 15 minutes
let currentDate = Date()
// The next date when the widget should reload the view with a new timeline
let nextWidgetUpdateDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
// The date of the only widget entry that will be part of the timeline.
let currentHeadlinesEntry = HeadlinesWidgetEntry(date: currentDate, configuration: configuration, articles: [])
// Timeline instance without articles in case that the retrieve from the network fails we return no stories
let timeline = Timeline(entries: [currentHeadlinesEntry], policy: .after(nextWidgetUpdateDate))
// Retrieve data from the network
// Use country code selected by the user to retrieve the news
var countryCode = "ar"
switch configuration.country {
case .ar:
countryCode = "ar"
case .us:
countryCode = "us"
case .unknown:
break
}
let newsApiKey = ""
guard let headlinesUrl = URL(string: "https://newsapi.org/v2/top-headlines?country=\(countryCode)&apiKey=\(newsApiKey)") else {
return completion(timeline)
}
let topHeadlinesTask = URLSession.shared.topHeadlinesTask(with: headlinesUrl) { topHeadlines, response, error in
if let topHeadlines = topHeadlines {
let entry = HeadlinesWidgetEntry(date: nextWidgetUpdateDate, configuration: configuration, articles: topHeadlines.articles)
completion(Timeline(entries: [entry], policy: .after(nextWidgetUpdateDate)))
} else {
completion(timeline)
}
}
topHeadlinesTask.resume()
}
Widget View
The view of the widget is HeadlinesWidgetEntryView
where we will use the environment variable .widgetFamily
to return different views for each widget size:
struct HeadlinesWidgetEntryView : View {
var entry: HeadlinesWidgetEntry
@Environment(\.widgetFamily) var family: WidgetFamily
@ViewBuilder
var body: some View {
switch family {
case .systemSmall: SmallWidgetView(entry: entry)
case .systemLarge: MediumLargeWidgetView(entry: entry, articlesCount: 5)
case .systemMedium: MediumLargeWidgetView(entry: entry, articlesCount: 2)
default: Text(entry.date, style: .time)
}
}
}
For the .systemSmall
widget we will create the SmallWidgetView
and for .systemLarge
and .systemMedium
we will use the same view using a parameter to set the number of stories to show in each family:
SmallWidgetView
The small widget will show the most recent story of the response, using a ZStack
we can put the image of the article in the background with an overlay gradient, and on top of the image the headline of the story.
struct SmallWidgetView: View {
var entry: HeadlinesWidgetEntry
var body: some View {
if let item = entry.articles.first {
ZStack(alignment: Alignment(horizontal: .center, vertical: .bottom)) {
VStack {
URLImage(url: item.urlToImage)
.scaledToFit()
.overlay(imageGradientOverlay, alignment: .bottom)
Spacer()
}
.background(Color.black)
Text(item.title)
.font(.headline)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.lineLimit(5)
.padding([.leading, .bottom, .trailing], 10.0)
}
.background(Color.black)
}
}
let imageGradientOverlay: some View = LinearGradient(
gradient: Gradient(
colors: [Color.black, Color.black.opacity(0.0)]),
startPoint: .bottom,
endPoint: .top)
.frame(height: 48)
}
MediumLargeWidgetView
For the medium and large widget family we will reuse the same view, and just showing a different amount of articles depending on the family. Using a VStack we'll render a list of stories and at the top of the widget a Text as widget header. I extracted each story view using ArticleRow
item in a different struct
Medium | Large |
---|---|
![]() |
![]() |
struct MediumLargeWidgetView: View {
var entry: HeadlinesWidgetEntry
var articlesCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 7.0) {
widgetHeaderView
ForEach(0..<articlesCount) { index in
if entry.articles.count > index,
let item = entry.articles[index] {
ArticleRow(item: item)
}
}
Spacer()
}
.padding(.horizontal, 15.0)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
let widgetHeaderView: some View = Text("🔥 TOP STORIES")
.font(.headline)
.foregroundColor(Color.storyTitle)
.padding(.top)
}
ArticleRow item:
struct ArticleRow: View {
let item: Article
var body: some View {
HStack(alignment: .top) {
URLImage(url: item.urlToImage)
.clipShape(RoundedRectangle(cornerRadius: 8.0))
.frame(width: 75.0, height: 50.0)
Text(item.title)
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundColor(Color.storyTitle)
.lineLimit(3)
Spacer()
}
}
}
Open the app from the widget
The user can long-press the widget if you support an IntentConfiguration to configure the widget with customized values, and the user can tap in the widget to open the app. You can specify .widgetURL
modifier with a deep link to the app, for example in the .small
widget we can use that modifier because it's there is only one article, in case you have to support different deep links, you can use the Link
view.
To add the URL to the small widget we can add the widgetURL
view modifier to the ZStack of the view
ZStack(alignment: Alignment(horizontal: .center, vertical: .bottom)) {
//...
}
.background(Color.black)
.widgetURL(URL(string: item.url)!)
Finally, for each story of the medium and large widget, we will wrap the ArticleRow in a Link
Link(destination: url) {
ArticleRow(item: item)
}
Challenge💡: If you are interested in continuing this tutorial and I'll ask to handle the link tap in the app and create a pull request in this project to discuss and merge your solution to open the news URL in the app using a webview
Reviewing the widget in the Widget gallery
Light mode
![]() |
![]() |
![]() |
Dark mode
![]() |
![]() |
![]() |
Editing the widget
As a user I can long-press in the widget and select the country from I wanna see the top stories, this allows the user to have two widgets of the same app with different sources.
Where to go from here?
I recommend watching the WWDC20 videos about designing and developing widgets, and something that it's important to mention in terms of efficiency of the widget is read about Keeping a Widget Up to Date to understand how the widget refresh works and what you can do from the app using WidgetCenter
because Apple says that widget receives a limited number of refreshes every day. so choosing the correct refresh strategy for your widget is a game-changer.
The project is available in Github, and I eager to see pull requests contributing to this initial idea to share with the community.
Ideas: Show an empty state view when there are no articles to show, load URL image using and ObservableObject, handle widgetURL in app opening a webview, and more...! 🙌🏾