Categories
Uncategorized

To-Do App Widget

Next, we will look at how to use CoreData in Widgets. For this, we will use our already completed To-Do app.

Our Widget should, depending on its size, end up looking like this:

Open the corresponding Xcode project and add a Widget Target by selecting File-New-Target in the Xcode toolbar and searching for “Widget Extension”. For example, name the widget “OpenTasksWidget”. Again, make sure you don’t check “Use Configuration Intent” as we will be using a StaticConfiguration for this Widget. When asked, activate the Widget Scheme. 

We will use both the PersistenceController and the CoreData model of our To-Do app for the Widget. Therefore, we add the corresponding files to the Widget Target:

Composing views depending on the Widget family 🎨

We start by designing the OpenTasksEntryView for our Widget. To work with some sample To-Do’s we temporarily add a “sampleTaskTitle” array to our SimpleEntry struct. We will replace this later with a set of respective CoreData instances.

struct SimpleEntry: TimelineEntry {
    let date: Date
    let sampleTaskTitles = ["Learning SwiftUI", "Hitting the gym"]
}

As discussed earlier, a Widget can have three different sizes: .systemSmall, .systemMedium, and .systemLarge. We can simulate these different sizes simultaneously by initializing the OpenTasksWidgetEntryView multiple times in our OpenTasksWidget_Previews using a Group. For each instance we use a different .family:

struct OpenTasksWidget_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date()))
                .previewContext(WidgetPreviewContext(family: .systemSmall))
            OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date()))
                .previewContext(WidgetPreviewContext(family: .systemMedium))
            OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date()))
                .previewContext(WidgetPreviewContext(family: .systemLarge))
        }
    }
}

The Preview simulator should now look like this:

We now have the option to make the OpenTasksWidgetEntryView dependent on the Widget family.

To know which size the user has chosen, we can use the .widgetFamily @Environment property wrapper:

struct OpenTasksWidgetEntryView : View {
     
     @Environment(\.widgetFamily) var family
     
     var entry: Provider.Entry
 
     var body: some View {
        Text(entry.date, style: .time)
    }
}

If we now use an appropriate switch statement, we can easily design the view for our widget depending on its size.

struct OpenTasksWidgetEntryView : View {
    
    @Environment(\.widgetFamily) var family
    
    var entry: Provider.Entry
    
    var body: some View {
        switch family {
        case .systemExtraLarge:
            Text("View for extra large widget")
        case .systemLarge:
            Text("View for large widget")
        case .systemMedium:
            Text("View for medium-sized widget")
        case .systemSmall:
            Text("View for small widget")
        }
    }
}

Let’s start with the .systemLarge family. In this case, we build a view that displays both the number of open tasks and the title of those tasks, where we want to display at most the seven most recent tasks. For example, we can achieve this like this:

case .systemLarge:
    VStack(alignment: .leading) {
        Text("\(entry.sampleTaskTitles.count) open tasks")
            .foregroundColor(.blue)
            .font(.title)
        Divider()
        ForEach(entry.sampleTaskTitles.prefix(7), id: \.self) { item in
            Text(item ?? "")
                .padding(.top, 3)
                .lineLimit(1)
        }
        if entry.sampleTaskTitles.count > 7 {
            Text("More ...")
                .padding(.top, 3)
                .foregroundColor(.blue)
        }
        Spacer()
    }
        .padding()

The corresponding preview should now be updated:

We copy the VStack and paste it to the .systemExtraLarge case to reuse it:

case .systemExtraLarge:
    VStack(alignment: .leading) {
        Text("\(entry.sampleTaskTitles.count) open tasks")
            .foregroundColor(.blue)
            .font(.title)
        Divider()
        ForEach(entry.sampleTaskTitles.prefix(7), id: \.self) { item in
            Text(item)
                .padding(.top, 3)
                .lineLimit(1)
        }
        if entry.sampleTaskTitles.count > 7 {
            Text("More ...")
                .padding(.top, 3)
                .foregroundColor(.blue)
        }
        Spacer()
    }
        .padding()

Since the Widget in the .systemMedium family already has a little less space available, we want to display at most the three most recent open tasks. Also, we change the arrangement a bit, since the widget will be aligned “horizontally” in this case:

case .systemMedium:
    HStack {
        VStack(alignment: .leading) {
            Text("\(entry.sampleTaskTitles.count)")
            Text("open")
            Text("tasks")
    }
       .font(.title)
       .foregroundColor(.blue)
    Divider()
    VStack(alignment: .leading) {
        ForEach(entry.sampleTaskTitles.prefix(3), id: \.self) { item in
            Text(item)
                .padding(.top, 3)
                .lineLimit(1)
        }
        if entry.sampleTaskTitles.count > 3 {
            Text("More ...")
                .padding(.top, 3)
                .foregroundColor(.blue)
        }
        Spacer()
    }
    Spacer()
}
    .padding()

This results in the following preview:

For the .systemSmall family, we use a much scaled-down version of the .systemLarge family and again limit the maximum tasks displayed to three. 

case .systemSmall:
    VStack(alignment: .leading) {
         Text("\(entry.sampleTaskTitles.count) open tasks")
             .foregroundColor(.blue)
             .font(.headline)
             .padding(.top, 2)
         Divider()
         ForEach(entry.sampleTaskTitles.prefix(3), id: \.self) { item in
             Text(item ?? "")
                 .font(.caption)
                 .padding(.bottom, 2)
                 .lineLimit(1)
         }
         if entry.sampleTaskTitles.count > 3 {
             Text("More ...")
                 .font(.caption)
                 .padding(.top, 2)
                 .foregroundColor(.blue)
         }
         Spacer()
     }
         .padding()

The corresponding Widget will look like this:

Great, we are now done designing our OpenTasksWidgetEntryView!

Accessing CoreData using an App Group 🤝

However, the Widget cannot yet use the CoreData database that is created and managed by the To-Do app. In order for extensions like our OpenTasksWidget to get access to it, we need to create a so-called App Group.

To do this, go to the Apple Developer Account (the one you also use for Xcode Project Signing) and click on “Certificates, Identifies & Profiles”.  

Then click on the plus button under “Identifiers”. Next, select “App Groups” and click “Continue”. 

Now you can pick any description you like. But as the identifier, you should use the following structure “group.*YOUR DEVELOPER NAME*.*YOUR PROJECT NAME“. For example:

After you have registered the identifier, quit Xcode by hitting CMD-Q, and reopen your project. To add the corresponding App Group to the project, you have to go to the Signing & Capabilities tab of your app. Drag and drop the “App Group” capability from the capability library below the “Signing” section. After some time, the identifier you just created should appear. Then set a checkmark to register the App Group. Otherwise, you can just click on the plus button to enter the identifier manually.

You should end up with an entitlements file added to your project. This is a plist file containing the app group identifier you just created:

To share this App Group with the Widget, we repeat the process again for the OpenTasksWidget target itself. After that, you should end up with two entitlement files in total.

Okay, we are almost finished. As a final preparation, we just need to update the Persistence.swift file so that the CoreData database is also captured by the App Group.

To do this, we need to modify the init method of the PersistenceController. So, except for setting the container, delete the current content of this method:

init(inMemory: Bool = false) {
     
}

Next, we define the path where the database should be stored. Make sure you use your App Group identifier for this!

init(inMemory: Bool = false) {
     container = NSPersistentContainer(name: "Shared")
     
     //USE YOUR APP GROUP IDENTIFIER FOR THE "forSecurityApplicationGroupIdentifier" parameter
     guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.BLCKBIRDS.To-Do") else {
         fatalError("Shared file container could not be created.")
     }
     
     let storageURL = fileContainer.appendingPathComponent("ToDoAppDatabase.sqlite")
     
     
 }

Next, we set the description for the database using our storageURL. After this, we load the persistent storage.

init(inMemory: Bool = false) {
     //...
     
     
     let description = NSPersistentStoreDescription(url: storageURL)
     container.persistentStoreDescriptions = [description]
     
     container.loadPersistentStores(completionHandler: { (storeDescription, error) in
         if let error = error as NSError? {
             fatalError("Unresolved error \(error), \(error.userInfo)")
         }
     })
 }

Finally, we are ready to access the CoreData database of our To-Do app through the Widget!

Updating our Provider 🔄

Next, we will update our OpenTasksWidget.swift file. The first thing we need to do is to import the CoreData framework.

import CoreData

We can now replace the sampleTaskTitles property of our SimpleEntry model with an array of ToDoItem instances.

struct SimpleEntry: TimelineEntry {
    let date: Date
    let toDoItems: [ToDoItem]
}

Accordingly, we now need to update our OpenTasksWidgetEntry view …

struct OpenTasksWidgetEntryView : View {
     
     @Environment(\.widgetFamily) var family
     
     var entry: Provider.Entry
 

     var body: some View {
         switch family {
         case .systemLarge:
             VStack(alignment: .leading) {
                 Text("\(entry.toDoItems.count) open tasks")
                     //...
                 Divider()
                 ForEach(entry.toDoItems.prefix(7), id: \.self) { item in
                     Text(item.taskTitle ?? "")
                         //...
                 }
                 if entry.toDoItems.count > 7 {
                     //...
                 }
                 Spacer()
             }
                 .padding()
         case .systemMedium:
             HStack {
                 VStack(alignment: .leading) {
                     Text("\(entry.toDoItems.count)")
                     //...
                 }
                     //...
                 Divider()
                 VStack(alignment: .leading) {
                     ForEach(entry.toDoItems.prefix(3), id: \.self) { item in
                         Text(item.taskTitle ?? "")
                             //...
                     }
                     if entry.toDoItems.count > 3 {
                         Text("More ...")
                             //...
                     }
                     Spacer()
                 }
                 Spacer()
             }
                 .padding()
         case .systemSmall:
             VStack(alignment: .leading) {
                 Text("\(entry.toDoItems.count) open tasks")
                     //...
                 Divider()
                 ForEach(entry.toDoItems.prefix(3), id: \.self) { item in
                     Text(item.taskTitle ?? "")
                         //...
                 }
                 if entry.toDoItems.count > 3 {
                     //...
                 }
                 Spacer()
             }
                 .padding()
         }
     }
 }
 

… our OpenTasksWidget_Previews struct…

struct OpenTasksWidget_Previews: PreviewProvider {
     static var previews: some View {
         Group {
             OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date(), toDoItems: [ToDoItem]()))
                 .previewContext(WidgetPreviewContext(family: .systemSmall))
             OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date(), toDoItems: [ToDoItem]()))
                 .previewContext(WidgetPreviewContext(family: .systemMedium))
             OpenTasksWidgetEntryView(entry: SimpleEntry(date: Date(), toDoItems: [ToDoItem]()))
                 .previewContext(WidgetPreviewContext(family: .systemLarge))
         }
     }
 }

… and the placeholder function of our Provider:

func placeholder(in context: Context) -> SimpleEntry {
     SimpleEntry(date: Date(), toDoItems: [ToDoItem()])
 }

Both our getSnapshot and getTimeline functions require access to the CoreData database. Let’s start with the getSnapshot function. Since we can’t use the @FetchRequest property wrapper in Widgets, we need to implement the corresponding functionality manually.

func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
     
     let viewContext = PersistenceController.shared.container.viewContext
     let request = NSFetchRequest<ToDoItem>(entityName: "ToDoItem")
     request.predicate = NSPredicate(format: "taskDone = %d")
     
 }

We can fetch the ToDoItems as usual and generate a corresponding SimpleEntry:

func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
     
    let viewContext = PersistenceController.shared.container.viewContext
    let request = NSFetchRequest<ToDoItem>(entityName: "ToDoItem")
    //Filter out tasks marked as done
    request.predicate = NSPredicate(format: "taskDone = %d")
     
    do {
        let fetchedItems = try viewContext.fetch(request)
        let entry = SimpleEntry(date: Date(), toDoItems: fetchedItems)
        completion(entry)
    } catch {
        fatalError("Failed to fetch tasks: \(error)")
    }
}

Also for the timeline, we fetch the ToDoItems from our CoreData database. Then, we create a single SimpleEntry instance from the corresponding array and add it to the timeline.

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
     
    let viewContext = PersistenceController.shared.container.viewContext
    let request = NSFetchRequest<ToDoItem>(entityName: "ToDoItem")
    request.predicate = NSPredicate(format: "taskDone = %d")
     
    do {
        let fetchedItems = try viewContext.fetch(request)
        print(fetchedItems)
        var entries: [SimpleEntry] = []
        entries.append(SimpleEntry(date: Date(), toDoItems: fetchedItems))
        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    } catch {
        fatalError("Failed to fetch tasks: \(error)")
    }
}

If we now run our OpenTasksWidgetExtension in the standard simulator, the widget should successfully display the open To-Do’s (assuming you already added some in the To-Do app before).

Globally Reloading Widgets using the WidgetCenter 🌎

You probably noticed that we had set the refresh policy to .never. However, to refresh the widget, we don’t want to choose a time interval, so neither the .atEnd nor the .after policy is suitable.

Instead, we want to refresh the widget every time a new task is added, deleted or marked as done.

Widgets can easily be refreshed manually, and that too from the app itself! To do this, we simply use the so-called Widget Center.

We add the following statement to the saveTask and markTaskAsDone functions of our TasksView:

func saveTask() {
    //...
     
    do {
        //...
        WidgetCenter.shared.reloadAllTimelines()
    } catch {
        //...
    }
     
    //...
}
 

func markTaskAsDone(at index: Int) {
    //...
    do {
        //...
        WidgetCenter.shared.reloadAllTimelines()
    } catch {
        //...
    }
}

At the same time, we need to make sure that the corresponding file also contains the WidgetKit framework.

import WidgetKit

We repeat the same steps for the removeItems method in our TasksDoneView.

import SwiftUI
import WidgetKit
 

struct TasksDoneView: View {
     
    //...
     
    var body: some View {
        //...
    }
     
    private func removeItems(at offsets: IndexSet) {
        //...
        do {
            //...
            WidgetCenter.shared.reloadAllTimelines()
        } catch {
            //...
        }
    }
}

Now every time we create a new task, mark it as done, or delete it, the getTimeline method of the widget Provider is called manually, and the Widget gets refreshed!

Conclusion 🎊

That’s it! We learned how to use CoreData databases in Widgets using App Groups and how to manually refresh Widgets using the WidgetCenter. You also learned how to adjust the design of a Widget to its size using widget families. 

If you have any questions, feel free to leave a comment below!

Categories
Uncategorized

Download: MyDemoWidget, StockX Widget, To-Do Widget

Categories
Uncategorized

StockX Widget

Let’s get started with our first practical Widget. For this, we will build a Widget extension of our StockX app. The Widget will show the latest chart of a certain stock picked by the user in appropriate intervals.

Our Widget will look like this:

Preparations 🔧

To get started, open the finished StockX project. As already learned, we add a Widget to an app by selecting File – New – Target in the Xcode menubar and adding a Widget Extension. For example, name the extension “StockXWidget”. Again, we don’t need to check the “Include Configuration Intent” box, since we will add the corresponding functionality manually later. Activate the Extension Scheme when you are asked for it.

Our Widget will utilize the Chart view of our StockX app. We also need access to the DataEntry struct, the sampleData array, the TimeSeriesJSON struct and to the “Helper” files. Therefore, we need to add the corresponding files to our StockXWidget target. For this, we use the “Target Membership” section in the Identity Inspector of these files:

Setting up the SimpleEntry model, the widget view, and the Provider

Each SimpleEntry represented by the corresponding StockXWidgetEntryView must contain a Date, the corresponding stockSymbol, and a DataEntry array to display the chart later. For this, we extend the SimpleEntry struct inside the generated StockXWidget.swift file by two properties:

struct SimpleEntry: TimelineEntry {
    let date: Date
    let stockSymbol: String
    let stockData: [DataEntry]
}

Now we have to adjust the placeholder, snapshot and getTimeline function of the Provider. For now, we use the sampleData array from our DataEntry.swift file and the “AAPL” stock symbol.

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), stockSymbol: "AAPL", stockData: sampleData)
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), stockSymbol: "AAPL", stockData: sampleData)
        completion(entry)
    }

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

        let dummyEntry = SimpleEntry(date: Date(), stockSymbol: "AAPL", stockData: sampleData)
        entries.append(dummyEntry)

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

Also, the StockXWidget preview must be supplied with a sample SimpleEntry.

struct StockXWidget_Previews: PreviewProvider {
    static var previews: some View {
        StockXWidgetEntryView(entry: SimpleEntry(date: Date(), stockSymbol: "AAPL", stockData: sampleData))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

As said, we want the Widget to display the latest chart of the corresponding stock. For this, we can simply reuse the Chart view of the StockX app and use the stockData property of the provided entry.

struct StockXWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Chart(dataSet: entry.stockData)
    }
}

The corresponding preview looks like this:

In addition, the StockXWidgetEntryView should display the symbol of the represented stock. We can achieve this – for example – like this:

struct StockXWidgetEntryView : View {
    
    var entry: Provider.Entry
    
    var body: some View {
        ZStack(alignment: .topLeading) {
            Chart(dataSet: entry.stockData)
            Rectangle()
                .foregroundColor(.clear)
                .background(LinearGradient(gradient: Gradient(colors: [.black, .clear]), startPoint: .top, endPoint: .center))
                .opacity(0.4)
            Text(entry.stockSymbol)
                .bold()
                .foregroundColor(.white)
                .padding(8)
        }
    }
}

The preview now looks like this:

Adding a WidgetDownloadManager ⬇️

To download the data of the respective stock, we use a download manager again. However, we don’t reuse the DownloadManager.swift file of our StockXApp but create a new, slightly modified one.

Thus, create a new File-New-File and select “Swift File”. Name it “WidgetDownloadManager”. Make sure that “MyWidgetExtension” is selected as the target.

Create a class with the same name.

import Foundation
 
class WidgetDownloadManager {
      
}

Our DownloadManager needs a method that uses the given stockSymbol to download the necessary information using the Alphavantage API and generate a DataEntry array.

For this, we use a slightly modified version of the downloadJSON function of our DownloadManager

class WidgetDownloadManager {
    
    
    enum FetchError: Error {
        case badURL
        case badEntries
    }
    
    func downloadJSON(stockSymbol: String) async throws -> [DataEntry] {

        var dataEntries = [DataEntry]()
            
        guard let url = URL(string: generateRequestURL(stockSymbol: stockSymbol)) else { throw FetchError.badURL}
        let request = URLRequest(url: url)
        let (data, _) = try await URLSession.shared.data(for: request)

        let parsedJSON = try JSONDecoder().decode(TimeSeriesJSON.self, from: data)
        
        for timeSeries in parsedJSON.timeSeries {
            dataEntries.append(DataEntry(date: Date(timeSeries.key, dateFormat: "yyyy-MM-dd HH:mm:ss"), close: (timeSeries.value.close as NSString).doubleValue))
        }
        
        if dataEntries.count == parsedJSON.timeSeries.count {
            dataEntries.sort(by: {$0.date.compare($1.date) == .orderedAscending})
        }
        
        guard let lastDateOfData = dataEntries.last?.date else { throw FetchError.badEntries }
        
        var filteredEntries = [DataEntry]()
        for entry in dataEntries {
            if Calendar.current.isDate(entry.date, equalTo: lastDateOfData, toGranularity: .day) {
                filteredEntries.append(entry)
            }
        }
        
        dataEntries = filteredEntries
        
        return dataEntries

    }
}

Updating the GetTimeline method

Let’s use our new download manager to generate the entries for our widget’s timeline.

To do this, we initialize a WidgetDownloadManager instance in the Provider struct.

struct Provider: TimelineProvider {
     
    let widgetDownloadManager = WidgetDownloadManager()
     
    //...
}

Inside the getTimeline function, we can now use the downloadJSON method of the widgetDownloadManager to download the latest stock data.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    Task {
        let stockData = try await widgetDownloadManager.downloadJSON(stockSymbol: "AAPL")
    }
}

Once this process is finished, we use the stockData to initialize a SimpleEntry. Then, we add it to the timeline.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    Task {
        let stockData = try await widgetDownloadManager.downloadJSON(stockSymbol: "AAPL")
        let entries = [SimpleEntry(date: Date(), stockSymbol: "AAPL" , stockData: stockData)]
        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    }
}

We want the Widget to update itself 10 minutes at the earliest. Therefore, we change the timeline policy from .never to .after as follows:

let timeline = Timeline(entries: entries, policy: .after(Calendar.current.date(byAdding: .minute, value: 10, to: Date())!))

Now when we run the Widget Extension in the simulator, the Provider calls the getTimeline method. This uses the downloadJSON method of the widgetDownloadManager to retrieve the data of the “AAPL” stock via the Alphavantage API. Once this process is complete, a corresponding SimplyEntry is initialized and added to the timeline. The StockXWidgetEntryView uses this to display the corresponding chart.

Allowing user customization using IntentConfiguration 🤘

Until now, the user is always shown the latest “Apple” chart.

However, we want to allow the user to select the stock represented in the Widget. To offer the user individualization options, we use a so-called IntentConfiguration. As we saw earlier, you can check the corresponding option when adding the Widget Extension to the app.

To add an IntentConfiguration manually, we go to File-New-File and search for “SiriKit Intent Definition File”. Name the file “IntentConfiguration”. Again, make sure that the Widget Extension is selected as the target.

To add a new configuration option, open the IntentConfiguration file and click “Plus”-icon on the bottom and select “New Intent”. Double click on the resulting Intent to rename it to “AvailableStocks”.

Now we configure our AvailableStocks intent. Select “View” as the “Category” and use “AvailableStocks” as the “Title”. It is important that you now check “Intent is eligible for widgets”! However, we do not want the “Shortcuts” and “Siri Suggestions” functionality.

We only need one parameter for our Intent. To add it, we click on the plus icon under the “Parameter” section and name it “selectedStock”. For this parameter, we now create a corresponding enum. This can be done easily by selecting “Add enum” as the “Type”.

We name the generated enum “SelectedStock”. Now, we can easily create different cases with the respective stock symbol and also define under which display name each is shown to the user. For example:

We’re finished preparing our IntentConfiguration!

Updating our StockXWidget.swift file to a IntentConfiguration

Next, we need to modify our StockXWidget.swift file accordingly. Let’s start with the Provider struct. 

Instead of the TimelineProvider protocol, the Provider struct needs to conform to the IntentTimelineProvider protocol:

struct Provider: IntentTimelineProvider {
     
     //...

}

Accordingly, we need to make some adjustments to our Provider.

First, our getSnapshot function needs another parameter named “for configuration”:

func getSnapshot(for configuration: AvailableStocksIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    //...
}

Also, the snapshot should depend on the stock picked by the user. To find know this stock, we add the following function to our Provider struct:

private func getStockSymbol(for configuration: AvailableStocksIntent) -> String {
    switch configuration.selectedStock {
    case .aAPL:
        return "AAPL"
    case .nFLX:
        return "NFLX"
    case .tSLA:
        return "TSLA"
    case .unknown:
        return "AAPL"
    }
}

Instead of the fixed “AAPL” stockSymbol, we can now use this function for the SimplyEntry in our getSnapshot method:

func getSnapshot(for configuration: AvailableStocksIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    let entry = SimpleEntry(date: Date(), stockSymbol: getStockSymbol(for: configuration), stockData: sampleData)
    completion(entry)
}

We repeat the same steps for the getTimeline method:

func getTimeline(for configuration: AvailableStocksIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    Task {
        let stockData = try await widgetDownloadManager.downloadJSON(stockSymbol: getStockSymbol(for: configuration))
        let entries = [SimpleEntry(date: Date(), stockSymbol: getStockSymbol(for: configuration), stockData: stockData)]
        let timeline = Timeline(entries: entries, policy: .after(Calendar.current.date(byAdding: .minute, value: 10, to: Date())!))
        completion(timeline)
    }
}

Our Provider now conforms to the IntentTimelineProvider protocol. Finally, we just need to use an IntentConfiguration instead of a StaticConfiguration in our StockXWidget struct:

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

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: AvailableStocksIntent.self,provider: Provider()) { entry in
            StockXWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

If the user now long-presses on the widget, he has the possibility to select his preferred stock.

Recap 🎊

We have already learned how Widgets basically work, how to provide them with data and how to update them at the appropriate time. We also learned how to provide customization options to the user using an IntentConfiguration.

Next, learn how to integrate CoreData functionality into Widgets, how to adjust the appearance to the size of the Widget, and how to manually refresh Widgets.

Categories
Uncategorized

Chapter 15: Mastering Widgets

What you’ll learn in this chapter:

  • How to extend your app’s functionality using Widgets
  • The theory behind Widgets: Providing and refreshing Widgets
  • How to offer customization options using IntentConfigurations
  • CoreData Integration and Networking Requests

Here is a preview of the Widgets we’re going to create:

The basics: How widgets work 💡

One of the major innovations of iOS 14 was the introduction of Widgets. What has been available on Android for years is now also possible on Apple devices. Widgets can be used to individualize the home screen.

Widgets are available in three different sizes: .systemSmall, .systemMedium and .systemLarge. You can see an example of this when you open the Widget Gallery and open the Widgets for the pre-installed “Clock” app, for example. Depending on the selected size of the Widget, a different view will be presented.

It is important to note that widgets are not mini-apps, i.e. they cannot be interacted with (with the exception that tapping on the Widget opens the app). Rather, they act as mere views that display information from the associated app that is relevant for the user. 

But how does the Widget exactly know when to display which information? We will go into this in detail in a moment, but here is a rough outline: When a Widget is added to the home screen, the system first asks our app for one or more entries, which are arranged along a so-called timeline. 

For example, imagine a Widget that always counts up from 0 to 40 in steps of ten, then start again from 0 (to be honest, a quite impractical example). In this example, we give the system five entries (the values from 0, 10, 20, 30, and 40) and align them on the timeline at an interval of ten seconds each. 

Note: In case you’re wondering why we don’t just update the Widget every second, Apple doesn’t allow us to refresh the Widget too often. In my experience, a 10-second interval has proven to be a reasonable minimum.

Look at the following illustration for a better understanding:

But what happens now when all given entries have been used? That is our decision: For example, we could not provide any new entries, but then the Widget would not be updated anymore (i.e., in our example, it would permanently show 40).

But we could also, for example, start over and provide five new entries again for the next 50 seconds on the timeline and so on.

Finally, we can specify a completely different time when new entries are played out, which would result in a “gap” on the timeline.

This all sounds very abstract now, but once we actually implement a Widget, it should be easier to imagine.

One more thing: By default, Widgets have a so-called StaticConfiguration, where we as developers configure all settings for our Widget in advance. But there are also so-called IntentConfigurations possible, with which we can allow the user to set their own preferences for the Widget to customize the Widget accordingly. But more about this more advanced topic later.

Creating our first Widget 🔨

Let’s start with creating our own Widget. Each widget is part of a SwiftUI app. For this, we create a new Xcode project, select „iOS App“, and name the project, e.g. “MyDemo”. 

We can leave our MyDemo app as it is. To add a Widget, we go to File – New – Target in the Xcode menubar and search for “Widget Extension”. Click on “Next” and name the widget “MyDemoWidget”, for example. Also, make sure you select a “Team” (regularly that’s your Apple Developer account). We won’t check the “Include Configuration Intent” box since we’re just using a StaticConfiguration for this example.

After you click on “Finish”, a window should pop up asking you if you want to activate the Extension Scheme. Click on “Activate”. 

In your Xcode project, there is now a new group that contains the file MyDemoWidget.swift.

Let’s take a closer look at this one.

How the Provider works

Basically, our MyDemoWidget is structured quite similarly to the corresponding MyDemoApp. In the MyDemoWidget.swift file, you will find a @main MyDemoWidget struct. 

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

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            MyDemoWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

This initializes the Widget when the user adds it to the home screen. Here we can specify, among other things, whether it is a static or intent configuration and initialize the Provider. More about this in a moment.

As you can see, the MyDemoWidget struct initializes the MyDemoWidgetEntryView. We can find the corresponding one above.

struct MyDemoWidgetEntryView: View {
     
    var entry: Provider.Entry
     
    var body: some View {
        Text(entry.date, style: .time)
    }
}

As already explained, there are one or more entries on the so-called timeline. The MyDemoWidgetEntryView takes the date contained in the respective entry and represents it in a Text view.

Our Widget gets the entries from the so-called Provider, whose implementation we see above.

struct Provider: TimelineProvider {
    //...
}

The Provider contains three different functions:

  1. func placeholder
func placeholder(in context: Context) -> SimpleEntry {
    SimpleEntry(date: Date())
}

This placeholder function provides our view with a sample SimpleEntry instance. The iOS device then displays the resulting instance in a much-reduced version. This is needed when the Widget has not yet finished loading.

You’ll see how this looks like in a moment.

2. func getSnapshot:

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

Similar to the placeholder function, the getSnapshot function also returns a sample SimpleEntry. The resulting view is used, for example, when the Widget is selected in the Widget Gallery of the device.

3. func getTimeline:

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    var entries: [SimpleEntry] = []
     
    // 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 = SimpleEntry(date: entryDate)
        entries.append(entry)
    }
     
     
    let timeline = Timeline(entries: entries, policy: .atEnd)
    completion(timeline)
}

This function is the heart of every Widget. As shown above, it provides the timeline with a set of SimpleEntry instances, which are then played out on the respective date.

We will look into this in more detail in a moment.

Let’s now take another look at the preview simulator. This is generated by the MyDemoWidget_Previews struct and represents a simple SimpleEntry instance.

You can see how your placeholder looks like by applying the .redacted modifier to the MyDemoWidgetEntry view in the PreviewProvider:

Setting up our widget 🧑🏽‍💻

Let’s talk about the getTimeline function of the Provider in more detail. As said before, it is responsible for providing the given entries at the right time.

Let’s delete the pre-generated code.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
     
}

As discussed in our example above, we want our Widget to display a new view every ten seconds showing the current elapsed time. For this purpose, the corresponding SimpleEntry instances must have a property to know at what time they are used and a property to hold the elapsed seconds. Our SimpleEntry already contains a corresponding date property, so we only need to add a “secondsElapsed” property:

struct SimpleEntry: TimelineEntry {
    let date: Date
    let secondsElapsed: Int
}

Next, we need to update our placeholder and getSnapshot function accordingly. 

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), secondsElapsed: 10)
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), secondsElapsed: 10)
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        
    }
}

We also need to update the PreviewProvider struct:

struct MyDemoWidget_Previews: PreviewProvider {
    static var previews: some View {
        MyDemoWidgetEntryView(entry: SimpleEntry(date: Date(), secondsElapsed: 10))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

When our Widget requests the timeline (the first time this happens is when the Widget is added to the home screen), the getTimeline function is called. When this happens, we create an empty array first:

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

Starting from the current Date, we now create five different entries covering the next five 10-second intervals and add them to the entries array:

var entries = [SimpleEntry]()
let currentDate = Date()
for secondOffset in 0 ..< 5 {
    let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset*10, to: currentDate)!
    let entry = SimpleEntry(date: entryDate, secondsElapsed: secondOffset*10)
    entries.append(entry)
}

From the entries array, we can now create a Timeline and then provide it to the Widget.

func getTimeline(in context: Context, completion: @escaping (Timeline< Entry>) -> ()) {
    var entries = [SimpleEntry]()
    let currentDate = Date()
    for secondOffset in 0 ..< 5 {
        let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset*10, to: currentDate)!
        let entry = SimpleEntry(date: entryDate, secondsElapsed: secondOffset*10)
        entries.append(entry)
    }
     
    let timeline = Timeline(entries: entries, policy: .never)
    completion(timeline)
}

The policy parameter determines after which rule a new timeline should be asked for, i.e. the getTimeline function should be called the next time. We have the choice between .never (self-explanatory), .atEnd (i.e. immediately when the last SimpleEntry has been played on the timeline) or .after (here we specify an individual Date when the getTimeline function should get called again).

To display the elapsed seconds, we update our MyWidgetEntry view accordingly.

struct MyDemoWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text("\(entry.secondsElapsed) seconds elapsed" )
    }
}

To test our Widget we need to run the “MyWidgetDemoExtension” scheme in the regular simulator.

After the Widget has been installed and added to the simulator’s home screen, we call the getTimeline function for the first time and add the five different entries to the timeline at intervals of 10 seconds each. Accordingly, the MyDemoWidgetEntryView should refresh every ten seconds. 

Note: Even if the date of a SimplyEntry instance is reached on the timeline, this is no guarantee that the Widget will be refreshed at exactly this time. We are just setting the earliest time for the Widget to be refreshed. For our example, this is not so important, but you should definitely keep it in mind!

Since we have currently chosen .never as the policy, after playing the last SimplyEntry instance of the timeline with a secondsElapsed value of 40, the Widget is not updated anymore. If we change the policy to .atEnd, the getTimeline method is called again immediately and five new entries are added to the timeline and so on.

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

We can create a “gap” in the timeline by using the .after policy to select a specific time when the getTimeline function should be called again.

let timeline = Timeline(entries: entries, policy: .after(Calendar.current.date(byAdding: .second, value: 90, to: currentDate)!))

Now that we have familiarized ourselves with the theoretical functionality of Widgets, we will deepen our knowledge with practical examples. For this purpose, we will create Widgets for our already existing StockX and To-Do app.