Interactive Widgets in SwiftUI
Widgets are a great way to show users relevant information on their home screen.
Previous to iOS 17, we could only display static data. However, in WWDC23 Apple announced the possibility to add interactivity in widgets. This could be seen as an insignificant feature, but we can leverage it to allow our users to perform simple actions directly from the widget.
So, If you’re interested in improving your app’s engagement, here is how to use it.
We'll use a simple widget created for our Coffee Shop Demo App. The widget shows the last coffee the user ordered and a button to repeat the order.
The Widget
I'll not be going into details on how to create a Widget, but here's a simple guide.
If you already have your widget implemented, you can skip this section.
1. Add a Widget Extension Target
2. Define Widget's content
Implement TimeEntry
protocol and define any data you want to display.
import WidgetKit
struct CoffeeOrder: TimelineEntry {
var date: Date
let coffees: Int
let lastCoffee: String
}
Implement TimelineProvider
protocol. The provider produces a timeline of TimelineEntry
and will tell the widget when to render new data.
struct CoffeeProvider: TimelineProvider {
typealias Entry = CoffeeOrder
func placeholder(in context: Context) -> CoffeeOrder {
// Return a placeholder
}
func getSnapshot(in context: Context, completion: @escaping (CoffeeOrder) -> Void)
// Snapshot is used to display information in the Widget Gallery
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CoffeeOrder>) -> Void) {
// The timeline to be used in the widget
}
}
Craft Widget's UI (the fun part 😄).
struct LastOrderView: View {
let order: CoffeeOrder
init(_ order: CoffeeOrder) {
self.order = order
}
var body: some View { ... }
}
3. Add Widget's configuration
struct CoffeeShopWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.blorenzo.coffeeshop.last-order",
provider: CoffeeProvider()) { order in
LastOrderView(order)
}
.supportedFamilies([.systemSmall])
}
}
@main
struct CoffeeShopWidgetBundle: WidgetBundle {
var body: some Widget {
CoffeeShopWidgetLiveActivity()
CoffeeShopWidget()
}
}
Adding interactivity
Ok, we have our widget, and we are showing some information. How do we do to perform an action?
We need to use AppIntents
. An AppIntent
is basically a configuration that allows us to execute a specific action outside our app's scope.
struct RepeatOrderIntent: AppIntent {
static var title: LocalizedStringResource = "Repeat Last Coffee"
init(){}
func perform() async throws -> some IntentResult {
OrdersManager.shared.repeatLastOrder()
return .result()
}
}
Inside the perform()
method, we can execute the logic we want, and return a result. In our case, we just tell our OrdersManager
to repeat the last order.
- For our demo, this is enough. However, we can configure a lot more in an
AppIntent
. - The
AppIntent
must be created in the app's main target. - If you're curious, check Apple's documentation regarding
AppIntent
for more information.
Now that we have our AppIntent
ready, we need a way to use it from our widget.
The only components that support interactivity so far are Buttons
and Toggls
. Each one has a new initializer available to use with an AppIntent
.
extension Button {
public init<I: AppIntent>(
intent: I,
@ViewBuilder label: () -> Label
)
}
extension Toggle {
public init<I: AppIntent>(
isOn: Bool,
intent: I,
@ViewBuilder label: () -> Label
)
}
So, to wrap up, we just need to update our widget's UI and add a new button with the AppIntent
we created.
struct LastOrderView: View {
let order: CoffeeOrder
init(_ order: CoffeeOrder) {
self.order = order
}
var body: some View {
VStack(alignment: .leading) {
Text("10 coffees = 1 free")
.font(.system(size: 12, weight: .ultraLight))
HStack {
HStack {
Text("\(order.coffees)")
.font(.system(size: 32, weight: .heavy))
.contentTransition(.numericText(value: Double(order.coffees)))
Text("/10")
.font(.system(size: 32, weight: .heavy))
}
Spacer()
Image(systemName: "cup.and.saucer.fill")
.font(.system(size: 24))
}
Text("Your last coffee")
.font(.system(size: 9, weight: .bold))
Text(order.lastCoffee)
.font(.system(size: 16, weight: .thin))
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
Button(intent: RepeatOrderIntent()) {
Label("Repeat", systemImage: "plus")
.font(.caption)
}
.tint(Color.black)
.foregroundColor(.white)
}
.containerBackground(for: .widget) {
Color.widgetBackground.opacity(0.5)
}
}
}