Creating your first Widget via WidgetKit iOS 11.07.2021

Introduction

Introduced in iOS 14, widgets allow apps to present important information to the user directly on the device home screen without the need to launch the app. Widgets are implemented using the WidgetKit Framework and take the form of extensions added to the main app.

When a widget is tapped by the user, the corresponding app is launched, taking the user to a specific screen where more detailed information may be presented.

Some examples are checking weather, the next meeting on your calendar, and so on with just a glance at the home screen.

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

Widgets come in three sizes: small, medium, and large.

In order to develop a widget, developers need to create a new extension for their app: a widget extension. They can configure the widget with a timeline provider. A timeline provider updates the widget information when needed.

The following are descriptions of the building blocks of a widget extension:

  • If the widget is configurable by the user, it will need a custom Siri intent configuration definition. For example, a widget that displays stocks can ask the user for a configuration to choose what stocks to display.
  • A provider is needed that will provide the data to display on the widget. The provider can generate placeholder data (that is, show when the user is browsing the widget gallery or loading), a timeline (to represent data over time), and a snapshot (the units that compose a timeline).
  • A SwiftUI view to display the data is needed.

Here’s a typical workflow for creating a widget:

  1. Add a widget extension to your app. Configure the widget’s display name and description.
  2. Select or adapt a data model type from your app to display in the widget. Create a timeline entry structure: a Date plus your data model type. Create sample data for snapshot and placeholder entries.
  3. Decide whether to support all three widget sizes. Create small, medium and/or large views to display one or more data model values.
  4. Create a timeline to deliver timeline entries. Decide on the refresh policy.

When creating a widget target, Xcode will autogenerate placeholders for all these classes. 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.

Inside struct WordWidgetExtension: Widget we have the basic building pieces of the widget:

  • An intent configuration (optional), to allow the user to configure the widget.
  • A provider to provide data to the widget: Provider(). The provider can generate placeholder data (that is, show when the user is browsing the widget gallery or loading), a timeline (to represent data over time), and a snapshot (the units that compose a timeline).
  • A view to display the data.

You define the configuration and content of a widget by creating a struct that conforms to WidgetKit. Mark the struct with the @main attribute to make it the widget entry point:

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])
    }
}

A static configuration has no user configurable options so takes three arguments:

  • 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. Together with any data you need to display the widget you must set a date and time for when the entry is valid. You can optionally include a relevance property:

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 has three methods WidgetKit can call to ask your widget’s timeline provider for timeline entries:

  • placeholder() method will provide the widget with the initial view the first time the widget is rendered. The placeholder will give the user a general idea of what the widget will look like.
  • getSnapshot() method will provide the widget with a value (entry) to display when the widget needs to be shown in transient situations. The isPreview property from context indicates that the widget is being shown in the widget gallery. In those cases, the snapshot has to be fast: those scenarios may require the developer to use dummy data and avoid network calls to return the snapshot as fast as possible.
  • The getTimeline() method will provide the widget with an array of values to display over the current time (and optionally in the future).

WidgetKit calls getTimeline(in:completion:) to get timeline entries for the current and, optionally, future times to update the widget. You call the completion handler with the array of timeline entries and the required refresh policy.

The refresh policy tells WidgetKit when it should request a new timeline from your provider:

  • atEnd - after the date of the entry in the timeline.
  • after(Date) - after a future date
  • never - WidgetKit will never request a new timeline from the widget. The app must inform WidgetKit when to request a new timeline.
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
}

User Interaction

When a user taps your widget, the system launches your app. You can pass a URL to the app by adding the widgetURL view modifier to a view:

WordEntryView(entry: entry)
.widgetURL(entry.url)

In your app you handle the URL using either onOpenURL(perform:) for SwiftUI or application(_:open:options:) if you have a UIKit app delegate.

Requesting a reload

You can have your app tell WidgetKit to request a new timeline using WidgetCenter:

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)
    }
}