Виджеты в новом обличии появились в 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 виджета
Итого, после всех вставок с кодом и объяснений виджет выглядит:
В галерее виджетов (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)