How we build an iOS 14 Widget with SwiftUI
By: Vo Minh Hien
Tags: Swiftui, Widgetkit, Ios 14, Widget, Nedigital

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

: 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

: 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

: 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

, 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