Rake, rake. We are writing a responsive interactive widget IOS 17

Rake, rake. We are writing a responsive interactive widget IOS 17

Hello everybody! Hanna 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 clicks in the moment. 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 is bound to break or suddenly change.

Therefore, today we will talk about how to use Widget Kit iOS 17 to make a widget interactive, working and responsive in the moment, and to bypass the pitfalls left by API developers. Let’s consider the example of a self-written program for TODO notes.

In such applications, it is also important to synchronize state between targets without loss or delay. We store the data (our tudushkas and their state) locally. For this, we use the SwiftData data storage tool. This framework was also presented at WWDC 2023, and there are also many pitfalls when using it in different targets.


So let’s see what we have at the beginning. Our main program is implemented on SwiftUI:

List of entries in the View program

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")
            }))
        }
    }

Data for display is taken directly from the storage using a macro

Query

. We use SwiftData as a 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 literally have several fields: task name, execution 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 repository:

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

So far, nothing unusual, the most standard solution.

Now let’s go to the widget:

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

Code Widget

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

    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
            TodoAppWidgetView(entry: entry)
                .containerBackground(for: .widget) {
                    BackgroundView()
                }
        }.supportedFamilies([.systemSmall, .systemLarge])
    }
}

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

TodoAppWidgetView is responsible for displaying our view.

Let’s replace the UI View widget:

TodoAppWidgetView

import WidgetKit

struct TodoAppWidgetView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack(alignment: .leading) {
            HStack(alignment: .center, content: {
                Text("Notes").foregroundStyle(.white)
                Spacer()
                Text("\(entry.uncompleted)/\(entry.total)").foregroundStyle(.white)
            }).frame(height: 40)
            ForEach(entry.data.indices) { index in
                    Label(entry.data[index].taskName , systemImage: "circle\(entry.data[index].isCompleted ? ".fill" : "")")
                        .frame(maxWidth: .infinity, alignment: .leading)             
            }
            Spacer()
            HStack(alignment: .bottom, content: {
                Text("Add task +").foregroundColor(.gray)
            }).frame(height: 40)
        }
    }
}

A widget cannot have a state and cannot depend on a @PropertyWrapper state variable. To render the 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 tuples, the number of all records and the number of completed ones. So that we can maintain the same data structure that we use for the main application, let’s add support for all application targets:

Similarly, let’s enable support for all targets for TodoDataManager.

The status provider itself stores a set of snapshots of our widget at a moment in time for displaying them on 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 requests data 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 point, the question arises: why don’t we use @Query in the provider? Answer: A widget is state independent and cannot subscribe to state.

Let’s launch our application and add a couple of entries:

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

Let’s specify the same group:

The group specifies internally the URL for our local repository. Data that we previously stored is no longer available to us. We remove the previous widgets from the screens and add a new one:

We now have access to the repository.

However, if we change the state of the record, add a new one or delete it, our widget will not react to it correctly and will not count the current data.

In the current implementation, we read the data once when the widget is installed. We also request the current status from the timeline provider:

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

The interval between updates must not be less than a minute or it will be ignored.

There will be other solutions for timers, players, etc., but that’s not the topic today.

Let’s add a widget update when data changes in the app. For this, in our TodoDataManager, we will add the call WidgetCenter.shared.reloadAllTimelines() to reload all widgets, or reloadTimelines(of: Kind) to reload widgets with the given 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()
    }

Also, let’s 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 react to adding and removing records instantly.

Notice that we’ve annotated all the 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, it became possible 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 call the record change event by index. We notice the property we need

Parameters

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 call the TodoDataManager method asynchronously. We also need to wrap our strings on 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 program may need to be restarted to update the state when returning from the widget. The point is as follows:

1. `@Query’ is called at the start of our program and can track changes in the Foreground. And in general it is bugged.

2. SwiftData mainContext can work correctly only in advance. The widget does not request data from the foreground, the program starts from the background upon return. Background task context required.

3. The widget may also experience desynchronization when updating the value.

Let’s try to solve this problem through background context. Don’t confuse a background thread with a background task. We are talking about the last one.

To work with the background context, we make 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 this background model context. Through it, we make a fetch request to receive data.

On the side of the widget, replace the method code for downloading:

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

For our main program, let’s do the following. Remove @Query, create ObservableObject and attach to our View as 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 the data request from the background when returning to the program. For example, in the onChange method:

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

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

We removed @Query and now we don’t have automatic subscription to data changes. To fix this, we create an UpdateListener protocol and connect the TodoDataManager to our ViewModel using the delegate principle:

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()
    }

You need to replace and update the status from the list:

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

We get an active program with a widget:

Let’s summarize what we did:

1. Added AppGroups to the application and widget
2. Created a single context for accessing operations
3. Added AppIntent to the button for calling events.
4. The widget was reloaded from operations.
5. Fixed background query issue for SwiftData
Profit!

Next time we will try to deal with the player and special AppIntent.

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

Related posts