Creating a configurable widget using WidgetKit

This is a two-part article in which we will learn about WidgetKit for creating Widgets and the newly introduced ActivityFramework for creating Live Activities and Dynamic Island interaction in our iOS Apps.

In this part of the article, we will be learning about the WidgetKit framework, using which we will create widgets for our App named SA Blogs.

Before starting with the tutorial please download the starter project from here download.

This starter project contains a very basic static SwiftUI app that shows featured and latest posts for SwiftAnytime, you can look around the codebase, but the implementation has no impact on this tutorial.

We will be implementing a widget where users can see the latest, and featured articles from SwiftAnytime updated each hour directly to their Home Screen, the completed widget would look like this.

To implement Widgets for your app you will need to add App Extension called Widget Extension

Adding Widget Extension to your App

Creating a widget extension is the first step to adding support for widgets in your app you can use a single widget extension to add multiple widgets.
To add a widget extension to the starter app follow the below steps:

  1. Open SABlogs in Xcode and choose File > New > Target.
  2. Find the Application Extension section, select Widget Extension, and then click Next.
  3. Enter the name of your extension as SABlogWidgets.
  4. Check the Include Configuration Intent checkbox.
  5. Click Finish.

Creating a configurable widget

We can create a configurable widget using Siri intent with the widget kit, this gives users the flexibility to change provided parameters to customize the widgets

Creating the Widget

Inside the folder SABlogWidget create a new file named SADynamicConfigurableWidget.swift make sure that the target membership of this file is only set for the SABlogWidget target.

Paste in below code in the file...

import WidgetKit
import SwiftUI
import Intents

struct SADynamicConfigurableWidget: Widget { // 1
    
    let kind: String = "SADynamicConfigurableWidget" // 2

    var body: some WidgetConfiguration { // 3
        IntentConfiguration( // 4
            kind: kind, 
            intent: ConfigurationIntent.self, 
            provider: <#IntentTimelineProvider#> 
        ) { entry in 
            
        }
        .configurationDisplayName("SA Blogs") // 5
        .description("This is a configurable widget that shows featured and latest blogs from SwiftAnytime") // 6
    }
}

Code Explaination:

  1. We created a struct conforming to the Widget protocol from the widget kit, this protocol is pretty similar to the View property from SwiftUI
  2. Here we declare a string id for our widget to get identified by the system
  3. Widget have one requirement which is a variable named body of type some WidgetConfiguration
  4. In the body property we are expected to pass a widget configuration, for making this widget configurable we return an IntentConfiguration this accepts the below values,

1.  A string in kind to recognise our widget,

2.  An Siri Intent type, to populate the configuration view from when the user edits the widget, this ConfigurationIntent get auto-generated by Xcode if you have checked the intent checkmark while creating the widget extension, we will be configuring this intent to accept our custom input later in the article

3.  A object that conforms to IntentTimelineProvider this object is responsible to provide the updated data TimelineEntry when the widget updates, as well as handling the updation timeline,

4.  It also accepts a closure this closure is called every time the widget updates with an entry that comes from the provider.

5.   We set a name to this widget configuration, that appears in the widget picker gallery

Creating timeline entry

TimelineProvider creates multiple timeline entries with dates, widgetkit uses this entry to populate the data for your widgets, when a widget kit needs to render a widget it calls the content block of IntentConfiguration we created earlier and passes it to the TimelineEntry object, this object should contain all the info required to update/render the widget.

You can add any properties that you want to use in the widgets view here, in our case we will be adding properties of posts to render the post details in widgets.

Create a new file named PostEntry make sure to check the target membership only to the WidgetExtension target

Paste in the below code:

import Foundation
import WidgetKit

struct PostEntry: TimelineEntry { // 1
    let date: Date // 2
    let post: Post // 3
}

Code explanation:

  1. We are creating a TimeLine entry which requires as which requires to contain a date to identify when this entry was generated
  2. We add a date property as enforced by the TimelineEntry protocol from WidgetKit
  3. We add a post property to populate the widget data from

Fixing target membership issue for Post Model

You will see an error in this line as we declared the Post model in our app target but trying to access it from the WidgetExtension target.

To resolve this issue you will have to add the Post.swift file to the Widget Extension target as well

Configuring the intent

We will use Siri intent to provide the edit functionality inside our widget, for that we will be accepting post categories from the intent, which will be defined as an enum in the intent definition.

Go Over to the intent SABlogsWidget.intentdefinition generated by Xcode when you create the widget extension target

Intent

Here in the Customer Intents sidebar select the Configuration intent, it would be preselected as this is the only intent we have, inside the main view, go to the parameter section, and tap on the plus icon in the bottom right.

Intent property

Name the parameter as a category and keep the type String , for now, it should look like this.

WidgetImage8.png

We will not rely on a string for our input rather we will create an enum for taking the user input for this we will be creating a custom type definition in intent.

In the left second sidebar, tap on the bottom left plus icon and select the enum

widgetimage3.png

Name this enum as Category

widgetimage4.png

Go to the main view and add two enum cases:

Once you are done adding the cases it is time for setting this custom enum type as our config type in intent, go to the Configuration intent select the response and set the data type for our parameter named category to Category enum.

Creating widget's timeline provider

The timeline suggests the WidgetKit when to update the widget, we will be using IntentTimelineProvider which is similar to TimelineProvider with the addition of having user-configured details in timeline entries which we will see later in this article.

Create a new file named SADynamicConfigurableTimelineProvider.swift inside the SABlogWidget folder, make sure to change the target membership to the widget extension

Now paste in the following code and build using CMD + B

struct SADynamicConfigurableTimelineProvider: IntentTimelineProvider {
    
}

Here we have created a struct conforming to IntentTimelineProvider, which have two associated type requirement one for the Entry and one for Intent which we will need to set the types for

WidgetImage6.png

Click on the fix button  and it will populate the requirements, below

WidgetImage5.png

Set the type for Entry as our custom Entry Type PostEntry and for Intent set it as ConfigurationIntent
This ConfigurationIntent type is generated by Xcode from our intent definition file, the file should contain the below code.

struct SADynamicConfigurableTimelineProvider: IntentTimelineProvider {
    typealias Entry = PostEntry
    typealias Intent = ConfigurationIntent
}

Try to build the project again, and click on the fix:

WidgetImage7.png

Once you do Xcode will prepopulate the required functions stubs by this time you can optionally remove the type aliases:
The struct will look like this:

struct SADynamicConfigurableTimelineProvider: IntentTimelineProvider {
    
    func placeholder(in context: Context) -> PostEntry { 
        
    }
    
    func getSnapshot(
        for configuration: ConfigurationIntent,
        in context: Context,
        completion: @escaping (PostEntry) -> Void
    ) { 
        
    }
    
    func getTimeline(
        for configuration: ConfigurationIntent,
        in context: Context,
        completion: @escaping (Timeline<PostEntry>
        ) -> Void) { 
        
    }
    
}
These respective functions are called by the WidgetKit framework at a different point in time as required to populate the widget.

Setting the placeholder

placeholder(in:) is called when our widget is either shown for the first time when the Time Line is not yet populated or the widget is shown in the widget library for the first time, here widget kit passes us a context object by using which you can check where the widget is being shown, as well as the family type of widget being show e.g system small, system medium etc.

In this function you need to pass an Entry that will be used to render the placeholder widget, we will pass an empty entry for this:

func placeholder(in context: Context) -> PostEntry {
    PostEntry( // 1
        date: Date(), // 2
        post: Post( // 3
            image: FileManager.default.temporaryDirectory,
            title: "Title..",
            description: "Description.."
        )
    )
}
Code Explanation:
  1. We initialised an Entry using PostEntry's initialiser,
  2. Pass the current date for this entry,
  3. Pass dummy/placeholder data in the initialiser.

Implementing the snapshot

getSnapshot(for:in:completion:) this function is responsible to provide a timeline entry representing the current time and state of a widget, this gets called from WidgetKit during transient situations (Intermediate situation which lies where there is no timeline entry exists, e.g in WidgetGallery), this passes the context as well, one key point to take into consideration here is if the context.isPreview is true you should bypass the actual data loading if it takes more than few seconds and calls the completion with dummy data otherwise the widget in widget gallery can glitch.

We will be using this method to return a Post Entry according to the user's intent configuration Using the PostRepository class, add the following static property in SADynamicConfigurableTimelineProvider

static var repository = PostRepository()

If you try to build your widget extension target right now Xcode will show an error saying Cannot find 'PostRepository' in scope
this is because the file PostRepository.swift have target membership to the host app only and not to the WidgetExtension target, to fix this Go to the file PostRepository.swift and in the target membership section adds the checkmark for widget extension as well.

You will require the add PostCategory.swift to the widget extension target as well.

Once this is done come back to SADynamicConfigurableTimelineProvider.swift and add the following implementation in getSnapshot(for:in:completion:) function..

func getSnapshot(
    for configuration: ConfigurationIntent,
    in context: Context,
    completion: @escaping (PostEntry) -> Void
) {
    let posts: Posts
    if configuration.category == .featured { // 1
        posts = Self.repository.featuredPosts
    } else {
        posts = Self.repository.latestPosts
    }
    let date = Date() 
    let postIndex = Int(date.timeIntervalSince1970) % (posts.count-1) // 2
    let postEntry = PostEntry(date: date, post: posts[postIndex]) // 3
    completion(postEntry) // 4
}
Code Explaination:
  1. Here we use the passed intent configuration to check the post category selected by the user, and set the value of the posts according to the user config
  2. We calculate the post index using an arbitrary formula on the date
  3. We use the index calculated to get the post object from the array and create a PostEntry using it
  4. After this we call the completion passing the post entry

Providing widget update timeline

getTimeline(for:in:completion:) Is responsible for providing an array of timeline entries, and the next update time, In this function you are expected to pass multiple entries containing the data for rendering the widget in a particular time,

We will be configuring our widget to have PostEntry every other hour for five hours and once that time frame is completed we will remake the PostEntry sequence for the next five hours.

func getTimeline(
    for configuration: ConfigurationIntent,
    in context: Context,
    completion: @escaping (Timeline<PostEntry>
    ) -> Void) {
    
    let posts: Posts
    if configuration.category == .featured { // 1
        posts = Self.repository.featuredPosts
    } else {
        posts = Self.repository.latestPosts
    }
    
    let currentDate = Date()
    var entries: [PostEntry] = []
    for hourOffset in 0 ..< 5 { // 2
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        let postIndex = Int(entryDate.timeIntervalSince1970) % (posts.count-1)
        let entry = PostEntry(date: entryDate, post: posts[postIndex])
        entries.append(entry)
    }
    
    let timeline = Timeline(entries: entries, policy: .atEnd) // 3
    completion(timeline) // 4
    
}
Code Explanation:
  1. We switch the post array according to the widget configuration passed
  2. We create an array for PostEntry each having date property one at the one-hour difference to consecutive PostEntry
  3. We create a timeline with the array of entries and pass TimelineReloadPolicy.atEnd for TimelineReloadPolicy this policy tells the widget kit what to do after our current return time get finished, you can either pass .atEnd to let the widget kit know you want to get this function getTimeline(for:in:completion:) after the timeline ended, you can pass a particular date as well after which this function should be called regardless the current timeline is finished or not by sending, TimelineReloadPolicy.after(_ date: Date) with the date, you also pass TimelineReloadPolicy.never to let widget kit know that you don't want to create any more timelines.
  4. Then we call the completion with the timeline object

At this step, your SADynamicConfigurableTimelineProvider.swift file should look like this.

import Foundation
import WidgetKit

struct SADynamicConfigurableTimelineProvider: IntentTimelineProvider {
    
    static var repository = PostRepository()
    
    func placeholder(in context: Context) -> PostEntry {
        PostEntry(
            date: Date(),
            post: Post(
                image: FileManager.default.temporaryDirectory,
                title: "Title..",
                description: "Description.."
            )
        )
    }
    
    func getSnapshot(
        for configuration: ConfigurationIntent,
        in context: Context,
        completion: @escaping (PostEntry) -> Void
    ) {
        let posts: Posts
        if configuration.category == .featured {
            posts = Self.repository.featuredPosts
        } else {
            posts = Self.repository.latestPosts
        }
        let date = Date()
        let postIndex = Int(date.timeIntervalSince1970) % (posts.count-1)
        let postEntry = PostEntry(date: date, post: posts[postIndex])
        completion(postEntry)
    }
    
    func getTimeline(
        for configuration: ConfigurationIntent,
        in context: Context,
        completion: @escaping (Timeline<PostEntry>
        ) -> Void) {
        
        let posts: Posts
        if configuration.category == .featured {
            posts = Self.repository.featuredPosts
        } else {
            posts = Self.repository.latestPosts
        }
        
        let currentDate = Date()
        var entries: [PostEntry] = []
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let postIndex = Int(entryDate.timeIntervalSince1970) % (posts.count-1)
            let entry = PostEntry(date: entryDate, post: posts[postIndex])
            entries.append(entry)
        }
        
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
        
    }
    
}

Creating the widget entry view

We will be creating a really simple swiftui view for our widget, it would contain a post image and the title and subtitle.

Create a file name SADynamicConfigurableWidgetEntryView.swift with target membership to the widget extension.

And paste in the below code

import SwiftUI
import WidgetKit

struct SADynamicConfigurableWidgetEntryView : View {
    
    var entry: SADynamicConfigurableTimelineProvider.Entry
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(entry.post.title).font(.headline)
            Text(entry.date, style: .time).font(.subheadline)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(8)
        .background(.thinMaterial)
        .clipShape(ContainerRelativeShape())
        .padding(8)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
        .background(
            Image("background")
                .resizable()
                .scaledToFill()
        )
    }
}

struct SADynamicConfigurableWidgetEntryView_Previews: PreviewProvider {
    static var previews: some View {
        ForEach(
            [
                WidgetFamily.systemSmall,
                WidgetFamily.systemMedium,
                WidgetFamily.systemLarge
            ], id: \.self) { family in
                SADynamicConfigurableWidgetEntryView(
                entry: PostEntry(
                    date: Date(),
                    post: Post(
                        image: URL(string: "https://www.swiftanytime.com/content/images/size/w1200/2022/07/asyncimage-swiftui.jpg")!,
                        title: "AsyncImage in SwiftUI", description: "Almost all modern apps and websites are driven by images and videos. The images you see on shopping apps"
                    )
                )
            )
            .previewContext(
                WidgetPreviewContext(family: family)
            )
        }
        
    }
}
Preview:
WidgetImage9

Integrating the TimelineProvide and EntryView in the Widget

We have prepared all the necessary ingredients for your widget recipe now it's  time to cook them, but first we need to do a bit of cleaning up and delete the file generated by Xcode while creating the Widget Extension target, named SABlogsWidget.swift, once this is deleted go to -> SADynamicConfigurableWidget.swift file and replace the code with below

import WidgetKit
import SwiftUI
import Intents

@main // 1
struct SADynamicConfigurableWidget: Widget {
    
    let kind: String = "SADynamicConfigurableWidget"

    var body: some WidgetConfiguration {
        IntentConfiguration(
            kind: kind,
            intent: ConfigurationIntent.self,
            provider: SADynamicConfigurableTimelineProvider() // 2
        ) { entry in
            SADynamicConfigurableWidgetEntryView(entry: entry) // 3
        }
        .supportedFamilies([.systemSmall,.systemMedium,.systemLarge]) // 4
        .configurationDisplayName("SA Blogs")
        .description("This is a configurable widget that shows featured and latest blogs from SwiftAnytime")
    }
}
Code Change Explanation:
  1. We added the @main attribute to mark this file as the start of the execution of this WidgetExtension
  2. We created an instance of SADynamicConfigurableTimelineProvider and passed it to the configuration initialiser
  3. Then we returned the view we just created
  4. And added the supported widget families

Result

You can download the completed project here download.

You've successfully subscribed to Swift Anytime
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.