Interactive Widgets — iOS 17

As an iOS developer, there are several exciting features and capabilities to explore within the platform. Among them, widgets stand out as a favorite for me. Widgets have become an integral part of the iOS and macOS experience, and with the latest capabilities introduced in SwiftUI, they are now even more powerful. In this article, we will explore how to bring widgets to life with interactivity and animations, making them more engaging and visually appealing. We will dive into the details of how animations work with widgets and showcase the new Xcode Preview API, which enables quick iteration and customization. Additionally, we will explore how to add interactivity to widgets using familiar controls like Button and Toggle, leveraging the power of App Intents. So let’s get started!

lyvennitha sasikumar
7 min readJun 22, 2023

Interactivity in Widgets

Widgets are rendered in a separate process, and their view code only runs during archiving. To make widgets interactive, we can use controls like Button and Toggle. However, since SwiftUI won’t execute closures or mutate bindings in the app’s process space, we need a way to represent actions that can be executed by the widget extension. App Intents provide a solution for this, allowing us to define actions that can be invoked by the system. By importing SwiftUI and AppIntents, we can use Button and Toggle initializers that accept an AppIntent as an argument to execute the desired action.

Now we’ve gonna create widgets to the existing project.

Name it accordingly. And note, disable the two checkboxes

Now am gonna rewrite the existing code with checklist along with a button.

struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry( checkList: Array(ModelData.shared.items.prefix(3)))
}

func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(checkList: Array(ModelData.shared.items.prefix(3)))
completion(entry)
}

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
//var entries: [SimpleEntry] = []

// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let data = Array(ModelData.shared.items.prefix(3))
let entries = [SimpleEntry(checkList: data)]

let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}

struct SimpleEntry: TimelineEntry {
var date: Date = .now

var checkList: [ProvisionModel]
}

struct InteractiveWidgetEntryView : View {
var entry: Provider.Entry

var body: some View {
VStack(alignment: .leading, spacing: 5.0) {
Text("My List")
if entry.checkList.isEmpty{
Text("You've bought all🏆")
}else{
ForEach(entry.checkList) { item in
HStack(spacing: 5.0){

Image(systemName: item.isAdded ? "checkmark.circle.fill":"circle")
.foregroundColor(.green)


VStack(alignment: .leading, spacing: 5){
Text(item.itemName)
.textScale(.secondary)
.lineLimit(1)
Divider()
}
}
}
}
}
.containerBackground(.fill.tertiary, for: .widget)
}
}

struct InteractiveWidget: Widget {
let kind: String = "InteractiveWidget"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
InteractiveWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}

The provided code defines a widget using SwiftUI in an iOS or macOS app. Let’s break down the code and explain each part:

  1. Provider: This struct conforms to the TimelineProvider protocol, which is responsible for providing data to the widget. It contains three functions:
  • placeholder(in:): This function returns a placeholder entry that represents the widget's appearance when it's first added. It creates a SimpleEntry with a checklist array derived from ModelData.shared.items.
  • getSnapshot(in:completion:): This function generates a snapshot entry representing the current state of the widget. It creates a SimpleEntry with a checklist array derived from ModelData.shared.items.
  • getTimeline(in:completion:): This function generates the timeline of entries for the widget. It creates an array of SimpleEntry instances with a checklist array derived from ModelData.shared.items and returns a timeline with those entries.
  1. SimpleEntry: This struct conforms to the TimelineEntry protocol and represents a single entry in the widget's timeline. It contains a date property representing the entry's date and a checkList property, which is an array of ProvisionModel items.
  2. InteractiveWidgetEntryView: This struct defines the view hierarchy for displaying the widget's entry. It takes an entry of type Provider.Entry as input. Inside the body property, it creates a VStack with alignment and spacing settings. It displays a title, and depending on whether the checkList array is empty, it shows a message or iterates through the array to display each item's information.
  3. InteractiveWidget: This struct defines the widget itself. It conforms to the Widget protocol and specifies the kind of the widget. It provides a StaticConfiguration with a Provider instance as the data provider and an InteractiveWidgetEntryView as the view for each entry. It also sets a display name and description for the widget.
  4. Preview: This code block is used for previewing the widget's appearance during development. It creates a preview for the widget in the .systemSmall size, providing a SimpleEntry instance as the entry.

Overall, this code sets up a widget that displays a checklist using the SwiftUI framework. The widget’s data is provided by the Provider struct, and the entry's view is defined by the InteractiveWidgetEntryView struct. The InteractiveWidget struct configures the widget and provides a preview for development purposes.

And button action!

Apple Introduced AppIntents for this!

I’ve created view model and app intent.

struct ProvisionModel: Identifiable{
var id: String = UUID().uuidString
var itemName: String
var isAdded: Bool = false

}

class ModelData{
static let shared = ModelData()

var items: [ProvisionModel] = [.init(
itemName: "Orange"
), .init(
itemName: "Cheese"
), .init(
itemName: "Bread"
), .init(
itemName: "Rice"
), .init(
itemName: "Sugar"
), .init(
itemName: "Oil"
), .init(
itemName: "Chocolate"
), .init(
itemName: "Corn"
)]
}

The provided code includes the definition of two data structures: ProvisionModel and ModelData. Here's an explanation of each:

ProvisionModel: This struct represents a provision item in a checklist. It conforms to the Identifiable protocol, which requires it to have a unique identifier. It has the following properties:

  • id: A string property that holds a unique identifier generated using UUID. Each ProvisionModel instance will have a different id.
  • itemName: A string property representing the name of the provision item.
  • isAdded: A boolean property indicating whether the item has been added to the checklist. It is initialized with a default value of false.
  • ModelData: This class acts as a data store and singleton, providing shared access to the provision items. It has the following components:
  • shared: A static property of type ModelData representing the shared instance of the class. It follows the singleton pattern, allowing access to the same instance across the app.
  • items: An array property that holds instances of ProvisionModel representing the provision items. The array is initialized with a predefined set of items, each initialized with a specific itemName. The ModelData.shared instance provides access to this array.

Overall, this code sets up the data model for a checklist app. The ProvisionModel struct defines the properties for each provision item, including its unique identifier and whether it has been added to the checklist. The ModelData class provides shared access to the list of provision items and follows the singleton pattern to ensure consistency in accessing and modifying the data.

Now its time to appIntent!

struct MyActionIntent: AppIntent{

static var title: LocalizedStringResource = "Toggle Task State"
@Parameter(title: "Task ID")
var id: String
init(){

}

init(id: String){
self.id = id
}

func perform() async throws -> some IntentResult {
if let index = ModelData.shared.items.firstIndex(where: { $0.id == id }) {
ModelData.shared.items[index].isAdded.toggle()

let itemToRemove = ModelData.shared.items[index]
ModelData.shared.items.remove(at: index)

DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
ModelData.shared.items.removeAll(where: { $0.id == itemToRemove.id })
}

print("Updated")
}

return .result()
}
}

The provided code defines a struct called MyActionIntent that conforms to the AppIntent protocol. This struct represents an intent for toggling the state of a task in a checklist app. Here's an explanation of its components:

  1. title (static property): This property represents the title of the action intent. It is of type LocalizedStringResource, which is a localized string resource used for localization purposes.
  2. id (property decorator): This property is decorated with @Parameter and represents the ID of the task that needs to be toggled.
  3. init(): This is a default initializer for the struct. It doesn't perform any specific initialization.
  4. init(id: String): This initializer allows you to create an instance of MyActionIntent with a specific task ID.
  5. perform() (method): This method is required by the AppIntent protocol and performs the action associated with the intent. Here's a breakdown of its implementation:
  • It checks if there is a task in the ModelData.shared.items array with a matching ID to the one provided in the intent.
  • If a match is found, it toggles the isAdded property of the task using the toggle() method. This changes the state of the task.
  • It then creates a local variable itemToRemove to store the task that was toggled.
  • The task is removed from the ModelData.shared.items array using the remove(at:) method and the index where the task was found.
  • After a delay of 2 seconds, the itemToRemove is removed from the ModelData.shared.items array using removeAll(where:) and a closure that checks for matching IDs.
  • Finally, “Updated” is printed to the console.
  1. return .result(): This statement returns an instance of IntentResult, indicating the completion of the intent without any specific result value.

Overall, this code defines an intent that performs the action of toggling the state of a task in the checklist. It accesses the shared instance of ModelData to find and modify the task based on the provided ID.

Now its time to replace the image with AppIntents

 Button(intent: MyActionIntent(id: item.id)) {
Image(systemName: item.isAdded ? "checkmark.circle.fill":"circle")
.foregroundColor(.green)
}
.buttonStyle(.plain)

And its time to run!

Output!

Happy Coding :) !

--

--