How we build an iOS 14 Widget with SwiftUI
iOS14 was released in September with many improvements and one of a cool feature from this 14th iOS was Widget. We also have a new way of building user interfaces (SwiftUI) from WWDC19, which is very innovative and interesting to work with. In this short article, I will describe how we create our Widget extension with SwiftUI and apply it to FairPrice use cases.
Some of widget benefits
- A great place to provide quick glance with no need to open an app
- A convenient component to provide useful and up-to-date information
- An attractive tool to drive more app launches
In this article, I’m going to support the medium size widget and fetch our banners to display in this widget (the banners match perfectly with medium size)
Initiate widget extension
This is done easily by following File > New > Target, search and select Widget Extension
As a start, I will draft a simple Widget view in Widget.swift
Part 1: IntentTimelineProvider
import WidgetKit
import SwiftUI
import Intents
struct Provider: IntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), displaySize: context.displaySize)
}
func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
completion(placeholder(in: context))
}
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
completion(Timeline(entries: [placeholder(in: context)], policy: .never))
}
}
We have a struct Provider
that confronts to IntentTimelineProvider
, this provider will provide a placeholder entry, snapshot and timeline
placeholder
: while waiting for callbacks from getSnapshot and getTimeline, iOS will get this placeholder entry and draw a waiting view base on itgetSnapshot
: snapshot is used in the iOS Widget management screen, we should provide a very light view for itgetTimeline
: timeline is a list of entries that we plan for the Widget to display overtime. It comes withpolicy
, where we define when to finish the timeline and reload the whole Widget
Part 2: TimelineEntry and View
struct SimpleEntry: TimelineEntry {
let date: Date
let displaySize: CGSize
}
struct WidgetEntryView : View {
let entry: SimpleEntry
var body: some View {
Image("fairprice-logo")
.resizable()
.frame(width: 218, height: 48, alignment: .center)
.background(
Rectangle()
.fill(Color(red: 21/255.0, green: 87/255.0, blue: 191/255.0, opacity: 1))
.frame(width: entry.displaySize.width, height: entry.displaySize.height, alignment: .center)
)
}
}
TimelineEntry
: this will carry a date which tells iOS when to render this entry to Widget, we should put the necessary data here as well for rendering the correct ViewWidgetEntryView
: this will be the main View for our Widget. Here, I am using declarative syntax of SwiftUI, which is very clean and easy to maintain. This code will render the fairprice-logo in center of a blue background
Part 3: Widget and Widget_Previews
@main
struct FPWidget: Widget {
let kind: String = "Widget"
var body: some WidgetConfiguration {
IntentConfiguration(
kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()) { entry in
WidgetEntryView(entry: entry)
}
.configurationDisplayName("FairPrice")
.description("This is a FairPrice widget.")
.supportedFamilies([.systemMedium])
}
}
struct Widget_Previews: PreviewProvider {
static var previews: some View {
WidgetEntryView(
entry: SimpleEntry(
date: Date(),
displaySize: CGSize(width: 360, height: 169)
)
)
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
}
The FPWidget will provide the starting point for our Widget, it should specify Provider
and WidgetEntryView
as main components of the Widget.
I want to support medium size Widget only so I’ll add .supportedFamilies([.systemMedium])
at the end of IntentConfiguration
Widget_Previews
should provide View and Entry for previewing in Xcode
Part 4: Build and Run Widget extension I’ll get this result
Build more View
PlaceholderView: we will build a light View as a placeholder for Widget, this View is rendered while waiting for snapshot and timeline callbacks
import SwiftUI
import WidgetKit
struct PlaceholderView: View {
var body: some View {
GeometryReader { geo in
Rectangle()
.fill(Color.fpBlue)
.frame(
width: geo.size.width,
height: geo.size.height,
alignment: .center
)
}
}
}
struct PlaceholderView_Previews: PreviewProvider {
static var previews: some View {
PlaceholderView()
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
}
Here, I am using GeometryReader to get the frame and draw a Rectangle with background is filled by FairPrice blue color. We should get this result:
SnapshotView: using the same technique, I’ll use GeometryReader and Rectangle to draw the fairprice-logo in center of a blue background
import SwiftUI
import WidgetKit
struct SnapshotView: View {
var body: some View {
GeometryReader { geo in
Image("fairprice-logo")
.resizable()
.frame(
width: 218,
height: 48,
alignment: .center
)
.position(
x: geo.size.width / 2,
y: geo.size.height / 2
)
.background(
Rectangle()
.fill(Color.fpBlue)
.frame(
width: geo.size.width,
height: geo.size.height,
alignment: .center
)
)
}
}
}
struct SnapshotView_Previews: PreviewProvider {
static var previews: some View {
SnapshotView()
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
}
We should get this result:
BannerView: this is the main view for our Widget, we will have our banner filled into the Widget
import SwiftUI
import WidgetKit
struct BannerView: View {
let image: UIImage
var body: some View {
Image(uiImage: image)
.resizable()
.aspectRatio(
CGSize(width: 602, height: 169),
contentMode: .fill
)
}
}
struct BannerView_Previews: PreviewProvider {
static var previews: some View {
BannerView(image: UIImage(named: "sample-banner") ?? UIImage())
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
}
FairPrice banners have size of 1424×378, they don’t fit well into Widget medium size 360×169. In order to fix this, I am resizing the banner image with aspectRatio(CGSize(width: 602, height: 169). We should get this result with a sample banner:
Put the 3 new Views into Widget
Firstly, we refactor SimpleEntry
to support the 3 new Views, here I have EntryType
and 3 cases to represent the 3 Views
struct SimpleEntry: TimelineEntry {
enum EntryType {
case placeholder
case snapshot
case banner(UIImage)
}
let date: Date
let type: EntryType
}
Secondly, we change Provider
to provide correct EntryType
for each state of Widget
struct Provider: IntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), type: .placeholder)
}
func getSnapshot(
for configuration: ConfigurationIntent,
in context: Context,
completion: @escaping (SimpleEntry) -> ()
) {
completion(SimpleEntry(date: Date(), type: .snapshot))
}
func getTimeline(
for configuration: ConfigurationIntent,
in context: Context,
completion: @escaping (Timeline<Entry>) -> ()
) {
if let image = UIImage(named: "sample-banner") {
completion(
Timeline(
entries: [SimpleEntry(date: Date(), type: .banner(image))],
policy: .never
)
)
} else {
completion(
Timeline(
entries: [SimpleEntry(date: Date(), type: .snapshot)],
policy: .never
)
)
}
}
}
Lastly, we put the 3 EntryType
and 3 View together in WidgetEntryView
struct WidgetEntryView : View {
let entry: SimpleEntry
var body: some View {
switch entry.type {
case .placeholder:
PlaceholderView()
case .snapshot:
SnapshotView()
case let .banner(image):
BannerView(image: image)
}
}
}
Data Loading and Schedule Timeline
In this section, I will add a network request to load banners from FairPrice home API and schedule Widget Timeline to switch between banners over time
Add 2 models for saving Banner data
struct ImageSlideItem: Codable {
let imageUrl: String
let link: String
}struct Banner {
let item: ImageSlideItem
let image: UIImage
}
imageUrl
is for loading the banner image and link
is for handling deeplink when tapping on the Widget
Add API request
struct HomeAPI {
let urlString = "the_api_url"
func perform(completion: @escaping ([ImageSlideItem]) -> Void) {
guard let url = URL(string: urlString)
else {
completion([])
return
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard
let data = data,
let response = try? JSONDecoder().decode(HomeAPIResponse.self, from: data)
else {
completion([])
return
}
completion(response.extractBanners())
}
task.resume()
}
}
This code will fetch data from home API, parse it into HomeAPIResponse
(the response structure is very complicated so I’ll skip it here) and extract ImageSlideItem
list from HomeAPIResponse
Make API call and schedule Timeline
We make HomeAPI
call in getTimeline
func getTimeline(
for configuration: ConfigurationIntent,
in context: Context,
completion: @escaping (Timeline<Entry>) -> ()
) {
HomeAPI().perform { items in
let banners = items.compactMap { item -> Banner? in
guard let image = UIImage.loadImage(from: item.imageUrl)
else { return nil }
return Banner(item: item, image: image)
}
if banners.count > 0 {
let refreshInterval = 1 * 60 * 60 // Reload API after 1h
let entries = SimpleEntry.build(
banners: banners,
refreshInterval: refreshInterval
)
let refreshDate = Calendar.current.date(byAdding: .second, value: refreshInterval, to: Date()) ?? Date()
completion(Timeline(entries: entries, policy: .after(refreshDate)))
} else {
let retryDate = Calendar.current.date(byAdding: .minute, value: 5, to: Date()) ?? Date() // Retry after 5m
completion(Timeline(entries: [SimpleEntry(date: Date(), type: .snapshot)], policy: .after(retryDate)))
}
}
}
This code is doing:
- Fetch all available banners from API
- Load image from
banner.imageUrl
- If
banners.count > 0
, schedule Widget Timeline to switch banner every 10s and reload API after 1 hour - If no banners found, schedule Widget Timeline with snapshot View and retry API after 5 minutes
Code for building list of entries:
extension SimpleEntry {
static func build(
banners: [Banner],
refreshInterval: Int
) -> [SimpleEntry] {
let switchBannerInterval = 10
let repeats = refreshInterval / switchBannerInterval
let currentDate = Date()
let totalBanners = banners.count
return (0..<repeats).compactMap { index -> SimpleEntry? in
guard let date = Calendar.current.date(byAdding: .second, value: switchBannerInterval * index, to: currentDate)
else { return nil }
return .init(
date: date,
type: .banner(banners[index % totalBanners])
)
}
}
}
Build and Run we should have a completed FairPrice Widget