Creating your first Widget via WidgetKit iOS 11.07.2021

Introduction

Starting with iOS 14, the new WidgetKit framework was introduced. With the new WidgetKit framework, you can build widgets that can be added to your home screen to let you see important information at a glance. Widgets can be presented in multiple sizes and placed on the iOS Home screen, Today View, or macOS Notification Center.

Widgets created before iOS 14 can't be placed on the home screen, but they are available on the Today View.

Widget design comes in three types: system small, system medium and system large. You can support all three of them or stick with just one size by defining this option in your code.

A widget contains following blocks

  • Configuration Intent. User can set additional options for your widget.
  • TimelineProvider. It provides some data over time and you can define a refresh policy.
  • View. A SwiftUI view to display the data is needed. There is a caveat not all SwiftUI views can be used within Widgets, that is due to the static nature.

New widgets support in your app starts with Widget Extension. Add a new target to the Xcode project. After the widget extension target is added, Xcode will generate some entities required for widget implementation.

Let's do it now, follow these steps:

  1. Go to File > New > Target > Widget Extension.
  2. Choose product name (like WordWidgetExtension). If you want the widget to be user configurable check the "Include Configuration Intent" checkbox. Leave it unchecked for a static Widget configuration.
  3. Click Activate on the following popup.

Widget. In the code listing below, you can see a structure that conforms to the Widget protocol. It will be the main entry point for a newly created target, and this code is required to initialise and configure the widget.

import SwiftUI
import WidgetKit

@main
struct WordWidgetExtension: Widget {
    let kind: String = "WordWidgetExtension"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            WordEntryView(entry: entry)
        }
        .configurationDisplayName("Word Widget")
        .description("This is a word widget.")
        .supportedFamilies([.systemMedium])
    }
}

The only requirement of the Widget protocol is body property that should return an instance of WidgetConfiguration. SwiftUI provides us two structs that conform to WidgetConfiguration: StaticConfiguration and IntentConfiguration.

StaticConfiguration has three parameters to configure

  • kind. A unique string identifier for the widget.
  • provider. A timeline provider object that supplies an array of timeline entries to WidgetKit to display.
  • content. A closure that takes a timeline entry and returns a SwiftUI view to display the widget.

Timeline Entry. A timeline entry is an object conforming to the TimelineEntry protocol. There two main parts a data and a date and time for when the entry is valid.

struct WordModel {
    let word: String
    let translation: String
    let lang: String
    let example: String

    static func getPlaceholder() -> WordModel {
        return getData().first!
    }

    static func getData() -> [WordModel] {
        return [
            WordModel(word: "comprare", translation: "buy", lang: "it", example: "Io compro una macchina"),
            WordModel(word: "kaufen", translation: "buy", lang: "ge", example: "Ich kaufe ein Auto")
        ]
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let word: WordModel
}

Timeline Provider. The TimelineProvider protocol provides some data over time with the getTimeline(in:completion:) method. In this method, you can define a refresh policy by making a new timeline for the widget request so that it can show refreshed data. Also, in this method, you can provide an array of timeline entries, one for the present time, and others for future times based on your widget’s update interval.

There are three methods to build data for timeline entries:

  • placeholder() method provides the initial view and give the user a general idea of widget's appearance.
  • getSnapshot() method provides the widget with a entry to display when the widget needs to be shown in transitory situations.
  • getTimeline() method provides the widget with an array of values to display over time. You call the completion handler with the array of timeline entries and the required refresh policy.

So, timeline is represented by one or more entries specified in getTimeline method. There are few ways to tell system to trigger your timeline update, and it depends on the selected reload policy

  • atEnd reloads timeline after the last date
  • after(date) reloads timeline after the specified date
  • never WidgetKit will never request a new timeline from the widget
struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), word: WordModel.getPlaceholder())
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), word: WordModel.getPlaceholder())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        let results = WordModel.getData()

        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: 5+hourOffset, to: currentDate)!

            let randomIndex = Int.random(in: 0..<results.count)
            let item = results[randomIndex]

            let entry = SimpleEntry(date: entryDate, word: item)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

View.

struct WordEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        ZStack{
            Color.bg
            VStack(spacing: 10) {
                HStack {
                    Text(entry.word.word).font(.headline).foregroundColor(.primaryText)
                    Text(entry.word.lang).font(.footnote).foregroundColor(.primaryText).baselineOffset(6.0)
                }
                Text(entry.word.translation).foregroundColor(.primaryText).font(.body)
                Text(entry.word.example).foregroundColor(.primaryText).font(.caption)
            }
        }
    }
}

import SwiftUI

extension Color {
    static let bg = Color("ColorBg") // #EEE6DB 
    static let primaryText = Color("ColorPrimaryText") // #232220
}

Requesting a reload

You can request a new timeline using WidgetCenter from any place in your app

WidgetCenter.shared.reloadTimelines(ofKind: "WordWidgetExtension")

Sharing data with a Widget

Let's use CoreData from main app as source for our widget.

First of all, we should add PersistenceController to WordWidgetExtension (check WordWidgetExtension at File inspector in Target Membership)

Second, update PersistenceController and set up app group. App group containers allow apps and targets to share resources.

import Foundation
import CoreData

class SharedPersistentContainer: NSPersistentContainer {
    override open class func defaultDirectoryURL() -> URL {
        var storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.me.proft.TestWidget2")
        storeURL = storeURL?.appendingPathComponent("Words.sqlite")
        return storeURL!
    }
}

struct PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = SharedPersistentContainer(name: "Words")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores(completionHandler: {(storeDescription, error)in
            if let error = error as NSError?{
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }

    func save () {
        guard container.viewContext.hasChanges else { return }
        do {
            try container.viewContext.save()
        } catch {
            print(error)
        }
    }
}

Third, add new Add Groups capability for main app and WordWidgetExtension targets.

Last, update source of items in getTimeline function in Provider class.

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), word: WordModel.getPlaceholder())
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), word: WordModel.getPlaceholder())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        let managedObjectContext = PersistenceController.shared.container.viewContext
        let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Word")

        var results = [Word]()

        do { results = try managedObjectContext.fetch(request) as! [Word] }
        catch let error as NSError { print("Could not fetch \(error), \(error.userInfo)") }

        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: 5+hourOffset, to: currentDate)!

            let randomIndex = Int.random(in: 0..<results.count)
            let item = results[randomIndex]

            let word = WordModel(word: item.word ?? "", translation: item.translation ?? "", lang: item.lang ?? "", example: item.example ?? "")

            let entry = SimpleEntry(date: entryDate, word: word)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}