Пишем виджет в Control Center (iOS 18)

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

  • Extend your app’s controls across the system

  • Пишем интерактивный виджет

  • Интерактивные виджет-подборки в Иви (iOS)

  • Репозиторий с кодом статьи

Источник: https://habr.com/ru/articles/827868/


Интересные статьи

Интересные статьи

5-9 июня 2023 года состоялась ежегодная презентация Apple WWDC23, на которой было представлено много интересных новшеств для iOS-разработчиков. Одну из таких фич — интерактивные виджеты, мы рассмотрим...
Туториалы делятся на две больших категории: либо "как нарисовать сову", либо подробно расписанные тысячи шагов в формате "напиши туториал для дурака - и только дурак захочет его читать".Как какой из д...
Привет, Хабр. Меня зовут Дмитрий Гусаков. Я тимлид команды QA в компании Arenadata. Наша команда занимается тестированием компонентов Arenadata Enterprise Data Platform, в том числе тестированием орке...
В данном посте мы коснемся написания action'ов и reducer'а. Для начала рассмотрим типичный 'flow', в котором мы выполняем следующие операции (далее переработаем все так, чтобы наш код отвечал при...
Язык JavaScript родом из раннего веба. Сначала на нём писали простые скрипты, которые «оживляли» страницы сайтов. Теперь же JS превратился в полноценный язык программирования, который можно испол...