Making Widgets Interactive using App Intents

App Intents provide an effortless way to connect your app's features and content with system functionalities. They can also be used to drive live activities, controls and interactive widgets — which is exactly what I will be covering in this article.

In this article, I will continue refining my to-dos sample app, using it as a hands-on example to demonstrate how the App Intents framework can streamline and improve user interactions.

If you are unfamiliar with the topic, I suggest starting with the article "Bringing App Intents to your SwiftUI App" for a detailed introduction.

The Plan

In this article, I will demonstrate how to create interactive widgets using App Intents. The plan is to:

  • Create a widget that displays the top three to-do items.
  • Enable users to mark a to-do as completed directly from the widget.

Adding an App Group

To share data between your iOS app and widgets extension, start by creating an App Group. This allows both targets to access our Swift data persistent database. Here is how:

  • Open your project's Target settings.
  • Navigate to the Signing & Capabilities tab.
  • Click the "+ Capability" button and select App Groups to add it.
  • Press "+" under App Groups to create a new group. It will suggest "group." — append your bundle Id after this prefix.

The string must be globally unique across the entire iOS ecosystem.

Now when the app launches, its data is stored within the app group. This setup allows any additional modules or targets that share the same app group to access the same content seamlessly.

Creating a Widget Target

A widget is an independent target, separate from other app targets. By default, it lacks access to shared content, including models, views, or app groups.

To set up a widget:

  • Create a new widget via File > New Target > Widget Extension.
  • Name it and avoid selecting checkboxes before finishing and activating.
  • Add the widget to the existing app group for shared data. Select the widget target, go to Signing & Capabilities, add App Groups, and choose the group created for the iOS target.

Setting Up our Widget

The view will remain static and refresh only when a user updates it by marking a to-do as completed in the app or when changes are triggered directly within the widget.

Here is the code for our widget:

import WidgetKit
import SwiftUI
 
struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> FirstTodosEntry {
        FirstTodosEntry(date: Date(), todos: [])
    }
 
    func getSnapshot(in context: Context, completion: @escaping (FirstTodosEntry) -> ()) {
        let currentDate = Date.now
        Task {
            let allTodos = try await getTodos()
            let entry = FirstTodosEntry(date: currentDate, todos: allTodos)
            completion(entry)
        }
    }
 
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        let currentDate = Date.now
        Task {
            let allTodos = try await getTodos()
            let entry = FirstTodosEntry(date: currentDate, todos: allTodos)
            let timeline = Timeline(entries: [entry], policy: .atEnd)
            completion(timeline)
        }
    }
 
    @MainActor
    func getTodos() async throws -> [Todo] {
        let todosManager = TodosManager(context: Shared.container.mainContext)
        return try await todosManager.getTodos()
    }
}
 
struct FirstTodosEntry: TimelineEntry {
    let date: Date
    let todos: [Todo]
}
 
struct FirstTodosWidgetEntryView : View {
    var entry: Provider.Entry
 
    private var todosPrefixIndex: Int {
        guard !entry.todos.isEmpty else { return 0 }
        return min(entry.todos.count, 3)
    }
 
    var body: some View {
        if entry.todos.isEmpty {
            ContentUnavailableView(
                "No todos yet",
                systemImage: "plus.circle.fill"
            )
        } else {
            [...]
        }
    }
}

Adding the Widget to the Home Screen

To add the widget after running the app, return to the home screen and long-press to enter edit mode.

  • Select "Edit" then tap "Add Widget".
  • Search for your app's name and click "Add Widget" to place it on your home screen.

You will get a snapshot of your widget on the preview.

Communication between App and Widget

With that, the widget appears on the home screen. However, there is an issue - if you add, remove, or update a to-do in the app and return to the home screen, the widget doesn't update.

To resolve this, return to the app and locate where the to-dos are modified. Import the WidgetKit framework. Then, whenever a change occurs, access the shared instance of WidgetCenter and call reloadTimelines to ensure the widget updates dynamically.

Here is the code when we toggle a to-do completed value:

import SwiftUI
import WidgetKit
 
struct TodoRowView: View {
    @Environment(\.modelContext) var context
    let todo: Todo
 
    var body: some View {
        HStack {
            Image(systemName: todo.isCompleted ? "largecircle.fill.circle" : "circle")
                .resizable()
                .frame(width: 24, height: 24)
                .onTapGesture {
                    todo.toggleCompleted()
                    try? context.save()
                    // reload widgets
                    WidgetCenter.shared.reloadAllTimelines()
                }
            Text(todo.title)
                .opacity(todo.isCompleted ? 0.6 : 1)
        }
    }
}

Making the Widget Interactive

Currently, tapping the widget only opens the app, as tap gestures aren't recognized. Fortunately, iOS 17 introduces interactive widgets using App Intents.

To implement this, create a new ConfigurableUpdateIntent file in the widget's target. Then, define a struct named ConfigurableUpdateIntent that conforms to the AppIntent protocol. This enables interaction and custom behavior when users interact with the widget.

import AppIntents
import WidgetKit
 
struct ConfigurableUpdateIntent: AppIntent {
    static var title: LocalizedStringResource = LocalizedStringResource("Update Todo")
    static var description: IntentDescription? = IntentDescription("Tap the todo to toggle its completion status")
 
    @Parameter(title: "Todo")
    var title: String
 
    init() {}
 
    init(title: String) {
        self.title = title
    }
 
    func perform() async throws -> some IntentResult {
        let update = try await updateTodo(title: title)
        return .result(value: update)
    }
 
    @MainActor
    func updateTodo(title: String) async throws -> Bool {
        let todosManager = TodosManager(context: Shared.container.mainContext)
        let foundTodos = try await todosManager.getTodos(with: title)
        guard let todo = foundTodos.first else { return false }
        try? await todosManager.toggleCompleted(todo: todo)
        // reload widgets
        WidgetCenter.shared.reloadAllTimelines()
        return todo.isCompleted
    }
}

Binding Everything Together

Next, head back to the widget view where the to-dos are displayed. Instead of a regular button with an action, you need to create a new button type that uses the to-dos view as its label.

Unlike traditional buttons that trigger actions, this button will be linked to an intent — specifically, the ConfigurableUpdateIntent — which will handle the interaction logic within the widget.

import WidgetKit
import SwiftUI
 
struct FirstTodosWidgetEntryView : View {
    [...]
 
    var body: some View {
        if entry.todos.isEmpty {
            [...]
        } else {
            VStack {
                HStack {
                    Text("My Todos")
                        .bold()
                    Spacer()
                }
                ForEach(0..<todosPrefixIndex, id: \.description) { index in
                    HStack {
                        Button(intent: ConfigurableUpdateIntent(title: entry.todos[index].title)) {
                            Image(systemName: entry.todos[index].isCompleted ? "largecircle.fill.circle" : "circle")
                                .resizable()
                                .frame(width: 24, height: 24)
                        }
                        .buttonStyle(.plain)
                        Text(entry.todos[index].title)
                            .font(.footnote)
                            .opacity(entry.todos[index].isCompleted ? 0.6 : 1)
                        Spacer()
                    }
                }
                Spacer()
            }
        }
    }
}

Running the App

Now, when running the app, exiting to the home screen, and tapping a to-do item to mark it as completed, the update happens instantly! However, if you tap outside the to-do item, the app launches as expected.

Conclusion

Interactive widgets with intents offer a great way to boost user engagement by enabling actions directly from the home screen. By setting up intents, handling them efficiently, and designing engaging interfaces, you can create widgets that provide a smooth and intuitive experience. The possibilities to enhance functionality are endless!

To explore the full implementation of the sample application, visit the My To-dos app repository on GitHub.

I hope you have enjoyed this article and thanks for reading 😉

Reference

More

In case you have any questions reach me at tiago.fig.henriques.2@gmail.com or see more of my work on Twitter.