Writing a widget in Control Center (iOS 18)

At WWDC 2024, Apple introduced widgets in Control Center for iOS 18. This innovation allows developers to add their own widgets to a new place in the application: Control Center, Home Screen. But can we do custom layout in the new widgets? Or pull data from the network?

In this article, we will look at the new widgets, answer the questions above. And at the end of the article, you will find code snippets to quickly add widgets to your project.

Widget in Control Center

Widget in Control Center

How to add a widget to a project

Since the widget is considered a separate application, you need to add the extension target to the project:

  1. Adding a target

File → New → Target → In the iOS tab, select Widget Extension

Adding a widget to a project

Adding a widget to a project

  1. Configuring the widget

We name the target with any name and click the checkboxes in the items:

  1. Done. The widget has been added to the project.

What widgets are available

Control Widgets are available in two variations: Button and Toggle. This limitation was introduced by Apple with iOS 17, when interactive widgets were introduced (you can read more in the article – Writing an interactive widget)

A widget with a Button allows you to perform any action via AppIntents. Most often, this will be a redirect to the application in a certain functionality by url_scheme. Further in the section with AppIntents, an example of a redirect will be given.

A widget with Toggle allows you to switch between states: true or false (on or off). An obvious example is with a flashlight. Apple also gives an example with a timer, where you can configure the widget through a dynamic configuration, setting the time for the timer and then starting the timer through the widget.

In both cases, interaction is carried out via AppIntent. Let's look at the library in more detail.

AppIntents

The App Intents library allows you to expand the functionality of the application by integrating with the ecosystem features of the application: Siri, Spotlight, Shortcuts app. After setting up an intent (or intention) and adding it to the ecosystem, Apple will begin to recommend convenient shortcuts to users for use.

To continue working with the widget, it is enough for us to understand that App Intent is an action, for Button the action opens the main application, for Toggle it switches the states of the widget. The execution of the action in the intent occurs in an asynchronous method func perform() async

struct HelloWorldIntent: AppIntent {
    
    static var title: LocalizedStringResource = "Hello to the World"
    
    func perform() async throws -> some IntentResult {
        print("Hello world")
        return .result()
    }
}

In the future, AppIntents will be able to be configured to allow Siri to perform desired actions in the app, such as switching Toggle to turn the flashlight on or off.

Redirect to application

When implementing a widget, the question may arise of how to redirect the user from an iOS widget to another application. After all, the method UIApplication.shared.open(url) And UIApplication.shared.open(url) – will not work because we do not have access to shared application instance from the widget target.

Comes to the rescue OpensIntent an intent available since iOS 16, the purpose of which is to redirect an action with a url scheme to an application or follow a deep link.

struct OpenAppIntent: AppIntent {
    
    static var title: LocalizedStringResource = "Открывает приложение"
  
    static var isDiscoverable: Bool = false
    static var openAppWhenRun: Bool = true
    
    func perform() async throws -> some IntentResult & OpensIntent {
        // url-scheme в соответствии с вашим приложением
        .result(opensIntent: OpenURLIntent(URL(string: "fichaApp://")!))
    }
}

This AppIntent would be perfect for a Button widget.

For Toggle widget you will need SetValueIntentby which the local state will be determined true or false.

struct ToggleAppIntent: SetValueIntent {
    
    @Parameter(title: "Running")
    var value: Bool
    
    static var title: LocalizedStringResource = "Toggle Control Widget"
    
    static var isDiscoverable: Bool = false
    static var openAppWhenRun: Bool = true
    
    func perform() async throws -> some IntentResult {
        // Будет меняться в зависимости от значения.
        print(value)
        return .result()
    }
}

Button widget

The button widget consists of three components:

  • Widget configuration. In our case StaticControlConfiguration

  • Widget view — ControlWidgetButton, in which you can specify image, title, subtitle

  • AppIntent, which will be used to navigate to the application

struct ShortcutButtonControlWidget: ControlWidget {
    
    let kind: String = "widget.ShortcutControlButtonWidget"
    
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: kind) {
            ControlWidgetButton(action: OpenAppIntent()) {
                Label("Ficha", image: "ficha-logo-control")
            }
        }
    }
}

The Button widget, like the Toggle widget, can have 3 size variations: small, medium, large. It is impossible to make any custom layout, Apple has not provided third-party developers with such functionality.

After adding the configuration and AppIntens, which we wrote above, we get a widget, which, when clicked, takes you to the application.

Button Control widget

Button Control widget

P.S. At the time of writing, the transition to the application worked unstable on Xcode 16 Beta 2 and iOS 18.0. In future versions, this will most likely be fixed.

Toggle widget

Let's write a Toggle widget that will save its on/off state in UserDefaults so that this state can be shared between the main application.

The switch widget consists of four components:

  • Widget configuration. In our case StaticControlConfiguration

  • Widget view — ControlWidgetToggle, in which you can specify image, title, subtitle. And also get the isOn state

  • AppIntent, which will trigger the widget state switching

  • ControlValueProvider — a value conductor. With the ability to pull the widget state from the network and the ability to share it with other devices

struct Provider: ControlValueProvider {
    
    typealias Value = Bool
    
    var previewValue: Value { false }
    
    func currentValue() async throws -> Value {
        ToggleStateManager.shared.isOn = !ToggleStateManager.shared.isOn
        let value = ToggleStateManager.shared.isOn
        return value
    }
}

struct ShortcutToggleControlWidget: ControlWidget {
    
    let kind: String = "widget.ShortcutControlToggleWidget"
    
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: kind,
                                   provider: Provider()) { isRunning in
            ControlWidgetToggle("Ficha",
                                isOn: isRunning,
                                action: ToggleAppIntent(),
                                valueLabel: { isOn in
                Label(isOn ? "true" : "false", image: "ficha-logo-control")
            })
            .tint(.purple)
        }
    }
}
Toggle Control widget

Toggle Control widget

You can change the color of the icon using a modifier .tint(.purple) . Similar color change does not work for Control widget with Button.

An interesting element of the Toggle widget is ControlValueProviderThis protocol has:

  • var previewValue — hardcoded values ​​for preview in widget gallery

  • func currentValue() async throws -> Self.Value – asynchronous method

The method makes it possible to store the widget state in the network. In the example that Apple demonstrates, the state is pulled from the network and shared between all Apple devices, which creates a sense of seamlessness in using the ecosystem.

ControlCenter

Interaction with Control widgets from the main application is carried out through ControlCenter (analogous to WidgetCenter for regular widgets).

Reloading widgets

There are two methods available to reset the widget: reloadControls(ofKind: ) reloadAllControls()

import WidgetKit

// Перезагружает контрол виджет с определённым kind.
// В данном случае перезагрузит Toggle виджет
ControlCenter.shared.reloadControls(ofKind: "ShortcutControlToggleWidget")

// Перезагружает все контрол виджеты.
ControlCenter.shared.reloadAllControls()

Getting added widgets

Also, through Control Center you can get the currently added widgets from the user.

import WidgetKit

// Получает текущие добавленные control виджеты у пользователя
let controls: [ControlInfo] = try await ControlCenter.shared.currentControls()

ControlInfo — a structure that contains data:

  • kind — widget identifier (configured when creating the widget)

  • pushInfo — optional property about push information (contains push token)

In addition to the properties, there is a method through which you can get AppIntent for a specific widget.

Data about added widgets can be useful for collecting analytics.

Results

For the fourth year in a row, Apple has been adding something new to widgets since iOS 14, showing that this functionality is important to them and will not be forgotten. Control widgets are available in three new places in iOS:

However, iOS 18 adds limited functionality for Control widgets: no ability to create a widget with a custom View, like the native flashlight widget or Music Control Center.

I hope more and more companies will turn to the widget functionality in iOS, for convenience I have provided 2 snippets for quickly adding widgets.

Widget code snippet

Below are 2 code snippets attached: for Button and Toggle, so that anyone can copy these snippets and quickly add Control widgets to the project.

(How to add the target widget itself is described in this article above)

Button Control Widget
import WidgetKit
import SwiftUI
import AppIntents

@available(iOSApplicationExtension 18.0, *)
struct OpenAppIntent: AppIntent {
    
    static var title: LocalizedStringResource = "Button Control Widget"
    
    static var isDiscoverable: Bool = false
    static var openAppWhenRun: Bool = true
    
    func perform() async throws -> some IntentResult & OpensIntent {
        let defaultIntent = OpenURLIntent()
        guard let url = URL(string: "fichaApp://") else { return .result(opensIntent: defaultIntent) }
        return .result(opensIntent: OpenURLIntent(url))
    }
}

@available(iOSApplicationExtension 18.0, *)
struct ShortcutButtonControlWidget: ControlWidget {
    
    let kind: String = "widget.ShortcutControlButtonWidget"
    
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: kind) {
            ControlWidgetButton(action: OpenAppIntent()) {
                Label("Ficha", image: "ficha-logo-control")
            }
        }
    }
}
Toggle Control Widget
import WidgetKit
import SwiftUI
import AppIntents

public class ToggleStateManager {
    
    static let shared = ToggleStateManager()
    
    private let key = "widget.ShortcutControlToggleWidget"
    public var isOn: Bool {
        get {
            guard let boolValue = UserDefaults.standard.object(forKey: self.key) as? Bool else { return false }
            return boolValue
        }
        set { UserDefaults.standard.set(newValue, forKey: self.key) }
    }
    
}

@available(iOSApplicationExtension 18.0, *)
struct Provider: ControlValueProvider {
    
    typealias Value = Bool
    
    var previewValue: Value { false }
    
    func currentValue() async throws -> Value {
        ToggleStateManager.shared.isOn = !ToggleStateManager.shared.isOn
        let value = ToggleStateManager.shared.isOn
        return value
    }
}

struct ToggleAppIntent: SetValueIntent {
    
    @Parameter(title: "Running")
    var value: Bool
    
    static var title: LocalizedStringResource = "Toggle Control Widget"
    
    static var isDiscoverable: Bool = false
    static var openAppWhenRun: Bool = true
    
    func perform() async throws -> some IntentResult {
        ToggleStateManager.shared.isOn = value
        return .result()
    }
}

@available(iOSApplicationExtension 18.0, *)
struct ShortcutToggleControlWidget: ControlWidget {
    
    let kind: String = "widget.ShortcutControlToggleWidget"
    
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: kind,
                                   provider: Provider()) { isRunning in
            ControlWidgetToggle("Some title",
                                isOn: isRunning,
                                action: ToggleAppIntent(),
                                valueLabel: { isOn in
                Label(isOn ? "true" : "false", image: "image-name")
            })
            .tint(.purple)
        }
    }
}

Useful materials

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *