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

от автора

Виджеты в новом обличии появились в 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<WidgetEntry>) -> ()) {          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<WidgetEntry>) -> ()) вызывается, когда виджет добавлен пользователем на рабочий стол / экран блокировки / 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<WidgetEntry>) -> ()) {        // Час     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:

  • accessoryCircular

  • accessoryRectangular

  • accessoryInline (доступен и на watchOS)

С точки зрения кода виджеты разных семейств между собой не различаются. 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 с заданным строковым идентификатором контейнера.

В примере, идентификатором является строка — 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<Value: Codable> {          // 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<Bool>          // 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<Bool>(             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 о произошедшем действии:

  • SwipeAppIntent — свайпает карточку в виджете

  • FlipCardAppIntent — показывает перевод/слово

/// Свайп экшн. 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<LearnWordWidgetEntry>) -> ()) {         let timeline: Timeline<LearnWordWidgetEntry>                  // 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 хранилищем, связанным между основным и виджет таргетами приложения.

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/787880/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *