🧱Building Widgets

Original Author: Reade Plunkett

WidgetKit

WidgetKit is the framework developed by Apple that allows us to build widgets, watch complications, and Live Activities. It allows contents of our app to be available in contexts outside the app and extend its reach by building an ecosystem of glanceable, up-to-date experiences.

Widget Timeline

While Widgets use SwiftUI to display their content, WidgetKit is used to render their view. Even if your widget is on screen, it is not necessarily active at all times. Since reloading a widget consumes system resources and can lead to battery drain, WidgetKit limits the frequency and number of updates you request to what’s necessary.

Each widget is assigned a budget which is dynamically allocated and considers the following factors:

  • The frequency and times the widget is visible to the user

  • The widget’s last reload time

  • Whether the widget’s containing app is active

Typically a widget can be allocated 40 - 70 refreshes as a daily budget, corresponding to the widget reloading about every 15 - 60 minutes.

We can define a timeline for when our widget should update if the widget has predictable points in time where it makes sense to update its content. For example, a widget that displays weather information might update the temperature hourly throughout the day. A stock market widget could update its content frequently during open market hours, but not at all over the weekend. By planning these times in advance, WidgetKit automatically refreshes your widget when the appropriate time arrives.

When you define your widget, you implement a custom [TimelineProvider]. WidgetKit gets a timeline from your provider, and uses it to track when to update your widget. A timeline is an array of [TimelineEntry] objects. Each entry in the timeline has a date and time, and additional information the widget needs to display its view. In addition to the timeline entries, the timeline specifies a refresh policy that tells WidgetKit when to request a new timeline.

Model

Before we start creating our widget, we will create a Weather Model that will contain the data for our weather. The model contains the following information:

  • conditions: A string describing the weather conditions.

  • symbol: A string referencing an SF Symbol that depicts the conditions.

  • color: The background color name for these weather conditions.

  • temp: The current temperature

Weather.swift
import Foundation

struct Weather {
    let conditions: String
    let symbol: String
    let color: String
    let temp: Int
    
    static let sunny = Weather(conditions: "Sunny", symbol: "sun.max.fill", color: "sunny-color", temp: 78)
    static let cloudy = Weather(conditions: "Cloudy", symbol: "cloud.sun.fill", color: "cloudy-color", temp: 64)
    static let overcast = Weather(conditions: "Overcast", symbol: "cloud.fill", color: "overcast-color", temp: 45)
    static let rainy = Weather(conditions: "Rainy", symbol: "cloud.rain.fill", color: "rainy-color", temp: 62)
    static let lightning = Weather(conditions: "Lightning", symbol: "cloud.bolt.fill", color: "lightning-color", temp: 57)
    static let snowy = Weather(conditions: "Snowy", symbol: "snowflake", color: "snowy-color", temp: 18)
}

We also define six constant weather conditions within our model that we will reference through this project corresponding to sunny, cloudy, overcast, rainy, lightning, and snowy.

TimelineEntry

Next, we will define TimelineEntry for our weather widget. This type specifies the date to display a widget, and, optionally, indicates the current relevance of the widget’s content.

WeatherWidgetEntry.swift
import WidgetKit

struct WeatherEntry: TimelineEntry {
    let date: Date
    let weather: Weather
}

Here, we define an entry that contains the date as well as a weather model that corresponds to this entry. We will use the data in the weather model to populate our Widget’s content view.

TimelineProvider

Periodically, our widgets will need to update their display. For example, a sports widget could update its display every time a team scores, a weather widget could update when the conditions change, or a music widget could update when the song playing finishes. The TimelineProvider is a type that advises WidgetKit when to update a widget’s display.

WidgetKit requests a timeline from the provider. This timeline is an array of objects that conform to TimelineEntry. Each entry contains a date, as well as additional properties we can define for displaying the widget.

WeatherWidgetProvider.swift
import WidgetKit

struct Provider: TimelineProvider {

}

Within the Provider, we have three functions to handle each of the three ways WidgetKit can request timeline entries: placeholder, getSnapshot , andgetTimeline

  • placeholder: returns an entry representing a placeholder version of the widget.

  • getSnapshot: returns single immediate snapshot entry that represents the widget’s current state.

  • getTimeline: returns a timeline of entries, including the current moment and any future dates when the widget’s state will change.

Fetch Placeholder Entry

When WidgetKit displays our widget for the first time, it renders the widget’s view as a placeholder. This placeholder provides a generic representation of your widget, giving the user a general idea of what the widget shows.

WeatherWidgetProvider.swift
func placeholder(in context: Context) -> WeatherEntry {
    return WeatherEntry(date: Date(), weather: .sunny)
}

Fetch Snapshot Entry

WidgetKit calls getSnapshot when the widget appears in transient situations, such as when the user is adding a widget. The context parameter provides additional details how the entry should be use, for example, whether it is a preview inside the Widget Gallery, the family, or the size of the widget to display.

WeatherWidgetProvider.swift
func getSnapshot(in context: Context, completion: @escaping (WeatherEntry) -> Void) {
    let entry = WeatherEntry(date: Date(), weather: .sunny)
    completion(entry)
}

In this snapshot, we create and return an entry that contains the current date and time, as well as a random Weather object. It is important to note, if the data for the snapshot requires a significant amount of time to load (for example, making a network call), it is best practice to use sample data.

Fetch Timeline

After a user adds our widget from the widget gallery, WidgetKit makes the timeline request. Our widget extension will not always be running, so WidgetKit needs to know when to activate to update the widget. The timeline tells WidgetKit when you would like to update the widget.

WeatherWidgetProvider.swift
func getTimeline(in context: Context, completion: @escaping (Timeline<WeatherEntry>) -> Void) {
    var entries: [WeatherEntry] = []
    
    let possibleWeathers: [Weather] = [.sunny, .cloudy, .overcast, .rainy, .lightning, .snowy]
    
    // Generate a timeline consisting of five entries an hour apart, starting from the current date.
    let currentDate = Date()
    for hourOffset in 0 ..< 5 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        let entry = WeatherEntry(date: entryDate, weather: possibleWeathers.randomElement()!) // select a random weather
        entries.append(entry)
    }
    
    let timeline = Timeline(entries: entries, policy: .atEnd)
    
    completion(timeline)
}

Here, we create and return a timeline of five entries each an hour apart. Each entry contains a date as well as a random weather. We use a refresh policy .atEnd to tell WidgetKit to request a new timeline after the last date specified by the entries in the timeline.

Widget

Let’s first start by building the Widget itself by conforming to the Widget protocol. This protocol contains two components:

  • kind: This is a string that identifies the widget.

  • body: This is body of the widget that defines its contents and configuration.

A Widget can have two configurations: StaticConfiguration or AppIntentConfiguration.

  • StaticConfiguration: Describes the content of a widget that has no user-configurable options.

  • AppIntentConfiguration: Describes the content of a widget that uses custom intent to provide user-configurable options.

WeatherWidget.swift
import WidgetKit
import SwiftUI

struct WeatherWidget: Widget {
    let kind: String = "WeatherWidget"

    var body: some WidgetConfiguration {
	    StaticConfiguration(kind: kind, provider: Provider()) { entry in
	        WeatherWidgetView(entry: entry)
	    }
	    .configurationDisplayName("Forecast")
	    .description("View the weather forecast for your location.")
	    .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
	}
}

We pass into the configuration the widget’s kind as well as its TimelineProvider. WidgetKit invokes the content closure, passing a timeline entry created by the widget’s provider. This entry can either be a snapshot entry or one from the timeline. Once we have this entry, we can pass it into the Widget’s view to display its contents.

For a widget, we have a view notable modifiers:

  • configurationDisplayName: Sets the name shown for a widget when a user adds or edits it.

  • description: Sets the description shown for a widget when a user adds or edits it.

  • supportedFamilies: Sets the sizes that a widget supports. There are two families our widget can belong in: system or accessory.

    • System Family

      • systemSmall: A small system widget that can appear on the Home Screen, Today View, or in StandBy, or the Mac Desktop.

      • systemMedium: A medium system widget that can appear on the Home Screen, Today View, or in StandBy, or the Mac Desktop.

      • systemLarge: A large system widget that can appear on the Home Screen, Today View, or in StandBy, or the Mac Desktop.

      • systemExtraLarge: An extra large system widget that can appear on the Home Screen, Today View, StandBy, or the Mac Desktop.

    • Accessory Family

      • accessoryCircular: A circular widget that can appear as a complication in watchOS, or on the Lock Screen.

      • accessoryCorner: A widget-based complication in the corner of a watch face in watchOS.

      • accessoryRectangular: A rectangular widget that can appear as a complication in watchOS, or on the Lock Screen.

      • accessoryInline: An inline widget that can appear as a complication in watchOS, or on the Lock Screen.

View

Next, we create the View for our widget using SwiftUI. The Widget’s entry gets fetched from the TimelineProvider and passed into the View by the Widget’s StaticConfiguration.

We can access the Weather object within our Entry to display the temperature, conditions, symbol, and background color.

WeatherWidgetView.swift
import SwiftUI
import WidgetKit

struct WeatherWidgetView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            HStack {
                Text("\\(entry.weather.temp)°")
                    .font(.system(size: 32))
                    .minimumScaleFactor(0.5)
                
                Spacer()
                
                Image(systemName: entry.weather.symbol)
                    .resizable()
                    .symbolRenderingMode(.multicolor)
                    .aspectRatio(contentMode: .fit)
                    .frame(minWidth: 20, maxWidth: 40, minHeight: 20, maxHeight: 40)
            }
            
            Spacer()
            
            HStack {
                Text(entry.weather.conditions)
                    .font(.system(size: 16, weight: .bold))
                    .minimumScaleFactor(0.5)
                Spacer()
            }
        }
        .foregroundStyle(.white)
				.containerBackground(Color(entry.weather.color).gradient, for: .widget)
    }
}

Additionally, we can make use of the canvas and live view to easily and quickly view our changes. We can even specify which size Widget (or WidgetFamily) we want to see displayed in the preview:

WeatherWidgetView.swift
#Preview(as: .systemSmall) {
    WeatherWidget()
} timeline: {
    WeatherEntry(date: .now, weather: .sunny)
    WeatherEntry(date: .now, weather: .cloudy)
    WeatherEntry(date: .now, weather: .overcast)
    WeatherEntry(date: .now, weather: .rainy)
    WeatherEntry(date: .now, weather: .lightning)
    WeatherEntry(date: .now, weather: .snowy)
}

Here, we create an entry in the timeline for each one of our weather conditions. We can view and cycle through this timeline in the preview.

WidgetBundle

In the previous section, we discussed creating a new widget extension. We can define multiple widgets within a single widget extension. A widget bundle allows us to expose these multiple widgets. In this case however, we only expose a single widget.

WeatherWidgetBundle.swift
import WidgetKit
import SwiftUI

@main
struct WeatherWidgetBundle: WidgetBundle {
    var body: some Widget {
        WeatherWidget()
				// A second widget...
				// A third widget...
    }
}

Last updated