By rake, by rake. Writing a responsive interactive widget for iOS 17

Hi all! Anna Zharkova, head of the mobile development group at Usetech, is in touch. At WWDC 2023, Apple introduced many new and interesting APIs, including the long-awaited interactive widgets that respond to instant clicks. However, as practice shows, not everything is as simple and beautiful as Apple shows at demo sessions, and from beta to release, something in the API always breaks or suddenly changes.

Therefore, today we will talk about how to use the Widget Kit iOS 17 to make the widget interactive, operational and responsive in the moment, and get around the pitfalls left by the API developers. We will look at the example of a self-written application for TODO notes.

In such applications, it is also important to synchronize state between targets without loss or delay. We save the data (our files and their state) locally. To do this, we use the data storage tool SwiftData. This framework was also presented at WWDC 2023, and when using it in different targets, you can also encounter many pitfalls.


So let's look at what we have at the beginning. Our main application is implemented in SwiftUI:

List of entries in the View application

struct ListContentView: View {
   @Query var items: [TodoItem]
    
    var body: some View {
        NavigationView {
            List {
                ForEach(tems) { item in
                    Label(item.taskName , systemImage: "circle\(item.isCompleted ? ".fill" : "")")
                        .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle())
                        .onTapGesture {
                            withAnimation {
                                item.isCompleted = !item.isCompleted
                     //Обновление
                            }
                        }
                }.onDelete {index in
                  /// Вызываем удаление
                    deleteItems(offsets: index)
                }
            }
            .navigationTitle("TODO")
            .navigationBarItems(trailing: Button(action: addItem, label: {
              /// По нажатию на эту кнопку добавляем
                Image(systemName: "plus")
            }))
        }
    }

We take the data for display directly from the storage using a macro

Query

. We use SwiftData as this toolkit. For convenience, we place the logic in a separate TodoDataManager class:

class TodoDataManager {
    static var sharedModelContainer: ModelContainer = {do {
            return try ModelContainer(for: TodoItem.self)
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

// Тут методы
}

We take the container for connection from our TodoDataManager:

@main
struct TodoAppApp: App {
    var sharedModelContainer: ModelContainer = TodoDataManager.sharedModelContainer

    var body: some Scene {
        WindowGroup {
            ListContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

The model itself, which we use to store and display data, will have literally several fields: task name, completion flag, date.

@Model
class TodoItem: Identifiable {
    var id: UUID
    var taskName: String
    var startDate: Date
    var isCompleted: Bool = false
    
    init(task: String, startDate: Date) {
        id = UUID()
        taskName = task
        self.startDate = startDate
    }
}

We delete and add a record through the context of our storage:

    @MainActor
    func addItem(name: String) {
       withAnimation {
        let newItem = TodoItem(task: name, startDate: Date())
        TodoDataManager.sharedModelContext.insert(newItem)
    }
    }

Nothing unusual yet, the most standard solution.

Now let's move on to the widget:

We add the New Target – Widget Extensions target to our application. We will create a template for our widget:

This structure of the Widget type sets the configuration of the widget, specifying its UI and state update mechanism (Provider).

TodoAppWidgetView is responsible for displaying our View.

Let's replace the widget's UI View:

A widget cannot have state and cannot depend on @PropertyWrapper state variables. To render data in the View, we pass the Entry model through the mechanism of our Provider state provider. The data model must support the TimelineEntry protocol:

struct SimpleEntry: TimelineEntry {
    let date: Date
    let data: [TodoItem]
    var completed: Int {
        return data.filter{
            $0.isCompleted
        }.count
    }
    var total: Int {
        return data.count
    }
}

We will need an array of several records, the number of all records and the number of completed ones. So that we can support the same data structure that we use for the main application, we will add support for all application targets to it:

Similarly, we will enable support for all targets for TodoDataManager.

The state provider itself stores a set of snapshots of our widget at a point in time to display them along the timeline at specified intervals. In iOS 17, the provider implements the AppIntentTimelineProvider protocol with async/await support:

struct Provider: AppIntentTimelineProvider {

//...

    func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
        let items = await loadData()
        return SimpleEntry(date: Date(), data: items)
    }
    
    func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
        var entries: [SimpleEntry] = []
        let entryDate = Date()
        let items = await loadData() //<-- вот тут данные запрашиваем из TodoDataManager
        let entry = SimpleEntry(date: entryDate, data: items)
        //20 потом заменим на 60
        return Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(20)))
    }
}

The loadData method calls a data request from the TodoDataManager via fetch using the sharedModelContainer and its context:

//Widget
@MainActor
    func loadData()->[TodoItem] {
        return TodoDataManager.shared.loadItems()
    }

//TodoDataManager
@MainActor
    func loadItems(_ count: Int? = nil)->[TodoItem] {
       return (try? TodoDataManager.sharedModelContainer.mainContext
                          .fetch(FetchDescriptor<TodoItem>())) ?? []
    }

At this stage the question arises: why don't we use `@Query` in the provider? Answer: A widget is state independent and cannot have a state subscription.

Let's launch our application and add a couple of records:

However, this will not affect our widget in any way. At this stage, it does not have access to the main application's storage. In order to share access, we need to add AppGroups to both the application target and the extension target:

Let's indicate the same group:

The group sets the url inside for our local storage. The data we saved before is no longer available to us. We remove previous widgets from the screens and add a new one:

Now we have access to the storage.

However, if we change the state of an entry, add a new one or delete it, our widget will not react to this correctly and does not consider the current data.

In the current implementation, we read the data once when installing the widget. We also request the current state in the timeline provider:

Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(60)))

The interval between updates should not be less than a minute, otherwise it will be ignored.

There will be other solutions for timers, players, etc., but we won’t talk about that today.

Let's add a widget update when data changes in the application. To do this, in our TodoDataManager we will add a call to WidgetCenter.shared.reloadAllTimelines() to reload all widgets, or reloadTimelines(of: Kind) to reload widgets with the specified key parameter Kind:

 @MainActor
    func addItem(name: String) {
        // код
        WidgetCenter.shared.reloadAllTimelines()
    }
    
    @MainActor
    func deleteItem(offsets: IndexSet) {
        //код
        WidgetCenter.shared.reloadAllTimelines()
    }

    @MainActor
    func updateItem(index: Int) {
        let items = loadItems()
        let checked = items[index].isCompleted
        items[index].isCompleted = !checked
        WidgetCenter.shared.reloadAllTimelines()
    }

Let's also create a special model context that we will use for operations:

 static var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            TodoItem.self,
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

//Тот самый контекст
    static var sharedModelContext = ModelContext(sharedModelContainer)

Our widget can now respond to adding and deleting records instantly.

Please note that we have marked all recording methods with @MainActor to call the storage operation on the main thread.

Let's add a reaction from the widget. In iOS 17, WidgetKit introduced the ability to use AppIntent to transmit events from buttons and toggles and call logic. There are a number of special AppIntents that not only support interactivity, but also include various useful permissions and functionality support.

Let's create the following intent:

struct CheckTodoIntent: AppIntent {
    @Parameter(title: "Index")
    var index: Int
    
    init(index: Int) {
        self.index = index
    }
    
    func perform() async throws -> some IntentResult {
      //Вызов обновления по индексу
        await TodoDataManager.shared.updateItem(index: index)
        return .result()
    }
}

We plan to trigger a record change event based on the index. We mark the property we need

Parameter

indicating the key. In our case, we will use the index (ordinal number) of the element from the array of entries in the widget.

In the main perform method, we asynchronously call the TodoDataManager method. We also need to wrap our strings in buttons:

 ForEach(entry.data.indices) { index in
              //Вот сюда мы индекс и передаем
                Button(intent: CheckTodoIntent(index: index)) {
                    Label(entry.data[index].taskName , systemImage: "circle\(entry.data[index].isCompleted ? ".fill" : "")")
                        .frame(maxWidth: .infinity, alignment: .leading)
                }     
            }

At this point, we may notice that the application may need to be restarted to update its state when returning from the widget. The point is this:

1. `@Query` is called when our application starts and can monitor changes in the Foreground. And in general he is bugged.

2. SwiftData mainContext can only work correctly in the foreground. The widget requests data not from the foreground; when it returns, the application starts from the background. Need context for background task.

3. The widget may also experience out of sync when updating the value.

Let's try to solve this problem through the background context. Don't confuse a background thread with a background task. We're talking about the latter.

To work with the background context, we create an actor wrapper:

@ModelActor
actor SwiftDataModelActor {
    
    func loadData() -> [TodoItem] {
        let data = (try? modelExecutor.modelContext.fetch(FetchDescriptor<TodoItem>())) ?? 
                 [TodoItem]()
        return data
    }
}

The ModelActor macro creates a special modelExecutor, which will give us the same background context of the model. Through it we make a fetch request to obtain data.

On the widget side, replace the loading method code:

 @MainActor
    func reloadItems() async -> [TodoItem] {
            let actor = SwiftDataModelActor(modelContainer: TodoDataManager.sharedModelContainer)
            return await actor.loadData()
    }

For our main application we will do the following. We remove `@Query`, create an ObservableObject and attach it to our View as an ObservedObject. In it we will make 2 methods for requesting data in the background and in the main contexts:

@MainActor
    func loadItems(){
        Task.detached {
            let actor = SwiftDataModelActor(modelContainer: TodoDataManager.sharedModelContainer)
            await self.save(items: await actor.loadData())
        }
    }
    
    @MainActor
    func save(items: [TodoItem]) {
        self.items = items
    }
   
    @MainActor
    func reloadItems() {
        self.items = TodoDataManager.shared.loadItems()
    }

We will call a request for data from the background when returning to the application. For example, in the onChange method:

.onChange(of: phase) { oldValue, newValue in
            if oldValue == .background {
                model.loadItems()
            }

But we will need reloadItems with mainContext in the foreground of our application to request data, for example, after creating a record.

We've removed `@Query` and now we don't have automatic subscriptions to data changes. To fix this, we create an UpdateListener protocol, and using the delegate principle, we associate the TodoDataManager with our ViewModel:

protocol UpdateListener {
    func loadItems()
    
    func reloadItems()
}

//TodoDataManager
@MainActor
    func addItem(name: String) {
        let newItem = TodoItem(task: name, startDate: Date())
        TodoDataManager.sharedModelContext.insert(newItem)
        listeners.forEach { listener in
            listener.reload()
        }
        WidgetCenter.shared.reloadAllTimelines()
    }

It is necessary to replace the status update from the list:

.onTapGesture {
       withAnimation {
           item.isCompleted = !item.isCompleted
          TodoDataManager.shared.updateItem(index: model.items.firstIndex(of: item) ?? 0)
              }
      }

We get a working application with a widget:

Let's summarize what we did:

1. Added AppGroups to the application and widget
2. Created a single context for access to operations
3. Added AppIntent to the button for calling events.
4. From the operations, the widget was rebooted.
5. Solved the problem with the background request for SwiftData
Profit!

Next time we'll try to figure out the player and special AppIntents.

Useful links:

developer.apple.com/videos/play/wwdc2023/10028

developer.apple.com/documentation/widgetkit/adding-interactivity-to-widgets-and-live-activities

developer.apple.com/documentation/swiftdata/modelactor

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *