На WWDC 2024 Apple представила — виджеты в Control Center для iOS 18. Это новшество позволяет разработчикам добавлять собственные виджеты в новое место в приложении: Control Center, Home Screen. Но можем ли мы делать кастомную вёрстку в новых виджетах? Или подтянуть данные из сети?

В этой статье разберёмся с новыми виджетами, ответим на вопросы выше. А в завершении статьи вы найдёте сниппеты кода, чтобы быстро добавить виджеты в свой проект.

Виджет в Control Center
Виджет в Control Center

Как добавить виджет в проект

Поскольку виджет считается отдельным приложением, необходимо добавить таргет extension в проект:

  1. Добавляем таргет

File → New → Target → Во вкладке iOS выбираем Widget Extension

Добавление виджета в проект
Добавление виджета в проект
  1. Конфигурируем виджет

Называем таргет любым названием и нажимаем галочки в пунктах:

  • Include Control

  • Include Configuration App Intent

  1. Готово. Виджет добавлен в проект.

Какие виджеты доступны

Control Widgets доступны в двух вариациях: Button и Toggle. Такое ограничение было представлено Apple с iOS 17, когда были представлены интерактивные виджеты (подробнее можно почитать в статье — Пишем интерактивный виджет)

Виджет с Button позволяет выполнить любой action через AppIntents. Чаще всего это будет редирект в приложение в определённый функционал по url_scheme. Далее в разделе с AppIntents будет приведён пример редиректа.

Виджет с Toggle позволяет переключаться между состояниями: true или false (вкл или выкл). Очевидный пример с фонариком. Apple также приводит пример с таймером, где можно настроить виджет через динамичную конфигурацию, задав время таймеру и через виджет уже запускать таймер.

В обоих случаях взаимодействие осуществляется через AppIntent. Разберёмся с библиотекой подробнее.

AppIntents

Библиотека App Intents позволяет расширить функционал приложения, интегрируя с экосистемными фичами приложения: Siri, Spotlight, Shortcuts app. После настройки intent'a (или намерения) и добавления в экосистему, Apple начнёт рекомендовать пользователям удобные шорткаты для использования.

Для продолжения работы с виджетом, нам хватит понимания, что App Intent — это action, для Button экшн открывает основное приложение, для Toggle — переключает состояния виджета. Выполнение action в intent происходит в асинхронном методе 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()
    }
}

Через AppIntents в будущем можно будет сделать настройку, чтобы через Siri выполнять нужные действия в приложении, например, переключить Toggle: включить или выключить фонарик.

Redirect в приложение

Во время реализации виджета, может возникнуть вопрос, как перенаправить пользователя из виджета iOS в другое приложение. Ведь способ с UIApplication.shared.open(url) и UIApplication.shared.open(url) — не подойдёт, поскольку мы не имеем доступ к shared инстансу приложения из виджет‑таргета.

На помощь приходит OpensIntent , интент доступный с iOS 16, цель которого — перенаправить action с url‑схемой в приложение или перейти по диплинку.

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://")!))
    }
}

Этот AppIntent отлично подойдёт для Button виджета.

Для Toggle виджета будет необходим SetValueIntent, по которому будет определяться локальное состояние true или 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 виджет

Виджет‑кнопка состоит из трёх составляющих:

  • Конфигурация виджета. В нашем случае StaticControlConfiguration

  • Вью виджета — ControlWidgetButton, в которой можно указать image, title, subtitle

  • AppIntent, по которому будет происходить переход в приложение

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

Button виджет также как и Toggle виджет может иметь 3 размерные вариации: small, medium, large. Делать какую‑то кастомную вёрстку — нельзя, Apple не предоставила сторонним разработчикам такого функционала.

После добавления конфигурации и AppIntens, который мы написали выше, получаем виджет, по нажатию на который происходит в переход в приложение.

Button Control виджет
Button Control виджет

P.S. На момент написания статьи переход в приложение работал нестабильно на Xcode 16 Beta 2 и iOS 18.0. В будущих версиях скорее всего будет исправлено.

Toggle виджет

Напишем Toggle‑виджет, который будет сохранять своё состояние вкл / выкл в UserDefaults, чтобы это состояние можно было шарить между основным приложением.

Виджет‑переключатель состоит из четырёх составляющих:

  • Конфигурация виджета. В нашем случае StaticControlConfiguration

  • Вью виджета — ControlWidgetToggle, в которой можно указать image, title, subtitle. А также получить состояние isOn

  • AppIntent, по которому будет происходить переключение состояния виджета

  • ControlValueProvider — проводник значения. С возможностью подтягивать состояние виджета из сети и возможности шарить на другие девайсы

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 виджет
Toggle Control виджет

Изменить цвет иконки можно с помощью модификатора .tint(.purple) . Аналогичное изменение цвета не работает для Control виджета с Button.

Интересным элементом Toggle виджета является ControlValueProvider. Данный протокол имеет:

  • var previewValue  — захардкоженное значения для превью в галерее виджетов

  • func currentValue() async throws -> Self.Value  — асинхронный метод

С помощью метода появляется возможность хранить состояние виджета в сети. На примере, которые демонстрирует Apple, состояние подтягивается из сети и шарится между всеми девайсами Apple, что создаёт ощущение бесшовности в использовании экосистемы.

ControlCenter

Взаимодействие с Control виджетами из основного приложения осуществляется через ControlCenter (аналог WidgetCenter для обычных виджетов).

Перезагрузка виджетов

Для перезагрузки виджета доступно два метода: reloadControls(ofKind: ) reloadAllControls()

import WidgetKit

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

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

Получение добавленных виджетов

Также, через Control Center можно получить текущие добавленные виджеты у пользователя.

import WidgetKit

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

ControlInfo  — структура в которой содержатся данные:

  • kind — идентификатор виджета (настраивается при создании виджета)

  • pushInfo — опциональное свойство об пуш‑информации (содержит пуш‑токен)

Помимо свойств, существует метод, через который можно получить AppIntent для конкретного виджета.

Данные о добавленных виджетах могут быть полезны для сбора аналитики.

Итоги

Apple уже четвёртый год подряд, начиная с iOS 14, добавляет что‑то новое в виджеты, показывая тем самым, что этот функционал для них важен и про него не будут забывать. Control виджеты доступны в трёх новых местах iOS:

  • Control Center

  • Lock Screen (Bottom Bar)

  • Кнопка Action (начиная с iPhone 15)

Однако, в iOS 18 добавлен урезаный функционал для Control виджетов: без возможности верстать виджет с кастомным View, как нативный виджет фонарика или Music Control Center.

Надеюсь всё больше компаний обратит на функционал виджетов в iOS, для удобства привёл 2 сниппета для быстрого добавления виджетов.

Сниппет кода виджета

Далее приложены 2 сниппета кода: для Button и Toggle, чтобы любой желающий мог скопировав эти сниппеты быстро добавить в проект Control виджеты.

(Как добавить сам таргет‑виджета, написано в этой статье выше)

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)
        }
    }
}

Полезные материалы

Комментарии (0)