Apple introduced Widgets in iOS 14 and brought a fresh look that changed our phone’s home screens. The framework evolved through the years, adding a powerful means of keeping users updated with their data.
iOS 17 takes widgets to the next level by introducing interactivity. Users can now interact with your app in a new, innovative way that wasn’t possible before.
By making your app’s essential actions available in a widget, your users have a more convenient and engaging way to interact with your app.
In this tutorial, you’ll add interactive widgets to the Trask app using SwiftUI.
If you’re thinking about learning SwiftUI, widgets’ simple views are a great place to start with.
This tutorial covers the following topics.
- What interactive widgets are and how they work.
- How to create interactive widgets with a SwiftUI animation.
- Different types of interactive widgets that you can create.
- Best practices for designing and developing interactive widgets.
Although there are no strict prerequisites, a basic knowledge of SwiftUI and WidgetKit might be helpful. Anyway, don’t worry, you’ll have a quick recap to start off on the right foot.
Getting Started
Download the starter project by clicking the Download Materials button at the top or bottom of this tutorial. Open the starter project (Trask.xcodeproj) in the Starter folder.
Build and run the project, and you should see the Trask initial screen.
Trask is a general tracker app that tracks different tasks/things/habits during the day, such as the number of glasses of water, medicine, yoga, and so on.
The first time you launch the app, Trask creates some sample data so you can see the different types of tasks you can create.
- Task with multiple steps.
- “TODO” task with just one step.
When tapping the plus button, the app advances the task, and once it reaches its target, it passes in the done state.
The user can delete tasks by swiping left on them and can add new ones using the button at the bottom of the View.
Recapping WidgetKit
Before you get into the hot topic of this tutorial, familiarize yourself with some basic concepts on WidgetKit to build common terminology for the rest of the tutorial.
Adding an iOS Widget
Trask comes with a static widget to follow the status of a selectable task.
Add an instance of the widget to see how it looks.
- Build and run the project.
- Minimize the app.
- Long press on an empty area of the screen.
- Then tap the + button, search for Trask, and select the widget available.
You’re now ready to jump into the code structure to see how it works.
Widget Code Structure
The TraskWidgets folder of the starter project contains all the files related to the widget.
Making the Widget Interactive
Data Sharing With The App
Timeline Provider
Updating Widgets
Types of Interactivity
Widgets and Intents
Adding the Intent
As you may see, the widget code is contained in a separate Xcode target, and iOS runs the widget in a process different from the app. This detail might seem subtle, but it’s crucial when considering that the app and the widget need to share the same data. The widget code can’t simply call some functions in the app target. Among the different possibilities, Trask uses a UserDefault
store on an App Group container shared between the app and the widget.
Timeline is a key concept of Widgets. To preserve battery and system resources, iOS doesn’t constantly run your widget. Instead, it asks your timeline provider to generate a series of timeline entries to render your widget and present it at the right time.
Your TaskTimelineProvider
defines three methods.
As said above, the timeline(for:in:)
returns the array of entries at the specified time, but what happens after the last widget view is presented? Enter the widget update strategy!
When returning the timeline of entries, you also provide one strategy for updating the timeline. You may choose between the three options below.
In our case, the Trask timeline provider returns the .never
policies since there is no need for the widget to update its view. The only way to update the status of a task is through the app when the user taps to step a task…until the next chapter. :]
Wow…that was a long warmup, but now you’re ready to add interaction to the Trask status widget.
Starting with iOS 17, iPadOS 17 and macOS 14, Apple allows two main ways of interactivity with your widget: buttons and toggles.
As the first improvement, you’ll add a step button to the Trask Status Widget so users can progress their favorite tasks without opening the app.
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.
Firstly, add the intent method that your button will invoke when pressed. Open TaskIntent.swift and add the perform()
method to TaskIntent.
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.
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.
Finally, add an empty stepTask(_:)
method to SampleStore
to make it compliant with the new protocol definition.
-
-
TaskIntent
is an intent conforming to theWidgetConfigurationIntent
protocol. Here, the intent allows the task selection in the Edit Widget menu. -
TaskStatusWidget
is the actual widget. Four parts compose the widget file.-
TaskTimelineProvider
specifies when iOS should refresh the widget screen. -
TaskEntry
represents the model of the widget view. It contains a date iOS uses to update the widget view with the task item. -
TaskStatusWidgetEntryView
defines the widget view using SwiftUI. It contains a timeline entry as a parameter, and it should lay out the widget based on this parameter value. -
TaskStatusWidget
binds all the parts together within aWidgetConfiguration
. - Finally,
TraskWidgetBundle
declares all the extension’s widgets.
-
placeholder(in:)
should return some sample data to render the placeholder UI while waiting for the widget to be ready. SwiftUI applies a redaction effect to this view. -
snapshot(for:in:)
provides the data to render the widget in the gallery presented when choosing a widget. -
timeline(for:in:)
is the main method that returns the timeline entries to present at the specified time.
-
.atEnd
recomputes the timeline after the last date in the timeline passes. -
.after(_:)
specifies approximately when to request a new timeline. -
.never
tells the system to never recompute the timeline. The app will prompt WidgetKit when a new timeline is available.
- Buttons are suitable to represent an action on the widget content.
- Toggles better identify a binary actionable state on/off. Such as our TODO task status.
Note: On a locked device, buttons and toggles are inactive, and iOS doesn’t perform actions until the user unlocks his device.
func perform() async throws -> some IntentResult { UserDefaultStore().stepTask(taskEntity.task) return .result() }
protocol TaskStore { func loadTasks() -> [TaskItem] func saveTasks(_ tasks: [TaskItem]) func stepTask(_ task: TaskItem) }
func stepTask(_ task: TaskItem) { var tasks = loadTasks() guard let index = tasks.firstIndex(where: { $0.id == task.id }) else { return } tasks[index].progress() saveTasks(tasks) }
-
-
-
TaskIntent
is an intent conforming to theWidgetConfigurationIntent
protocol. Here, the intent allows the task selection in the Edit Widget menu. -
TaskStatusWidget
is the actual widget. Four parts compose the widget file.-
TaskTimelineProvider
specifies when iOS should refresh the widget screen. -
TaskEntry
represents the model of the widget view. It contains a date iOS uses to update the widget view with the task item. -
TaskStatusWidgetEntryView
defines the widget view using SwiftUI. It contains a timeline entry as a parameter, and it should lay out the widget based on this parameter value. -
TaskStatusWidget
binds all the parts together within aWidgetConfiguration
. - Finally,
TraskWidgetBundle
declares all the extension’s widgets.
-
placeholder(in:)
should return some sample data to render the placeholder UI while waiting for the widget to be ready. SwiftUI applies a redaction effect to this view. -
snapshot(for:in:)
provides the data to render the widget in the gallery presented when choosing a widget. -
timeline(for:in:)
is the main method that returns the timeline entries to present at the specified time.
-
.atEnd
recomputes the timeline after the last date in the timeline passes. -
.after(_:)
specifies approximately when to request a new timeline. -
.never
tells the system to never recompute the timeline. The app will prompt WidgetKit when a new timeline is available.
- Buttons are suitable to represent an action on the widget content.
- Toggles better identify a binary actionable state on/off. Such as our TODO task status.
Note: On a locked device, buttons and toggles are inactive, and iOS doesn’t perform actions until the user unlocks his device.
func perform() async throws -> some IntentResult { UserDefaultStore().stepTask(taskEntity.task) return .result() }
protocol TaskStore { func loadTasks() -> [TaskItem] func saveTasks(_ tasks: [TaskItem]) func stepTask(_ task: TaskItem) }
func stepTask(_ task: TaskItem) { var tasks = loadTasks() guard let index = tasks.firstIndex(where: { $0.id == task.id }) else { return } tasks[index].progress() saveTasks(tasks) }
-
-
TaskTimelineProvider
specifies when iOS should refresh the widget screen. -
TaskEntry
represents the model of the widget view. It contains a date iOS uses to update the widget view with the task item. -
TaskStatusWidgetEntryView
defines the widget view using SwiftUI. It contains a timeline entry as a parameter, and it should lay out the widget based on this parameter value. -
TaskStatusWidget
binds all the parts together within aWidgetConfiguration
. - Finally,
TraskWidgetBundle
declares all the extension’s widgets.
-
placeholder(in:)
should return some sample data to render the placeholder UI while waiting for the widget to be ready. SwiftUI applies a redaction effect to this view. -
snapshot(for:in:)
provides the data to render the widget in the gallery presented when choosing a widget. -
timeline(for:in:)
is the main method that returns the timeline entries to present at the specified time.
-
.atEnd
recomputes the timeline after the last date in the timeline passes. -
.after(_:)
specifies approximately when to request a new timeline. -
.never
tells the system to never recompute the timeline. The app will prompt WidgetKit when a new timeline is available.
- Buttons are suitable to represent an action on the widget content.
- Toggles better identify a binary actionable state on/off. Such as our TODO task status.
Note: On a locked device, buttons and toggles are inactive, and iOS doesn’t perform actions until the user unlocks his device.
func perform() async throws -> some IntentResult {
UserDefaultStore().stepTask(taskEntity.task)
return .result()
}
protocol TaskStore {
func loadTasks() -> [TaskItem]
func saveTasks(_ tasks: [TaskItem])
func stepTask(_ task: TaskItem)
}
func stepTask(_ task: TaskItem) {
var tasks = loadTasks()
guard let index = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[index].progress()
saveTasks(tasks)
}
func stepTask(_ task: TaskItem) {}