Interactive Widgets With SwiftUI
Discover how iOS 17 takes widgets to the next level by adding interactivity. Use SwiftUI to add interactive widgets to an app called Trask. Explore different types of interactive widgets and best practices for design and development. By Alessandro Di Nepi.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Interactive Widgets With SwiftUI
20 mins
- Getting Started
- Recapping WidgetKit
- Adding an iOS Widget
- Widget Code Structure
- Data Sharing With The App
- Timeline Provider
- Updating Widgets
- Making the Widget Interactive
- Types of Interactivity
- Widgets and Intents
- Adding the Intent
- Binding Everything Together
- Animating the Changes
- Fixing the Animation For a Digit
- Adding More Animations
- Keeping the App in Sync
- Adding Your Second Widget
- Widgets Design Concepts
- Adding a TodoList Widget
- TodoList Timeline Provider
- TodoList Widget View
- Multiple Widgets with WidgetBundle
- Where to Go From Here?
Widgets and Intents
When adding interactivity, the widget’s button can’t invoke code in your app, but it does have to rely on a public API exposed by your app: App Intents.
App intents expose actions of your app to the system so that iOS can perform them when needed. For example, when the user interacts with the widget button.
Furthermore, you can also use the same App Intent for Siri and Shortcuts.
Adding the Intent
Firstly, add the intent method that your button will invoke when pressed. Open TaskIntent.swift and add the perform()
method to TaskIntent.
func perform() async throws -> some IntentResult {
UserDefaultStore().stepTask(taskEntity.task)
return .result()
}
The AppIntent
‘s perform()
method is the one called when an Intent is invoked. This method takes the selected task as input and calls a method in the store to progress this task.
Please note that UserDefaultStore
is part of both the app and the widget extension so that you can reuse the same code in both targets. :]
Next, open TaskStore.swift and add a definition of the stepTask(_:)
method to the protocol TaskStore.
protocol TaskStore {
func loadTasks() -> [TaskItem]
func saveTasks(_ tasks: [TaskItem])
func stepTask(_ task: TaskItem)
}
Then, add the stepTask(_:)
method to UserDefaultStore
. This method loads all the tasks contained in the store, finds the required task, calls the task’s progress()
method and saves it back in the store.
func stepTask(_ task: TaskItem) {
var tasks = loadTasks()
guard let index = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[index].progress()
saveTasks(tasks)
}
Finally, add an empty stepTask(_:)
method to SampleStore
to make it compliant with the new protocol definition.
func stepTask(_ task: TaskItem) {}
Binding Everything Together
Now that you’ve defined the intent action, you can add the button to the Trask status widget.
Open TaskStatusWidget.swift, go to TaskStatusWidgetEntryView
that represents the view of the widget, and add the following lines after the Text
component.
Button(intent: TaskIntent(taskEntity: TaskEntity(task: entry.task))) {
Image(systemName: "plus")
}
.disabled(entry.task.isCompleted)
.bold()
.tint(.primary)
- You used the new iOS 17 SwiftUI API for a button that calls intent on pressing it,
Button(intent:,_:)
. - For the button view, you use an SF symbol with the plus image.
- You create a
TaskIntent
for the selected task the widget refers to. When the user taps the button, iOS calls the Intent’sperform()
method, which ultimately increases the task’s status. - After the intent is called, iOS updates the widget timeline by calling its
timeline(for:in:)
method to update the widget’s view with the new status. - Disable the button once the task is completed.
Make some more room for the button by reducing the text size of the two labels in the widget to subheadline
and title
, respectively.
VStack {
Label(entry.task.name, systemImage: entry.task.category.systemImage)
.font(.subheadline)
Text(entry.task.status.description)
.font(.title)
Build and run the project, and start interacting with your widget.
Animating the Changes
When iOS updates the widget’s view, it automatically animates the changes.
For a general view, the default animation might look good, but if you want your widgets to shine, I suggest you invest in mastering this piece of the system.
Fixing the Animation For a Digit
In this specific case, the main thing changing when the user taps the plus button is the task’s step number, which is increased by 1.
You’ll use the animation for number changes, but you need to refactor the widget a little bit before doing that. Specifically, you need to make it explicit to SwiftUI what’s going to change so it can render the changes properly.
Open TaskStatusWidget.swift and replace the Text component of TaskStatusWidgetEntryView with the following Stack
.
HStack {
Text(entry.task.status.progress.formatted())
.contentTransition(.numericText())
Text("of \(entry.task.status.targetCount.formatted())")
}
.font(.title)
You separated the task’s status from the total number of steps to introduce the contentTransition(_:)
attribute just on the text that’s going to change when the user taps the button.
Furthermore, you use the .numericText()
transition, specific for changes in views containing numbers.
Build and run the project and check the difference.
Adding More Animations
For “TODO” tasks that have just two states, a better representation would be to replace the status description with a simpler TODO and DONE.
Since the label is now a text, you use a different content transition, such as .interpolate
that interpolates between the two strings.
Instead of adding more complexity to the main widget view, refactor the code to have a dedicated StatusView
for the widget main label.
Open TaskStatusWidget.swift and add the following code.
struct StatusView: View {
@State var task: TaskItem
var body: some View {
if task.isToDo {
Text(task.isCompleted ? "DONE" : "TODO")
.contentTransition(.interpolate)
} else {
HStack {
Text(task.status.progress.formatted())
.contentTransition(.numericText())
Text("of \(task.status.targetCount.formatted())")
}
}
}
}
The code above checks if the task is a TODO item and, based on the result, uses a number representation as before or the TODO/DONE text.
Finally, replace the HStack
in the TaskStatusWidgetEntryView view using the StatusView
you have just added.
struct TaskStatusWidgetEntryView: View {
var entry: TaskTimelineProvider.Entry
var body: some View {
VStack {
...
StatusView(task: entry.task)
.font(.title)
.bold()
.frame(maxWidth: .infinity, maxHeight: .infinity)
...
}
}
Build and run the project, add a widget for a TODO item, and check the result.
Keeping the App in Sync
Now, run the app. Step one task using the widget and open the app afterward.
If the user updates a task via the widget and then opens the app, the task status in the app is not updated.
The widget updated the store properly, but the app was neither informed nor updated to reload the refreshed data from the store.
To fix this issue, you have several techniques and approaches.
- A simple approach would be to reload the data from the store each time the app returns to the foreground.
- Another approach, based on notifications, where the widget notifies the app of new data through a notification, allows the app to refresh in the background if necessary.
In this case, you can follow the first approach. Open AppMain.swift and reload the tasks using the .onChange(of:)
view modifier on the scenePhase
environment variable.
@Environment(\.scenePhase)
var scenePhase
var body: some Scene {
WindowGroup {
ContentView(taskList: taskList)
...
.onChange(of: scenePhase) {
guard scenePhase == .active else { return }
taskList.reloadTasks()
}
...
The scenePhase
environment variable instructs to reload the tasks when the app comes to the foreground.
Lastly, add the method reloadTasks()
to TaskList.swift.
func reloadTasks() {
tasks = taskStore.loadTasks()
}
Build and run the project, and check the app now runs fine.