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!
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:
Provider
: This struct conforms to theTimelineProvider
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 aSimpleEntry
with a checklist array derived fromModelData.shared.items
.getSnapshot(in:completion:)
: This function generates a snapshot entry representing the current state of the widget. It creates aSimpleEntry
with a checklist array derived fromModelData.shared.items
.getTimeline(in:completion:)
: This function generates the timeline of entries for the widget. It creates an array ofSimpleEntry
instances with a checklist array derived fromModelData.shared.items
and returns a timeline with those entries.
SimpleEntry
: This struct conforms to theTimelineEntry
protocol and represents a single entry in the widget's timeline. It contains adate
property representing the entry's date and acheckList
property, which is an array ofProvisionModel
items.InteractiveWidgetEntryView
: This struct defines the view hierarchy for displaying the widget's entry. It takes anentry
of typeProvider.Entry
as input. Inside thebody
property, it creates aVStack
with alignment and spacing settings. It displays a title, and depending on whether thecheckList
array is empty, it shows a message or iterates through the array to display each item's information.InteractiveWidget
: This struct defines the widget itself. It conforms to theWidget
protocol and specifies thekind
of the widget. It provides aStaticConfiguration
with aProvider
instance as the data provider and anInteractiveWidgetEntryView
as the view for each entry. It also sets a display name and description for the widget.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 aSimpleEntry
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 usingUUID
. EachProvisionModel
instance will have a differentid
.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 offalse
.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 typeModelData
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 ofProvisionModel
representing the provision items. The array is initialized with a predefined set of items, each initialized with a specificitemName
. TheModelData.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:
title
(static property): This property represents the title of the action intent. It is of typeLocalizedStringResource
, which is a localized string resource used for localization purposes.id
(property decorator): This property is decorated with@Parameter
and represents the ID of the task that needs to be toggled.init()
: This is a default initializer for the struct. It doesn't perform any specific initialization.init(id: String)
: This initializer allows you to create an instance ofMyActionIntent
with a specific task ID.perform()
(method): This method is required by theAppIntent
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 thetoggle()
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 theremove(at:)
method and the index where the task was found. - After a delay of 2 seconds, the
itemToRemove
is removed from theModelData.shared.items
array usingremoveAll(where:)
and a closure that checks for matching IDs. - Finally, “Updated” is printed to the console.
return .result()
: This statement returns an instance ofIntentResult
, 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!