How we build an iOS 14 Widget with SwiftUI

Vo Minh Hien
NE Digital
Published in
7 min readDec 14, 2020
We use widget extension to provide a quick glance to banners

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
3 sizes of widget

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 it
  • getSnapshot: snapshot is used in the iOS Widget management screen, we should provide a very light view for it
  • getTimeline: timeline is a list of entries that we plan for the Widget to display overtime. It comes with policy , 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 View
  • WidgetEntryView: 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

--

--