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.

1 (1) · 1 Review

Download materials
Save for later
Share

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's main 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.

Note: To learn how to add Widgets, and for a deep dive into Widgets, please take a look at the amazing tutorial Getting Started With Widgets.

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.

  1. Build and run the project.
  2. Minimize the app.
  3. Long press on an empty area of the screen.
  4. Then tap the + button, search for Trask, and select the widget available.

Add Trask Widget.

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.

Data Sharing.

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.

Widgets and Intent.

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 the WidgetConfigurationIntent 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 a WidgetConfiguration.
      • Finally, TraskWidgetBundle declares all the extension’s widgets.
      Note: For all the details on how to create an App Group, check the above mentioned Getting Started With Widgets tutorial.
      • 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 the WidgetConfigurationIntent 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 a WidgetConfiguration.
    • Finally, TraskWidgetBundle declares all the extension’s widgets.
    Note: For all the details on how to create an App Group, check the above mentioned Getting Started With Widgets tutorial.
    • 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 a WidgetConfiguration.
  • 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: For all the details on how to create an App Group, check the above mentioned Getting Started With Widgets tutorial.
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) {}
Note: Intent represents a whole word by itself. For all the details, check the tutorial Creating Shortcuts with App Intents.