WWDC Quick Look 💓 By SwiftGGTeam
Bring widgets to life

Bring widgets to life

Watch original video

Highlight

iOS 17 turns widgets from read-only displays into interactive controls: developers can trigger AppIntents directly on widgets through Button and Toggle, and use new animation APIs to create smooth content transitions when timeline entries change.


Core Content

Widgets before: look but don’t touch

When WidgetKit introduced widgets in iOS 14, their role was simple: display information. Tapping a widget only opened the app—the widget itself did not respond to any gestures. If you wanted users to complete an action on the widget—such as checking off a todo or logging a cup of coffee—you had to bring them into the app.

For developers, this meant widgets were just display windows, not functional entry points. Every action followed the flow of “tap widget → open app → act → return.”

What changed in iOS 17: widgets support interaction

iOS 17 added two key capabilities to WidgetKit:

  1. Interactive controls: Widgets can include Button and Toggle. Tapping them runs an AppIntent directly without opening the app.
  2. Animated transitions: When timeline entries change, you can customize animations and content transitions instead of an abrupt swap.

Together, these turn widgets from an “information board” into a “mini app control panel.”

The mechanism behind it all: AppIntents

The core of widget interactivity is the AppIntents framework. Button and Toggle initializers accept an AppIntent object; when the user taps, the system runs that intent’s perform() method. After perform() returns, WidgetKit automatically refreshes the widget UI.

This design means widgets do not manage their own state—they trigger your app’s business logic and then display the latest result.


Detailed Content

Custom widget backgrounds: containerBackground

iOS 17 provides the new containerBackground modifier specifically for widget backgrounds (03:54):

.containerBackground(for: .widget) {
    Color.cosmicLatte
}

Key points:

  • containerBackground(for: .widget) is a new WidgetKit modifier that replaces the old background approach
  • The system adjusts how the background renders based on widget placement (Home Screen, Lock Screen, StandBy)
  • In StandBy mode, the system adds appropriate corner radius and margins to the background

Preview widget animations: #Preview macro

Xcode 15’s new #Preview macro lets you preview widget timeline animations directly (04:22):

#Preview(as: WidgetFamily.systemSmall) {
    CaffeineTrackerWidget()
} timeline: {
    CaffeineLogEntry.log1
    CaffeineLogEntry.log2
    CaffeineLogEntry.log3
    CaffeineLogEntry.log4
}

Key points:

  • #Preview(as:) specifies the widget size, supporting systemSmall, systemMedium, systemLarge, and more
  • The timeline: closure returns multiple timeline entries; the preview automatically cycles through them
  • Transitions play your defined animations and effects without testing on a device

Smooth transitions for changing values: contentTransition

Numeric changes in widgets can use .contentTransition(.numericText(value:)) for smooth scrolling number effects (05:41):

struct TotalCaffeineView: View {
    let totalCaffeine: Measurement<UnitMass>

    var body: some View {
        VStack(alignment: .leading) {
            Text("Total Caffeine")
                .font(.caption)

            Text(totalCaffeine.formatted())
                .font(.title)
                .minimumScaleFactor(0.8)
                .contentTransition(.numericText(value: totalCaffeine.value))
        }
        .foregroundColor(.espresso)
        .bold()
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

Key points:

  • .contentTransition(.numericText(value:)) takes a Double value; when it changes, the text animates with a number-scrolling transition
  • totalCaffeine.value comes from the Measurement<UnitMass> value property
  • This transition applies only to numeric text and is more informative than a default fade
  • Great for counts, amounts, measurements, and similar values

Push transitions between entries: transition and animation

When one timeline entry switches to another, use .transition and .animation to control the effect (06:21):

struct LastDrinkView: View {
    let log: CaffeineLog

    var body: some View {
        VStack(alignment: .leading) {
            Text(log.drink.name)
                .bold()
            Text("\(log.date, format: Self.dateFormatStyle) · \(caffeineAmount)")
        }
        .font(.caption)
        .id(log)
        .transition(.push(from: .bottom))
        .animation(.smooth(duration: 1.8), value: log)
    }

    var caffeineAmount: String {
        log.drink.caffeine.formatted()
    }

    static var dateFormatStyle = Date.FormatStyle(
        date: .omitted, time: .shortened)
}

Key points:

  • .id(log) tells SwiftUI that when log changes, this is a different view and should trigger a transition
  • .transition(.push(from: .bottom)) defines the transition: the old entry pushes up while the new one enters from the bottom
  • .animation(.smooth(duration: 1.8), value: log) sets the curve and duration, triggering only when log changes
  • duration: 1.8 gives a longer animation that feels more natural on a widget

Manually reload widget timelines

After data changes in your app, notify WidgetKit to refresh the widget (09:18):

WidgetCenter.shared.reloadTimelines(ofKind: "LocationForecast")

Key points:

  • WidgetCenter.shared.reloadTimelines(ofKind:) refreshes widgets of the specified kind
  • Call this after your app modifies data the widget depends on
  • Suitable after changing data in an AppIntent’s perform() method, though Button/Toggle AppIntents trigger refresh automatically

Interactive controls with AppIntent: Button

This is the core mechanism for widget interactivity. First, define an AppIntent (13:06):

import AppIntents

struct LogDrinkIntent: AppIntent {
    static var title: LocalizedStringResource = "Log a drink"
    static var description = IntentDescription("Log a drink and its caffeine amount.")

    @Parameter(title: "Drink", optionsProvider: DrinksOptionsProvider())
    var drink: Drink

    init() {}

    init(drink: Drink) {
        self.drink = drink
    }

    func perform() async throws -> some IntentResult {
        await DrinksLogStore.shared.log(drink: drink)
        return .result()
    }
}

Key points:

  • The AppIntent protocol comes from the AppIntents framework; you need import AppIntents
  • static var title and description are used for Siri and Shortcuts
  • Properties marked with @Parameter accept values from outside; optionsProvider supplies the list of options
  • perform() is where business logic runs; DrinksLogStore.shared.log(drink:) is a custom data store method
  • After perform() returns .result(), WidgetKit automatically reloads the widget

Then bind the intent to a Button in the widget view (15:10):

struct LogDrinkView: View {
    var body: some View {
        Button(intent: LogDrinkIntent(drink: .espresso)) {
            Label("Espresso", systemImage: "plus")
                .font(.caption)
        }
        .tint(.espresso)
    }
}

Key points:

  • Button(intent:label:) is a new iOS 17 initializer
  • Tapping the button runs LogDrinkIntent’s perform() without opening the app
  • Use .tint() to set the button color
  • A single widget can have multiple buttons, each bound to a different intent

Reducing perceived latency: invalidatableContent

After the user taps a button and triggers an AppIntent, there is a gap between data updating and the widget refresh. .invalidatableContent() lets the widget mark content as about to change first (16:28):

struct TotalCaffeineView: View {
    let totalCaffeine: Measurement<UnitMass>

    var body: some View {
        VStack(alignment: .leading) {
            Text("Total Caffeine")
                .font(.caption)

            Text(totalCaffeine.formatted())
                .font(.title)
                .minimumScaleFactor(0.8)
                .contentTransition(.numericText(value: totalCaffeine.value))
                .invalidatableContent()
        }
        .foregroundColor(.espresso)
        .bold()
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

Key points:

  • .invalidatableContent() marks that this view’s content will change before new data arrives
  • While the AppIntent runs, marked views show a transitional state instead of freezing
  • Works best combined with .contentTransition(.numericText(value:))
  • Ideal when you need to give users immediate feedback

Core Takeaways

  1. Build a coffee/water logging widget: Put a few drink buttons (espresso, latte, water) on the widget; tapping logs to HealthKit or local storage, with total intake shown via number-scrolling animation. Why it’s worth doing: this session’s demo is a caffeine tracker using Button + AppIntent + contentTransition together. How to start: define a Drink type and LogDrinkIntent, create drink buttons with Button(intent:), and call WidgetCenter.shared.reloadTimelines in perform().

  2. Build a Pomodoro widget: Show the current task and remaining time; use Toggle to start/pause the timer, with .contentTransition(.numericText(value:)) for countdown animation. Why it’s worth doing: Toggle is the second interactive control widgets support, and countdown is a natural fit for numeric transitions. How to start: create a TimerIntent, bind timer state with Toggle(isOn:intent:label:), and start/pause a background timer in perform().

  3. Build a habit check-in widget: List today’s habits, each with a Button to mark complete; completed items exit with .transition(.push(from: .bottom)). Why it’s worth doing: habit tracking is a high-frequency widget use case, and animated transitions make check-ins feel smoother. How to start: create a MarkHabitIntent per habit, list habits with ForEach, and pair each with a Button.

  4. Build a quick-payment widget: Place common amount buttons (breakfast ¥15, coffee ¥25, lunch ¥40); tapping logs to your expense app, with monthly total shown via number animation. Why it’s worth doing: the biggest friction in expense tracking is opening the app to enter amounts—a widget button reduces it to one tap. How to start: create RecordExpenseIntent, accept amount and category via @Parameter, and write to a local database in perform().

  5. Build a smart home control widget: Use Toggle for lights and Button for scene modes; match button colors to device state with .tint(). Why it’s worth doing: home control is a classic widget interaction scenario—users can switch devices without opening the app. How to start: create a ToggleDeviceIntent per device, execute at the HomeKit or MQTT layer, and use .invalidatableContent() to handle command latency.


Comments

GitHub Issues · utterances