Виджеты в новом обличии появились в 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)systemMediumsystemLargesystemExtraLarge(для iPad, начиная с 15 iOS; с 14 macOS)
systemSmall виджет может располагаться на LockScreen в iPad с iOS 17, и в StandBy в iPhone.
Семейство accessory может располагаться только на HomeScreen в iPhone, iPad:
accessoryCircularaccessoryRectangularaccessoryInline(доступен и на watchOS)
С точки зрения кода виджеты разных семейств между собой не различаются. Apple рекомендует помещать больше полезной информации / контента в виджет вместе с увеличением размеров виджета.
Конфигурации виджета
При инициализации StaticConfiguration, указывается параметр kind. Kind — это идентификатор‑строка, по которому можно будет перезагружать конкретный виджет из основного приложения через WidgetCenter.
В WidgetKit, помимо StaticConfiguration, существует также динамическая конфигурация AppIntentConfiguration. Такой конфиг даёт возможность пользователям настраивать виджет под себя. Сами параметры для настройки добавляют разработчики по своему усмотрению.
У динамичного конфига есть свои плюсы и минусы, не будем долго останавливаться на конфигах, эта тема заслуживает отдельной статьи, ведь там тоже есть чего рассказать. Приступим непосредственно к интерактивному виджету.
Сделали 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 добавить его на рабочий стол и ничего не делать через приложение!

Обязательные параметры: 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 с 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 с заданным строковым идентификатором контейнера.

В примере, идентификатором является строка — 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 хранилищем, связанным между основным и виджет таргетами приложения.

Следующая статья по виджетам — Интерактивные виджет-подборки в Иви (iOS)
