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

Виджеты в новом обличии появились в 2020 году вместе с выходом iOS 14 (HomeScreen widgets). За это время Apple выпустила больше семейств виджетов, а также добавила их на LockScreen в iPhone и iPad. Но интерактивность появилась впервые в iOS 17.

В этой статье разберёмся из чего состоит интерактивный виджет: формирование Timeline, как работает интерактивность через библиотеку AppIntents, а затем напишем свой первый интерактивный виджет.

Из чего состоит виджет

Прежде чем писать виджет, необходимо разобраться из каких компонентов он состоит:

  • TimelineEntry

  • EntryView

  • TimelineProvider

TimelineEntry

TimelineEntry — сущность таймлайна с датой показа в виджете. При создании entry необходимо указывать дату, в какой момент времени она будет отображена в виджете.

Здесь уже задействована бизнес‑логика виджета. Допустим, хотим показать 3 сущности виджета, которые будут меняться каждый час. И спустя 4 часа таймлайн будет перезагружен.

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

struct WidgetEntry: TimelineEntry {
    
    let word: String

    // Обязательный параметр с датой
    let date: Date
}

EntryView

EntryView — SwiftUI view, содержащая в себе TimelineEntry. После загрузки таймлайна в EntryView будете передан TimelineEntry.

struct WidgetEntryView: View {
    
    var entry: WidgetEntry
    
    var body: some View {
        Text(entry.word)
    }
}

TimelineProvider

TimelineProvider отвечает за формирование Timeline с подготовленными данными

Timeline состоит из серий данных, которые подменяют друг друга спустя установленное время. Также таймлайн полностью перезагружается спустя какое‑то установленное время. Все тайминги устанавливаются разработчиками.

struct WidgetProvider: TimelineProvider {

    // Заглушка во время формирования основного таймлайна в getTimeline
    func placeholder(in context: Context) -> WidgetEntry {
      
        WidgetEntry(word: "Hello world!", date: Date())
    }

    // Показ в галерее виджетов.
    func getSnapshot(in context: Context, 
                     completion: @escaping (WidgetEntry) -> ()) {
      
        let entry = WidgetEntry(word: "Hello world!", date: Date())
        completion(entry)
    }

    // Формирование основного таймлайна
    func getTimeline(in context: Context, 
                     completion: @escaping (Timeline) -> ()) {

        let hour: TimeInterval = 60.0 * 60.0
        let entries: [WidgetEntry] = [
            .init(word: "word 1", date: Date() + hour),
            .init(word: "word 2", date: Date() + hour * 2),
            .init(word: "word 3", date: Date() + hour * 3)
        ]

        let reloadDate: TimeInterval = Date() + hour * 5)
        let timeline = Timeline(entries: entries, policy: .after(reloadDate))
        completion(timeline)
    }
}

Метод placeholder(Context) вызывается в момент загрузки основного таймлайна виджета: когда данные ещё не успели прогрузится (например, когда ходим по API в сеть), но показать какую‑то заглушку нужно.

Снепшот‑метод getSnapshot(Context, (WidgetEntry) -> ()) вызывается в момент появления виджета в галерее виджетов. Здесь могут быть либо захардкоженные данные,  либо данные загруженные из бд, сети и тд.

И самый главный метод getTimeline(in context: Context, (Timeline) -> ()) вызывается, когда виджет добавлен пользователем на рабочий стол / экран блокировки / standBy. Здесь находится core логика виджета и по совместимости происходит формирование основного таймлайна виджета.

Работа таймлайна

Работа таймлайна

Работа таймлайна схожа с работой скрипта: раз в какое‑то время запускается, ходит в API / БД, формирует слепки данных и далее показывается виджет. Timeline подменяет слепки данных по установленным датам.

В примере на картинке выше установлены даты: Date 1, Date 2, Date 3. В боевом коде указывается конкретное время, когда одна сущность должна подменить другую. Тип даты — TimeInterval

Пример, как это может выглядеть:

let hour: TimeInterval = 60.0 * 60.0        // 1 час

let date1: TimeInterval = Date()            // Сейчас
let date2: TimeInterval = Date() + hour     // Спустя 1 час
let date2: TimeInterval = Date() + 2 * hour // Спустя 2 часа

Далее, для всех сущностей задаём даты и формируем таймлайн:

func getTimeline(in context: Context, 
                 completion: @escaping (Timeline) -> ()) {
  
    // Час
    let hour: TimeInterval = 60.0 * 60.0

    // Сущности entry
    let entries: [WidgetEntry] = [
        .init(word: "word 1", date: Date() + hour),
        .init(word: "word 2", date: Date() + hour * 2),
        .init(word: "word 3", date: Date() + hour * 3)
    ]

    // Перезагружаем виджет через 5 часов
    let reloadDate: TimeInterval = Date() + hour * 5.0)

    // Формируем таймлайн
    let timeline = Timeline(entries: entries, policy: .after(reloadDate))
    completion(timeline)
}

Собираем виджет воедино

Все 3 сущности действуют сообща в структуре, конформяющей протоколу Widget. Тело виджета принимает в себя конфигурацию StaticConfiguration, с заданными связями предыдущих сущностей.

struct MyWidget: Widget {
    
    let kind: String = "MyWidget"
    
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: WidgetProvider()) { entry in
            WidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Заголовок виджета")
        .description("Краткое описание виджета")
        .supportedFamilies([.systemSmall])
        .contentMarginsDisabled()
    }
}

В конфигурации виджета задаются настройки, описывающие внешний вид и свойства виджета. Разберёмся какие основные параметры существуют.

Параметры галереи

Чтобы задать название виджета и описание, необходимо определить свойства: configurationDisplayName, description . После задания параметров виджет приобретёт название и описание.

Типы семейств (family)

Семейство — family, описывает размерные вариации конкретного виджета.

system family доступны для iPhone, iPad, macOS:

  • systemSmall (начиная с 14 iOS, в том числе medium, large)

  • systemMedium

  • systemLarge

  • systemExtraLarge (для iPad, начиная с 15 iOS; с 14 macOS)

systemSmall виджет может располагаться на LockScreen в iPad с iOS 17, и в StandBy в iPhone.

Семейство accessory может располагаться только на HomeScreen в iPhone, iPad:

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

Конфигурации виджета

При инициализации StaticConfiguration, указывается параметр kind. Kind — это идентификатор‑строка, по которому можно будет перезагружать конкретный виджет из основного приложения через WidgetCenter.

В WidgetKit, помимо StaticConfiguration, существует также динамическая конфигурация AppIntentConfiguration. Такой конфиг даёт возможность пользователям настраивать виджет под себя. Сами параметры для настройки добавляют разработчики по своему усмотрению.

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

Сделали mvp виджета

Итого, после всех вставок с кодом и объяснений виджет выглядит:

MVP виджет

MVP виджет

В галерее виджетов (1) отображается надпись Hello world, из‑за того что в методе func placeholder(in context: Context) -> WidgetEntry в WidgetProvider мы захардкодили эти данные.

А на изображении (2) отображаются данные сформированного таймлайна в методе func getTimeline(Context, (Timeline) -> ())

Интерактивность в виджете

AppIntents

AppIntents — фреймворк для создания экшнов для разрабатываемого приложения с возможностью интеграции с разными частями экосистемы от Apple: Siri, Spotlight, Shortcuts.

Логика этого фреймворка следующая: создаём AppIntent action, который выполняет целевое действие. Через внешние экосистемные приложения можно эти экшны запускать, но также можно использовать внутри приложения.

Например, можно сделать AppIntent, который добавляет задачу в приложении: пользователь вводит title задачи, а затем нажимает кнопку добавить задачу. Имея такой экш, можно будет попросить Siri добавить задачу вместе с надиктованным описанием.

Как выглядит самый простой AppIntent экшн:

import AppIntents

struct NothingAction: AppIntent {
    
    static var title: LocalizedStringResource = "Do nothing"
    static var description: IntentDescription? = "Not description"
    
    func perform() async throws -> some IntentResult {
        return .result()
    }
}

Поздравляю, мы написали самый простой экш, который ничего не делает. Можем через приложение Shortcuts добавить его на рабочий стол и ничего не делать через приложение!

AppIntent, ничего не делает.

AppIntent, ничего не делает.

Обязательные параметры: static var title и func perform() async throws

Title будет отображаться в приложении Shortcuts, по нему можно различать разные действия, комбинировать их с другими приложениями. Также можно добавить этот экшн в индексацию Spotlight, и для понимания Siri.

Метод perform() активируется в момент произведения действия с экшном. Метод является асинхронным, что позволяет выполнять трудоёмкие действия с приложением, дожидаясь окончания их выполнения.

AppIntents в интерактивном виджете

Через библиотеку AppIntents делается интерактивность в виджете.

В iOS 17 существует 2 типа ui элементов (только Swift UI), с которым работает интерактивность в виджете:

  • Button

  • Toggle

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

Именно в связке экшн AppIntent + Button или Toggle работает интерактивность в виджете.

Добавим NothingAction в виджет c Button и Toggle:

struct WidgetEntryView: View {
    
    var entry: WidgetEntry
    var isOn = false
    
    var body: some View {
        VStack {
            Button(intent: NothingAction()) {
                Text("Click")
            }
            Toggle(isOn: isOn, intent: NothingAction()) {
                Text("Switch")
            }
            Text(entry.word)
        }
    }
}

Получаем виджет с интерактивными элементами:

Button и Toggle

Button и Toggle

Можете задаться вопросом как убрать стиль по дефолту с Button с AppIntent?
— Дефолтные стили можно убрать, дописав модификатор .buttonStyle(.plain)

Давайте наведём чуть‑чуть красоты в кнопке:

struct WidgetEntryView: View {
    
    var entry: WidgetEntry
    
    var body: some View {
        VStack {
            Button(intent: NothingAction()) {
                Text(entry.word)
                    .foregroundColor(.white)
                    .font(.system(size: 24.0, weight: .bold, design: .rounded))
            }
            .buttonStyle(.plain)
            .padding(.all(6.0))
            .background(Color.purple)
            .cornerRadius(8.0, corners: .allCorners)
        }
    }
}

Получаем следующий виджет:

Красивая кнопушка

Красивая кнопушка

Со всеми вводными данными закончили, приступим к написанию виджета.

Интерактивный виджет

Итак, перед нами стоит задача:

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

В виджете должно быть 2 кнопки: перейти к следующему слову, добавить слово в избранное.

Взаимодействие с БД

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

Мостиком для передачи данных будет служить хранилище UserDefaults.

Обмен данными через App Groups

Обмен данными через App Groups

Чтобы передать данные между разными таргетами, необходимо создать в приложении App Groups с заданным строковым идентификатором контейнера.

efcd4e95c52cca60f9a049db8ce55cd5.png

В примере, идентификатором является строка — group.Revolvetra.Inc.Ficha

После задания ключа между основным и виджет таргетами образуется мостик для передачи данных. Далее напишем саму модель для передачи и обёртку над UserDefaults хранилищем.

Модель с данными для шеринга между приложениями:

/// Модель хранилища.
public struct Word: Codable, Hashable {
    
    // MARK: - Properties
    
    /// Заголовок.
    public let title: String
    
    /// Перевод.
    public let translation: String
    
    // MARK: - Init
    
    /// Инит.
    public init(title: String,
                translation: String) {
        self.title = title
        self.translation = translation
    }
}

Допишем UserDefaults враппер для кодирования и декодирования json объектов:

public class UserDefaultCodable {
    
    // MARK: - Public properties
    
    public var key: String
    public var defaultValue: Value
    public var container: UserDefaults
    
    // MARK: - Init
    
    public init(key: String,
                defaultValue: Value,
                container: UserDefaults = .standard) {
        self.key = key
        self.defaultValue = defaultValue
        self.container = container
    }
    
    // MARK: - Public value
    
    public var wrappedValue: Value {
        get {
            let decoder = JSONDecoder()
            guard let object = container.object(forKey: key) as? Data,
                  let decodedValue = try? decoder.decode(Value.self, from: object)
            else {
                return defaultValue
            }
            return decodedValue
        }
        set {
            let encoder = JSONEncoder()
            guard let encoded = try? encoder.encode(newValue) else { return }
            container.set(encoded, forKey: key)
        }
    }
}

И хранилище, закрытое протоколом (чтобы в дальнейшем могли покрыть тестами код):

public protocol WordsStorageProtocol {
    
    /// Хранящиеся слова.
    var words: [Word] { get set }
    
    /// Перевёрнута ли карточка.
    var isFlipped: Bool { get set }
    
    /// Переворачивает карточку.
    func flip()
    
    /// Свайпает карточку.
    func swipeCard()
    
    /// Обнулить состояния хранилища.
    func reset()
}

public final class WordsStorage: WordsStorageProtocol {
    
    // MARK: - Public properties
    
    public var words: [Word] {
        get { _words.wrappedValue }
        set { _words.wrappedValue = newValue }
    }
    
    public var isFlipped: Bool {
        get { _isFlipped.wrappedValue }
        set { _isFlipped.wrappedValue = newValue }
    }
    
    private var _words: UserDefaultCodable<[Word]>
    
    private var _isFlipped: UserDefaultCodable
    
    // MARK: - Init
    
    public init(key: String) {
        let container = UserDefaults(suiteName: key) ?? UserDefaults.standard
        self._words = UserDefaultCodable<[Word]>(
            key: "UD_Widget_words",
            defaultValue: [],
            container: container
        )
        self._isFlipped = UserDefaultCodable(
            key: "UD_Widget_isFlipped",
            defaultValue: false,
            container: container
        )
    }
    
    // MARK: - Public methods
    
    public func flip() {
        self.isFlipped = !self.isFlipped
    }
    
    public func swipeCard() {
        self.isFlipped = false
        self.words.removeFirst()
    }
    
    public func reset() {
        self.isFlipped = false
        self.words = []
    }
}

Итак, на данный момент хранилище готово, логика работы с хранилищем будет следующая:

  • В основном приложении получаем случайные слова на английском и складываем в UserDefaults хранилище

  • Перезагружаем Timeline виджета

  • Получаем в TimelineProvider виджета данные из хранилища и отображаем их

А уже внутри виджета взаимодействие с хранилищем будет следующим:

  • После нажатия на карточку в бд будет делаться пометка в булеву переменную isFlipped, перевёрнута ли карточки или нет, и уже от этой логики будет показываться перевод или само слово.

  • После нажатия на кнопку свайпа в виджете, будет удаляться первая сущность из хранилища, по принципу FIFO (First in, First out) очередь со словами

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

Интерактивная кнопка AppIntent

Создадим 2 экшена AppIntent, которые будут сообщать хранилищу UserDefaults о произошедшем действии:

/// Свайп экшн.
struct SwipeAppIntent: AppIntent {
    
    static var title: LocalizedStringResource = "Swipe card"
    static var isDiscoverable: Bool = false
    
    func perform() async throws -> some IntentResult {
        let key = "group.Revolvetra.Inc.Ficha"
        let storage: WordsStorageProtocol = WordsStorage(key: key)
        storage.swipeCard()
        return .result()
    }
}

/// Флип экшн.
struct FlipCardAppIntent: AppIntent {
    
    static var title: LocalizedStringResource = "Flip current card"
    static var isDiscoverable: Bool = false
    
    func perform() async throws -> some IntentResult {
        let key = "group.Revolvetra.Inc.Ficha"
        let storage: WordsStorageProtocol = WordsStorage(key: key)
        storage.flip()
        return .result()
    }
}

А затем добавим эти экшны во вью виджета.

import SwiftUI


struct LearnWordWidgetEntryView: View {
    
    var entry: LearnWordWidgetEntry
    
    @Environment(\.widgetFamily) var family
    
    @ViewBuilder
    var body: some View {
        if family == .systemSmall {
            LearnWordSmallWidget(word: entry)
        }
    }
    
}

struct LearnWordSmallWidget: View {
    
    var entry: LearnWordWidgetEntryView
    
    var body: some View {
        ZStack {
            CustomGradientView(typeGradient: .angled,
                               firstColor: .lightPurrple,
                               secondColor: .darkPurrple)
            
            VStack(alignment: .leading, spacing: 8.0) {
                
                // Карточка с экшном флипа
                wordCard(title: entry.word.title, intent: FlipCardAppIntent())
                
                HStack {
                    buildButton(image: .starUnfilledIcon,
                                color: .softYellow,
                                intent: SwipeAppIntent()) // Экш свайпа
                    
                    Spacer()
                    buildButton(image: .rightArrowIconThin,
                                color: .softGreen,
                                intent: SwipeAppIntent()) // Экш свайпа
                }
                .frame(maxWidth: .infinity)
            }
            .padding(.all(8.0))
        }
        
    }
    
    @ViewBuilder
    func buildButton(image: UIImage?,
                     color: Color,
                     intent: any AppIntent) -> some View {

        // Устанавливаем AppIntent
      
        Button(intent: intent) {
            Image(uiImage: image ?? UIImage())
                .resizable()
                .renderingMode(.template)
                .foregroundColor(color)
                .frame(width: 30.0, height: 30.0)
        }
        .buttonStyle(.plain)
    }
    
    @ViewBuilder
    func wordCard(title: String,
                  intent: any AppIntent) -> some View {
      
        // Устанавливаем AppIntent
      
        Button(intent: intent) {
            VStack{
                Text(title)
                    .font(.system(size: 24,
                                  weight: .heavy,
                                  design: .rounded))
                    .multilineTextAlignment(.center)
                    .lineLimit(3)
                    .foregroundColor(Color.black)
                    .padding(.all(4.0))
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(Color.white.opacity(0.98))
            .clipShape(.rect(cornerSize: .init(width: 13.0, height: 13.0), style: .continuous))
        }
        .buttonStyle(.plain)
    }
}

Каждое нажатие на кнопку с AppIntent действием, обязательно перезагружает Timeline виджета через его TimelineProvider. Это гарантирует Apple.

Остаётся доработать TimelineProvider с учётом перезагрузки виджета после нажатия на кнопки.

import SwiftUI
import WidgetKit

struct LearnWordWidgetProvider: TimelineProvider {
    
    // MARK: - Properties
    
    let storage: WordsStorageProtocol
    
    // MARK: - Init
    
    init(storage: WordsStorageProtocol) {
        self.storage = storage
    }
    
    // MARK: - TimelineProvider
    
    func placeholder(in context: Context) -> LearnWordWidgetEntry {
        LearnWordWidgetEntry(date: Date(),
                             word: .init(title: "Hello there"))
    }
    
    func getSnapshot(in context: Context, 
                     completion: @escaping (LearnWordWidgetEntry) -> ()) {
        let defaultWord = Word(title: "Hello there", translation: "Привет")
        let firstWord = storage.words.first ?? defaultWord
        let entry = LearnWordWidgetEntry(date: Date(),
                                         word: .init(title: firstWord.title))
        completion(entry)
    }
    
    func getTimeline(in context: Context, 
                     completion: @escaping (Timeline) -> ()) {
        let timeline: Timeline
        
        // 1. Достаём первое слово из хранилища
        if let firstWord = storage.words.first {
            
            // 2. Показываем перевод, если в storage карточка помечена,
            // Как перевёрнутая
            let word: LearnWordWidgetEntryView.Word = storage.isFlipped
                ? .init(title: firstWord.translation)
                : .init(title: firstWord.title)
            
            // 3. Формируем таймлайн
            let hour: TimeInterval = 60.0 * 60.0
            timeline = Timeline(
                entries: [.init(date: Date(), word: word)],
                policy: .after(Date().addingTimeInterval(hour * 6.0))
            )
        } else {
            // Здесь можно показывать заглушку,
            // Если нет слов в виджете
            timeline = Timeline(entries: [],
                                policy: .never)
        }
        
        completion(timeline)
    }
}

Результат

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

Финальный результат виджета

Финальный результат виджета

Ссылки на виджет

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

© Habrahabr.ru