Building Widgets with SwiftUI and WidgetKit

Introduced in iOS 14, widgets allow small amounts of app content to be displayed alongside the app icons that appear on the device home screen pages, the Today view and the macOS Notification Center. Widgets are built using SwiftUI in conjunction with the WidgetKit Framework.

The focus of this chapter is to provide a high level outline of the various components that make up a widget before exploring widget creation in practical terms in the chapters that follow.

An Overview of Widgets

Widgets are intended to provide users with “at a glance” views of important, time sensitive information relating to your 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. Widgets are intended to display information which updates based on a timeline, ensuring that only the latest information is displayed to the user. A single app can have multiple widgets displaying different information.

Widgets are available in three size families (small, medium and large), of which at least one size must be supported by the widget, and can be implemented such that the information displayed is customizable by the user.

Widgets are selected from the widget gallery and positioned by the user on the device home screens. To conserve screen space, iOS allows widgets to be stacked, providing the user the ability to flip through each widget in the stack with a swiping gesture. A widget can increase the probability of moving automatically to the top of the stack by assigning a relevancy score to specific timeline entries. The widget for a weather app might, for example, assign a high relevancy to a severe weather warning in the hope that WidgetKit will move it to the top of the stack, thereby increasing the likelihood that the user will see the information.

The Widget Extension

A widget is created by adding a widget extension to an existing app. A widget extension consists of a Swift file, an optional intent definition file (required if the widget is to be user configurable), an asset catalog and an Info. plist file.

The widget itself is declared as a structure conforming to the Widget protocol, and it is within this declaration that the basic configuration of the widget is declared. The body of a typical widget declaration will include the following items:

  • Widget kind – Identifies the widget within the project. This can be any String value that uniquely identifies the widget within the project.
  • Widget Configuration – A declaration which conforms to the WidgetConfiguration protocol. This includes a reference to the provider of the timeline containing the information to be displayed, the widget display name and description, and the size families supported by the widget. WidgetKit supports two types of widget configuration referred to as static configuration and intent configuration.
  • Entry View – A reference to the SwiftUI View containing the layout that is to be presented to the user when the widget is displayed. This layout is populated with content from individual timeline entries at specific points in the widget timeline.

In addition to the widget declaration, the extension must also include a placeholder View defining the layout to be displayed to the user while the widget is loading and gathering data. This may either be declared manually, or configured to be generated automatically by WidgetKit based on the entry view included in the Widget view declaration outlined above.

Widget Configuration Types

When creating a widget, the choice needs to be made as to whether it should be created using the static or intent configuration model. These two options can be summarized as follows:

  • Intent Configuration – Used when it makes sense for the user to be able to configure aspects of the widget. For example, allowing the user to select the news publications from which headlines are to be displayed within the widget.
  • Static Configuration – Used when the widget does not have any user configurable properties.

When the Intent Configuration option is used, the configuration options to be presented to the user are declared within a SiriKit intent definition file.

The following is an example widget entry containing a static configuration designed to support both small and medium size families:

@main
struct SimpleWidget: Widget {
    private let kind: String = "SimpleWidget"
 
    public var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider(), 
                 placeholder: PlaceholderView()) { entry in
            SimpleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("A Simple Weather Widget")
        .description("This is an example widget.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

The following listing, on the other hand, declares a widget using the intent configuration:

@main
struct SimpleWidget: Widget {
    private let kind: String = "SimpleWidget"
 
    public var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, 
             intent: LocationSelectionIntent.self, provider: Provider(), 
               placeholder: PlaceholderView()) { entry in
            SimpleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Weather Fun")
        .description("Learning about weather in real-time.")
    }
}

The primary difference in the above declaration is that it uses IntentConfiguration instead of StaticConfiguration which, in turn, includes a reference to a SiriKit intent definition. The absence of the supported families modifier in the above example indicates to WidgetKit that the widget supports all three sizes. Both examples include a widget entry view.

Widget Entry View

The widget entry view is simply a SwiftUI View declaration containing the layout to be displayed by the widget. Conditional logic (for example if or switch statements based on the widgetFamily environment property) can be used to present different layouts subject to the prevailing size family.

With the exception of tapping to open the corresponding app, widgets are non-interactive. As such, the entry view will typically consist of display-only views (in other words no buttons, sliders or toggles).

When WidgetKit creates an instance of the entry view, it passes it a widget timeline entry containing the data to be displayed on the views that make up the layout. The following view declaration is designed to display city name and temperature values:

struct SimpleWidgetEntryView : View {
    var entry: Provider.Entry
 
    var body: some View {
        VStack {
            Text("City: \(entry.city)")
            Text("Tempurature: \(entry.temperature)")
        }
    }
}

The purpose of a widget is to display different information at specific points in time. The widget for a calendar app, for example, might change throughout the day to display the user’s next upcoming appointment. The content to be displayed at each point in the timeline is contained within widget entry objects conforming to the TimelineEntry protocol. Each entry must, at a minimum, include a Date object defining the point in the timeline at which the data in the entry is to be displayed, together with any data that is needed to fully populate the widget entry view at the specified time. The following is an example of a timeline entry declaration designed for use with the above entry view:

struct WeatherEntry: TimelineEntry {
    var date: Date
    let city: String
    let temperature: Int
}

If necessary, the Date object can simply be a placeholder to be updated with the actual time and date when the entry is added to a timeline.

Widget Timeline

The widget timeline is simply an array of widget entries that defines the points in time that the widget is to be updated, together with the content to be displayed at each time point. Timelines are constructed and returned to WidgetKit by a widget provider.

Widget Provider

The widget provider is responsible for providing the content that is to be displayed on the widget and must be implemented to conform to the TimelineProvider protocol. At a minimum, it must implement the following methods:

  • getSnapshot() – The getSnapshot() method of the provider will be called by WidgetKit when a single, populated widget timeline entry is required. This snapshot is used within the widget gallery to show an example of how the widget would appear if the user added it to the device. Since real data may not be available at the point that the user is browsing the widget gallery, the entry returned should typically be populated with sample data.
  • getTimeline() – This method is responsible for assembling and returning a Timeline instance containing the array of widget timeline entries that define how and when the widget content is to be updated together with an optional reload policy value.

The following code excerpt declares an example timeline provider:

struct Provider: TimelineProvider {
    public typealias Entry = SimpleEntry
 
    public func getSnapshot(with context: Context, 
              completion: @escaping (SimpleEntry) -> ()) {
 
        // Construct a single entry using sample content
        let entry = SimpleEntry(date: Date(), city: "London", 
                               temperature: 89)
        completion(entry)
    }
 
    public func getTimeline(with context: Context, 
                   completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
 
        // Construct timeline array here
 
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

Reload Policy

When a widget is displaying entries from a timeline, WidgetKit needs to know what action to take when it reaches the end of the timeline. The following predefined reload policy options are available for use when the provider returns a timeline:

  • atEnd – At the end of the current timeline, WidgetKit will request a new timeline from the provider. This is the default behavior if no reload policy is specified.
  • after(Date) – WidgetKit will request a new timeline after the specified date and time.
  • never – The timeline is not reloaded at the end of the timeline.

Relevance

As previously mentioned, iOS allows widgets to be placed in a stack in which only the uppermost widget is visible. While the user can scroll through the stacked widgets to decide which is to occupy the topmost position, this presents the risk that an important update may not be seen by the user in time to act on the information.

To address this issue, WidgetKit is allowed to move a widget to the top of the stack if the information it contains is considered to be of relevance to the user. This decision is based on a variety of factors such as previous behavior of the user (for example checking a bus schedule widget at the same time every day) together with a relevance score assigned by the widget to a particular timeline entry.

Relevance is declared using a TimelineEntryRelevance structure. This contains a relevancy score and a time duration for which the entry is relevant. The score can be any floating point value and is measured relative to all other timeline entries generated by the widget. For example, if most of the relevancy scores in the timeline entries range between 0.0 and 10.0, a relevancy score of 20.0 assigned to an entry may cause the widget to move to the top of the stack. The following code declares two relevancy entries:

let lowScore = TimelineEntryRelevance(score: 0.0, duration: 0)
let highScore = TimelineEntryRelevance(score: 10.0, duration: 0)

If a relevancy is to be included in an entry, it must appear after the date entry, for example:

struct WeatherEntry: TimelineEntry {
    var date: Date
    var relevance: TimelineEntryRelevance?
    let city: String
    let temperature: Int
}
.
.
let entry1 = WeatherEntry(date: Date(), relevance: lowScore, city: "London", temperature: 87)
 
let entry2 = WeatherEntry(date: Date(), relevance: highScore, city: "London", temperature: 87)

Forcing a Timeline Reload

When a widget is launched, WidgetKit requests a timeline containing the timepoints and content to display to the user. Under normal conditions, WidgetKit will not request another timeline update until the end of the timeline is reached, and then only if required by the reload policy.

Situations may commonly arise, however, where the information in a timeline needs to be updated. A user might, for example, add a new appointment to a calendar app which requires a timeline update. Fortunately, the widget can be forced to request an updated timeline by making a call to the reloadTimelines() method of the WidgetKit WidgetCenter instance, passing through the widget’s kind string value (defined in the widget configuration as outlined earlier in the chapter). For example:

WidgetCenter.shared.reloadTimelines(ofKind: "My Kind")

Alternatively, it is also possible to trigger a timeline reload for all the active widgets associated with an app as follows:

WidgetCenter.shared.reloadAllTimelines()

Widget Sizes

As previously discussed, widgets can be displayed in small, medium and large sizes. The widget declares which sizes it supports by applying the supportedFamilies() modifier to the widget configuration as follows:

@main
struct SimpleWidget: Widget {
    private let kind: String = "SimpleWidget"
 
    public var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, 
             intent: LocationSelectionIntent.self, provider: Provider(), 
               placeholder: PlaceholderView()) { entry in
            SimpleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Weather Fun")
        .description("Learning about weather in real-time.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

The following figure shows the built-in iOS Calendar widget in small, medium and large formats:

Figure 47-1

Widget Placeholder

As previously mentioned, the widget extension must provide a placeholder. This is the view which is displayed to the user while the widget is initializing and takes the form of the widget entry view without any data or information. Consider the following example widget:

Figure 47-2

The above example, of course, shows the widget running after it has received timeline data to be displayed. During initialization, however, the placeholder view resembling Figure 47-3 would be expected to be displayed:

Figure 47-3

Fortunately, SwiftUI includes the redacted(reason:) modifier which may be applied to an instance of the widget entry view to act as a placeholder. The following is an example of a placeholder view declaration for a widget extension using the redacted() modifier (note that the reason is set to placeholder):

struct PlaceholderView : View {
    var body: some View {
        SimpleWidgetEntryView()
            .redacted(reason: .placeholder)
    }
}

Summary

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. Widgets are driven by timelines which control the information to be displayed to the user and when it is to appear. Widgets can support small, medium and large formats and may be designed to be configurable by the user. When adding widgets to the home screen, the user has the option to place them into a stack. By adjusting the relevance of a timeline entry, a widget can increase the chances of being moved to the top of the stack.