В iOS 14 и macOS 11 Apple представили Widgets. Еще один способ взаимодействия пользователя с приложением. В данной статье рассмотрим основные принципы работы WidgetKit и интегрируем свой Widget в готовый проект.
Widget показывает главную информацию вашего приложения, без открытия самого приложения. Так же виджеты помогают сделать ваш домашний экран более персонализированным.
Пользователи могут найти виджеты в галерее виджетов, там же они могут выбрать размер для виджета. Всего есть 3 размера для виджетов: small, medium и large.
Для того чтобы добавить виджет на экран нужно войти в режим редактирования домашнего экрана и нажать на плюсик в верхнем левом углу, откроется галерея, где будут представлены виджеты приложений, но только от тех приложений, которые их поддерживают. При нажатии на конкретный виджет можно будет выбрать его тип и его размер, например приложение Stocks имеет 2 типа виджетов Watchlist и Symbol, причем для Watchlist представлены все 3 размера, а для Symbol только один.
Главное предназначение виджета это показать небольшой объем информации пользователю, актуальной в данный момент времени. Определение этого одного предназначение является первым шагом для построения хорошего пользовательского опыта. Поэтому основные принципы включают в себя
Пройдемся кратко по дизайну.
Виджеты масштабируются к размеру экрана на различных устройствах, начиная с iPhone 6s заканчивая iPhone 11 Pro Max.
Уравняйте скругление углов элементов в вашем виджете со скруглением углов самого виджета. Позаботесь о том, чтобы контент виджета выглядел хорошо со скругленными углами этого виджета, в этом может помочь ContainerRelativeShape.
Рекомендуется использовать стандартные отступы между элементами. Стандартный отступ это 16 поинтов, использование стандартного отступа поможет разместить элементы в читабельном виде и сделает контент более разборчивым для пользователя.
Вы можете организовывать виджеты в стеки, сам по себе стек это просто список виджетов, где в данный момент показывается только один виджет.
Когда у пользователя есть стек, то он может включить Smart Rotate (включено по умолчанию), который будет перемешивать и показывать только тот виджет, который он посчитает релевантным в данный момент времени.
WidgetKit позволяет пользователю получить доступ к возможностям вашего приложения с помощью вынесения виджета на iOS Home screen или в macOS Notification Center.
С тремя различными размерами для виджета (small, medium, large) у пользователя есть выбор, сколько именно информации он хочет видеть.
Чтобы создать свой виджет, вам нужно добавить Widget Extension к своему приложению. Сам контент виджета создается с помощью SwiftUI, чтобы настроить виджет используется Timeline Provider, Timeline Provider используется для того, чтобы сказать виджету когда ему обновлять свой контент.
Чтобы позволит пользователю конфигурировать виджет вы добавляете свой SiriKit intent definition, остальное сделает WidgetKit за вас и предоставит пользователю кастомизированный интерфейс.
Итак представим у нас есть iOS приложение и теперь мы хотим добить поддержку виджетов, рассмотрим шаг за шагом, как это можно сделать.
Перво-наперво нужно определится с главной идеи для виджета, какую проблему он будет решать, будет ли он полезен пользователю, также желательно соотнести вашу идею с эпловскими гайдлайнами.
Представим, что у нас уже есть готовое приложение и теперь мы хотим к нему прикрутить виджеты.
В данной статье будет рассматривается простое приложение Рейтинг Игроков, которое состоит из 2х экранов — таблицы с рейтингом профилей и детальной информации по каждому профилю. Исходный код можно найти в конце статьи.
Для того чтобы добавить виджет в приложение нужно выбрать File -> New -> Target, на вкладке iOS выбираем Widget Extension, даем имя нашему виджету, например LadderWidget
. В проект добавится новый таргет, также файл LadderWidget.swift, где будет сразу добавлены все необходимые классы. Уже начиная с этого моменты, можно запустить виджет, выбрав соответствующий таргет, и увидеть результат на экране симулятора.
Рассмотрим какие классы создал для нас Xcode.
LadderWidget
LadderWidgetEntryView
Provider
SimpleEntry
Как мы увидим, на самом деле это не классы, а структуры, кратко пройдемся по каждой из этих структур.
SimpleEntry
— слепок, который содержит данные для отображения их на виджете;Provider
— предоставляет таймлайн из слепков для виджета;LadderWidgetEntryView
— View для виджета, тут определяется как будет выглядеть виджет;LadderWidget
— наследник Widget
, это непосредственно и есть наш виджет.Как данные обновляются на виджете? iOS использует механизм слепков, то есть виджету предоставляются таймлайн слепков из провайдера и он их показывает на определенном интервале времени в будущем.
Проведем небольшой рефакторинг и переименуем наши структуры.
Данные для виджета определяются именно в этой структуре путем добавления соответствующих свойств.
struct LadderEntry: TimelineEntry {
let date: Date
// your properties -> let name: String
}
То есть если ваш виджет будет показывать, например, погоду, то у вас скорее всего будут свойства let cityName: String
, let cityTemperature: Double
, ну и let date: Date
, последнее свойство определяет дату когда WidgetKit
обновит виджет и является обязательным, чтобы следовать протоколу TimelineEntry
.
Кто-то должен предоставлять LadderEntry
для виджета и за это ответственен LadderProvider
. Эта структура следует протоколу TimelineProvider
, взлянем на определение.
protocol TimelineProvider {
associatedtype Entry : TimelineEntry
typealias Context = TimelineProviderContext
func placeholder(in context: Context) -> Entry
func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void)
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void)
}
placeholder(in context: )
— возвращает слепок, который будет использоваться как отображения шаблонной версии виджета.getSnapshot(in context: , completion:)
— возвращает слепок, который представляет текущее время и состояние виджета.getTimeline(in context: , completion:)
— возвращает массив слепков в виде таймлайна для текущего времени и будущих случаев обновления виджета.Это обычное View
, которое определяет, как будет выглядеть наш виджет.
struct LadderWidgetEntryView : View {
var entry: LadderProvider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
LadderWidget
— наследник Widget
, если посмотреть на определение протокола Widget
, то можно заметить, что оно сильно напоминает обычный View
, так же через body
определяется контент виджета, но тут также указывается, как виджет будет конфигурироваться статически или динамически, а также здесь указывается провайдер для виджета.
@main
struct LadderWidget: Widget {
let kind: String = "LadderWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: LadderProvider()) { entry in
LadderWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
.supportedFamilies([.systemMedium, .systemSmall])
}
}
.configurationDisplayName(_)
указывает название виджета в галерее виджетов;.description(_)
указывает описание в галерее виджетов;.supportedFamilies(_)
указывает какие размеры будет поддерживать виджет, если не указать, то будут поддерживаться все размеры.@main
перед определением виджета обозначает “точку входа” для данного таргета, а именно то, что при запуске будет показан именно Ladder Widget.
.widgetFamily
Наше приложение показывает список профилей, было бы неплохо иметь виджет, который показывает данные конкретного профиля, причем для виджета с маленьким размером это будет просто текст, а для среднего размера картинка и текст.
Начнем с LadderEntry
, для этого добавим соответствующую поле.
struct LadderEntry: TimelineEntry {
let date: Date
let profile: Profile
}
Так же надо не забыть включать файлы, классы которых используется в таргете виджета, в таргет виджета. Для Profile.swift
это будет выглядеть так.
Теперь обновим как будет отображается виджет.
struct LadderWidgetEntryView : View {
var entry: LadderProvider.Entry
@Environment(.widgetFamily) var family
@ViewBuilder
var body: some View {
switch family {
case .systemSmall:
ProfileInfoView(profile: entry.profile)
default:
ProfileView(profile: entry.profile)
}
}
}
С помощью @Environment(.widgetFamily)
можно получить доступ к размеру виджета и в зависимости от значения показывать просто текст для маленького размера или картинку и текст для среднего размера.
Чтобы можно было сразу увидеть, как виджеты будут выглядеть для разных размеров, можно заключить их в Group
.
struct LadderWidgetEntryView_Previews: PreviewProvider {
static var previews: some View {
Group {
LadderWidgetEntryView(entry: LadderEntry(date: Date(), profile: sampleProfile))
.previewContext(WidgetPreviewContext(family: .systemSmall))
LadderWidgetEntryView(entry: LadderEntry(date: Date(), profile: sampleProfile))
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
}
}
Неплохо было бы иметь возможность как-то выбирать какой профайл показывать на виджете, это можно сделать и для этого нам понадобится убрать статическую конфигурацию и добавить динамическую.
IntentConfiguration
Чтобы добавить динамическую конфигурацию, нужно в LadderWidget
заменить StaticConfiguration
на IntentConfiguration
, для этого нажимаем File -> New -> File -> SiriKit Intent Definition File. Называем файл LadderIntents.intentdefinition.
Конфигурация определяется Intent
, Intent
определяет некоторые свойства для виджета, их много, рассмотрим только некоторые. Но перед этим создадим модель для Intent
, это будет списком профилей из которых будет выбирать пользователь.
Нажимаем на плюсик и добавляем Enum
, заполняем поля этого перечисления.
Гуд, теперь создадим сам Intent
, нажимаем на плюсик и кликаем New Intent, называем его ProfileSelection и конфигурируем, как показано на картинке.
Xcode под капотом сгенерирует для нас класс ProfileSelectionIntent
. Теперь заменим конфигурацию в нашем LadderWidget
.
IntentConfiguration(kind: kind, intent: ProfileSelectionIntent.self, provider: LadderProvider()) { entry in
LadderWidgetEntryView(entry: entry)
}
Компилятор будет ругаться, что LadderProvider
не следует протоколу IntentTimelineProvider
, исправляем это, для этого просто следуем протоколу IntentTimelineProvider
вместо TimelineProvider
и обновляем сигнатуры функций соответственно.
Раз мы добавили ProfileSelectionIntent
, значит пора его где-то использовать, то есть в зависимости от выбранного профиля наш провайдер должен отдавать соответствующий LadderEntry
. Обновим наш getTimeline(for configuration:, in context:, completion:)
func getTimeline(for configuration: ProfileSelectionIntent, in context: Context, completion: @escaping (Timeline<LadderEntry>) -> ()) {
let entries: [LadderEntry] = [LadderEntry(date: Date(), profile: profile(for: configuration))]
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
func profile(for configuration: ProfileSelectionIntent) -> Profile {
switch configuration.profile {
case .lina:
return Profile(id: 3, name: "Lina", ...)
case .morphling:
return Profile(id: 2, name: "Morphling", ...)
// ...
}
}
Теперь у виджета появится кнопка Edit Widget и выбор отображаемого профиля для виджета.
Когда WidgetKit отображает виджет первый раз, он показывает вью виджета как placeholder. Вью Placeholder view показывает общую структуру вашего виджета, как бы давая пользователю понять, что будет отображено на виджете. WidgetKit вызывает placeholder(in:)
у провайдера чтобы получить слепок для placeholder’a.
func placeholder(in context: Context) -> LadderEntry {
LadderEntry(date: Date(), profile: sampleProfile)
}
WidgetKit
использует redacted(reason:)
модификатор, чтобы показать вью как placeholder.
Для того, чтобы понять, как будет выглядеть наш виджет в данном случае давайте создадим LadderWidgetPlaceholderView
.
struct LadderWidgetPlaceholderView: View {
var entry: LadderProvider.Entry
var body: some View {
LadderWidgetEntryView(entry: entry)
.redacted(reason: .placeholder)
}
}
То есть мы просто показывает вью нашего виджета и применяем к нему модификатор .redacted(reason: .placeholder)
. Если есть необходимость не показывать какие-то отдельные элементы вью как placeholder, то можно использовать оператор .unredacted()
для них.
Более подробно про работу placeholder’ов можно прочитать у Majid Jabrayilov здесь.
Данные для виджета предоставляет LadderProvider
, пройдемся более подробно по его функциям.
func placeholder(in context: Self.Context) -> Self.Entry
Тут все просто, просто возвращаем слепок для плейсхолдера.
func getTimeline(for configuration: Self.Intent, in context: Self.Context, completion: @escaping (Timeline<Self.Entry>) -> Void)
Как можно заметить этот метод ничего не возвращает, вместо этого у него есть комплишен блок, который принимает объект Timeline
.
По сути таймлайн это просто структура, которая содержит массив слепков и объект полиси.
struct Timeline<EntryType> where EntryType : TimelineEntry {
public let entries: [EntryType]
public let policy: TimelineReloadPolicy
}
Как мы помним, слепок здесь это наша структура LadderEntry
, у которой есть date
и собственно данные для виджета, так вот эта date
и определяет когда виджет будет обновлен в будущем.
Можно например сгенерировать таймлайн, где виджет будет обновляться каждые 3 секунды на протяжении 1 минуты.
func getTimeline(for configuration: ProfileSelectionIntent, in context: Context, completion: @escaping (Timeline<LadderEntry>) -> ()) {
let profile = self.profile(for: configuration)
var entries: [LadderEntry] = [LadderEntry(date: Date(), profile: profile)]
let threeSeconds: TimeInterval = 3
var currentDate = Date()
let endDate = Calendar.current.date(byAdding: .minute, value: 1, to: currentDate)!
while currentDate < endDate {
let newScore = Double.random(in: 150...200)
let newProfile = Profile(id: profile.id, name: profile.name, rating: profile.rating, score: newScore, imageName: profile.imageName)
let entry = LadderEntry(date: currentDate, profile: newProfile)
entries.append(entry)
currentDate += threeSeconds
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
Что будет когда закончится эта 1 минута? Это зависит от того, что указано в policy
объекта структуры TimelineReloadPolicy
.
atEnd
— WidgetKit запросит новый таймлайн;never
— само приложение скажет WidgetKit’у когда обновить виджет;after(_ date: )
— определяет точную дату, когда WidgetKit запросит новый таймлайн.Осталась одна функция в провайдере, которую осталась описать.
func getSnapshot(for configuration: Self.Intent, in context: Self.Context, completion: @escaping (Self.Entry) -> Void)
В ней мы возвращает слепок, который представляет текущее время и состояние виджета. По сути реализация мало чем отличается от getTimeline()
, но есть один момент который стоит упомянуть.
WidgetKit вызывает этот метод, когда виджет появляется в разных переходах и так же в галерее виджетов, тогда context.isPreview == true
. В этом случае нужно вызвать комплишен как можно быстрее, допускается возвращать заранее приготовленные данные, если загрузка реальных данных может занять больше пары секунд.
func getSnapshot(for configuration: ProfileSelectionIntent, in context: Context, completion: @escaping (LadderEntry) -> ()) {
if context.isPreview {
let entry = LadderEntry(date: Date(), profile: sampleProfile)
completion(entry)
} else {
// it takes a few seconds to fetch the data
let profile = self.profile(for: configuration)
let entry = LadderEntry(date: Date(), profile: profile)
completion(entry)
}
}
На симуляторе, это будет выглядеть вот так
WidgetKit
также предоставляет возможность иметь больше чем один виджета на приложение.
Для того, чтобы добавить еще один виджет в приложение нужно соответственно реализовать этот виджет и создать бандл виджетов, который будет входной точкой для Widget таргета.
Важный момент здесь, что точка входа может быть только одна, то есть @main
должен быть только один и стоять перед WidgetsBundle
.
@main
struct WidgetsBundle: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
LadderWidget()
NewsWidget()
}
}
Здесь Xcode может бросить ошибку:
Please specify the widget kind in the scheme’s Environment Variables using the key ‘_XCWidgetKind’ to be one of: ‘LadderWidget’, ‘NewsWidget’}.
Чтобы это поправить нужно пойти Edit Scheme виджет таргета и поправить значение свойства _XCWidgetKind
.
Запускаем симулятор снова и видим, что теперь мы можем выбрать еще один виджет для приложения.
Что если, когда мы редактируем виджет мы хотели бы иметь не статические параметры, а динамические? Которые мы могли бы, скажем, загружать с сервера?
Виджеты позволяют это сделать и для этого нужно будет создать Intents Extension таргет, создать файл .intentdefinition и в нем определить опции выбора и далее в классе IntentHandler` обработать колбек выбора опции.
Итак начнем, создаем новый таргет New -> Target -> Intents Extension и даем ему имя например LadderWidgetIntent. Далее руками создаем DynamicLadderIntents.intentdefinition. Его конфигурация будет похожа на LadderIntents.intentdefinition, но с небольшими отличиями.
Вместо Enum
у нас будет конкретный тип ProfileType
, сам intent будет называться DynamicProfileSelection
и так же нужно проставить галочку динамически загружать опции.
Теперь везде в проекте нужно заменить ProfileSelectionIntent
на DynamicProfileSelectionIntent
.
Xcode автоматически для нас генерирует класс ProfileType
, а также протоколы выбора конкретного профиля. Тем самым мы получаем тот самый колбек для загрузки конфигурации.
extension IntentHandler: DynamicProfileSelectionIntentHandling {
func provideProfileTypeOptionsCollection(for intent: DynamicProfileSelectionIntent, with completion: @escaping (INObjectCollection<ProfileType>?, Error?) -> Void) {
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
let items: [ProfileType] = [
ProfileType(identifier: "1", display: "Bloodseeker"),
ProfileType(identifier: "2", display: "Morphling"),
ProfileType(identifier: "3", display: "Lina")]
let collection = INObjectCollection(items: items)
completion(collection, nil)
}
}
}
Поправляем функцию profile(for _:)
в провайдере и теперь наша конфигурация подтягивается динамически.
iOS предоставляет несколько способов передачи данных между виджетом и приложением.
UserDefaults
, а также место на диске.
UserDefaults(suiteName: appGroup)
let url = sharedFileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
Приложение может обновить свой виджет не дожидаясь, когда это сделает система, с помощью WidgetCenter.
WidgetCenter.shared.reloadAllTimelines()
У многих могли возникнуть вопросы по работе с виджетами, попробую ответить на некоторые из них.
Вопрос: Можно ли использовать готовые UIKit вьюшки в виджетах?
Ответ: Нет, виджеты работают только на чистом SwiftUI, даже если вы обернете UIKit вьюшку в SwiftUI вью, то да код скомпилируется, но на устройстве вы увидите просто желтый экран заглушку.
Вопрос: Почему при запуске на симуляторе, я не вижу своих последних изменений?
Ответ: Отладка виджетов достаточно болезненный процесс, то виджет не появляется на экране или появляется только черный квадрат, то вообще виджета нету в галереи виджетов или виджет не обновляется, и такого много, как правило удаление виджета с симулятора и перезапуск симулятора помогает решить такие проблемы.
В статье были разобраны основные моменты работы с виджетами, надеюсь кому-то пригодится.
Apple возобновила переговоры с OpenAI о возможности внедрения ИИ-технологий в iOS 18, на основе данной операционной системы будут работать новые…
Конкурсный управляющий российской «дочки» Google подготовил 23 иска к участникам рекламного рынка. Общая сумма исков составляет 16 млрд рублей –…
Google завершил обновление основного алгоритма March 2024 Core Update. Раскатка обновлений была завершена 19 апреля, но сообщил об этом поисковик…
У частных продавцов на Авито появилась возможность составлять текст объявлений с помощью нейросети. Новый функционал доступен в категории «Обувь, одежда,…
24 апреля 2024 года в Москве состоялась церемония вручения наград международного конкурса Workspace Digital Awards. В этом году участниками стали…
27 июня Яндекс проведет гик-фестиваль Young Con для студентов и молодых специалистов, которые интересуются технологиями и хотят работать в IT.…