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
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

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.

Widgets and Intent.

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) {}
Note: Intent represents a whole word by itself. For all the details, check the tutorial Creating Shortcuts with App Intents.

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)
  1. You used the new iOS 17 SwiftUI API for a button that calls intent on pressing it, Button(intent:,_:).
  2. For the button view, you use an SF symbol with the plus image.
  3. You create a TaskIntent for the selected task the widget refers to. When the user taps the button, iOS calls the Intent’s perform() method, which ultimately increases the task’s status.
  4. 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.
  5. 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.

Interactive Widget First Version.

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.

Interactive Widget with Animated Numbers.

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.

Interactive Widget with Animated Text.

Keeping the App in Sync

Now, run the app. Step one task using the widget and open the app afterward.

Interactive Widget Sync Issue.

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.

Interactive Widget Sync Issue Fixed.