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:
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
. 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:
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, specifying its UI and state update mechanism (Provider).
TodoAppWidgetView is responsible for displaying our View.
Let's replace the widget's UI View:
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 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