Exploring WidgetKit: Creating Your First Control Widget in iOS 18 and SwiftUI

Sometimes, there were days where I wished I could update the Lock Screen’s torch and camera widgets with my own. Looks like that day has come. With iOS 18, we can do just that thanks to Control Widgets.

I have played around with widgets, and when controls were announced at WWDC 2024, the session related to it was the first one I watched on my flight back home from SFO.

The architecture is so similar to other widgets that I had a simple control running in fifteen minutes for my app, Fusion, to directly play Apple Music's discovery station.

‎Fusion: Custom Music Stations
‎Looking for tunes that speak to your heart? Dive into Fussion, where we pair you with Apple Music’s stations to bring you melodies that feel like finding a new friend! - Personal Touch: Tune into your likes to make every playlist feel like home! - Discover Daily: Unwrap new songs and artists that a…

In this post, we will talk about Controls, and how to get started with the first one!

What are Control Widgets?

Control Widgets are new in iOS 18 and iPadOS. They let us add quick actions from the app to places like the Control Center or the bottom Lock Screen widgets. They are shortcuts for simple tasks, like turning on the phone's flashlight, or an action like screen recording.

0:00
/0:24

The ControlWidget Protocol

We start with the ControlWidget protocol. Similar to the Widget protocol we are used to working with:

@available(iOS 18.0, *)
@MainActor protocol ControlWidget {
  
  @MainActor @preconcurrency init()
  
  associatedtype Body : ControlWidgetConfiguration

  @ControlWidgetConfigurationBuilder @MainActor @preconcurrency var body: Self.Body { get }
}
  1. It has a body property. This is where we describe what the widget looks like and does.
  2. The body returns a ControlWidgetConfiguration.

ControlWidgetConfiguration

The ControlWidgetConfiguration defines the body of the control and customise it.

@available(iOS 18.0, *)
@MainActor public protocol ControlWidgetConfiguration {
  associatedtype Body : ControlWidgetConfiguration

  @ControlWidgetConfigurationBuilder @MainActor @preconcurrency var body: Self.Body { get }
}

There are a few things we can do with it:

  • Set a description for the widget
  • Give the widget a display name
  • Ask the user to set it up right away
  • Handle push notifications

ControlWidgetButton

ControlWidgetButton is used for creating buttons in Control Widgets. It is designed for simple, immediate actions and adopts the system's appearance for consistency.

@MainActor struct ControlWidgetButton<Label, ActionLabel>: ControlWidgetTemplate where Label: View, ActionLabel: View {
  @MainActor var body: some ControlWidgetTemplate { get }

  typealias Body = some ControlWidgetTemplate
}

extension ControlWidgetButton {
  @MainActor init<Action>(action: Action, @ViewBuilder label: @escaping () -> Label, @ViewBuilder actionLabel: @escaping (Bool) -> ActionLabel) where Action: AppIntent
}


We can use it like:

ControlWidgetButton(action: SomeIntent()) {
  Label("Button Text", systemImage: "symbol.name")
}

Creating Your First Control Widget

Let us walk through creating a simple Control Widget using the ControlWidgetButton. This example will create a button to play Apple Music's discovery station.

#if swift(>=6.0)
@available(iOS 18, *)
struct PlayDiscoveryStation: ControlWidget {
  var body: some ControlWidgetConfiguration {
    StaticControlConfiguration(kind: "com.rudrankriyam.fussion") {
      ControlWidgetButton(action: PlayDiscoveryStationIntent()) {
        Label("Play Discovery Station", systemImage: "radio.fill")
      }
    }
  }
}
#endif

In this code:

  • We define a PlayDiscoveryStation struct conforming to ControlWidget.
  • We use StaticControlConfiguration to set up our widget.
  • ControlWidgetButton creates the button with a label and an associated action.

Handling the Action

The action is defined in a separate AppIntent:

import MusadoraKit

@available(iOS 18, *)
struct PlayDiscoveryStationIntent: AppIntent {
  static var title: LocalizedStringResource = "Play Apple Music Discovery Station"
  static var description = IntentDescription("Plays the Apple Music Discovery station in the control center.")

  init() {
  }

  func perform() async throws -> some IntentResult {
    try await WidgetMusicPlayer.playDiscoveryStation()
    return .result()
  }
}

This intent defines what happens when the button is tapped - in this case, playing the discovery station using the WidgetMusicPlayer class.

The perform() function is marked as async, allowing it to wait for the playback to begin without blocking. Once the playback starts, it simply returns .result(), returning nothing, as the action of starting playback is the intended result.

Integrating Control Widgets with Existing Widget Bundles

If we already have a WidgetBundle for the app, we update it to include the new Control Widgets for iOS 18:

struct FussionWidgetsBundle: WidgetBundle {
  var body: some Widget {
    DiscoveryStationWidgets()
    PersonalStationWidgets()
    SongStationWidget()

    if #available(iOSApplicationExtension 18, *) {
      PlayDiscoveryStation()
    }
  }
}

This allows the app to support both older iOS versions with existing widgets and iOS 18 with the new Control Widgets.

Here is how the Control Widget looks in action in the Control Center:

0:00
/0:21

And here is how to set it up on the lockscreen:

0:00
/0:14

Moving Forward

As we get more comfortable with basic Control Widgets, we can explore more of what the new APIs have to offer. We have ControlWidgetToggle for features that have a clear on/off state or use ControlValueProvider for up-to-date information.

Happy widgeting!