Как создать интерактивные виджеты на iOS 17
5–9 июня 2023 года состоялась ежегодная презентация Apple WWDC23, на которой было представлено много интересных новшеств для iOS-разработчиков. Одну из таких фич — интерактивные виджеты, мы рассмотрим в этом руководстве.
Возможность создавать виджеты для приложений на устройствах Apple появилась достаточно давно. Но до сих пор основной целью виджетов являлось отображение актуальной информации из приложения и его запуск по нажатию. Такой ограниченный функционал повлиял на падение популярности данного элемента.
В iOS 17 реализован функционал для создания интерактивных виджетов. Теперь у них появились элементы управления и анимации, виджет может сам выполнять некоторую работу без запуска основного приложения. Грубо говоря, обновленные виджеты представляют собой небольшие отдельные приложения, как AppClip.
В данном руководстве мы создадим несколько интерактивных виджетов и расскажем, каким образом пользователю быстро выполнять определенные функции без запуска основного приложения.
Подготовка
Перед тем, как интегрировать интерактивные виджеты в приложение, необходимо подготовиться. Возьмем в пример небольшое приложение, которое ежедневно отслеживает количество употребляемой человеком жидкости.
К примеру, в сутки необходимо выпивать в среднем 3 литра воды, возьмем это за целевое значение. Также отметим, что средний объем обычного стакана составляет 350 мл, следовательно, наш шаг при добавлении записи — 350. Индикатор показывает, на сколько процентов мы выполнили план текущего дня.
Чтобы успешно обмениваться данными с основным приложением, давайте создадим общую так называемую «Группу приложений».
В Xcode выбираем наш проект, далее выбираем таргет нашего приложения, переходим в Signing & Capabilities, жмем »+ Capability» в левом верхнем углу:
Выбираем App Groups, в появившемся поле нажимаем »+» и создаем новую группу. За название группы мы взяли Bundle identifier основного приложения с припиской group.
Ставим галочку напротив нашей новой группы. На этом создание группы приложений закончено.
Hidden text
Изначально при выборе новой группы она может подсвечиваться красным, не пугайтесь, соберите приложение, используя Cmd + B, и подождите некоторое время. Если все сделано правильно, цвет текста снова станет белым.
Приложение сохраняет записи пользователя на диске с помощью UserDefaults. Это и будет нашим главным связующим звеном и источником актуальных данных, которыми мы будем манипулировать с помощью приложения и виджета.
Но в этом случае наше приложение и виджет должны использовать данные из одного общего контейнера группы приложений. Для этого вместо стандартного синглтона standard
необходимо использовать наш контейнер группы приложений через инициализатор suitename
:
/// Класс, управляющий базой данных приложения
final class UserDatabase {
static let database = UserDefaults(suiteName: "group.com.ru.ppr.WidgetApp")!
}
Добавление нового виджета
Теперь пришло время создать виджет, для этого в Xcode идем в File → New → Target, и выбираем «Widget Extension».
Hidden text
*В нашем примере мы не добавляли Live Activity и Configuration Intent.
У нас создается новый таргет и шаблонный код для виджета. Первое, что мы должны сделать — это добавить данный виджет в «App Groups» так же, как мы сделали с основным приложением.
Hidden text
Как использовать файлы из основного приложения в расширении.
Если вы хотите использовать некоторые структуры или другие файлы из основного приложения, то необходимо привязать нужные файлы к новому таргету. Сделать это можно следующим образом:
Выбираем нужный файл в Xcode, разворачиваем правую панель управления и устанавливаем галочку напротив нужного таргета в разделе «Target Membership»:
В данном руководстве мы не будем подробно разбирать процесс создания обычного виджета, однако вспомним некоторые основные моменты.
Bundle — это, по сути, наш главный контейнер виджета, который содержит в себе всю информацию. Также тут можно указать несколько виджетов для приложения. В данный момент нам не нужно тут ничего настраивать, поэтому пока пропустим.
А вот дальше уже интереснее. Итак, Provider. Данная структура — это источник данных, к которому будет обращаться система, когда ей нужно будет запросить новые данные для виджета.
Метод placeholder()
отвечает за данные, которые будут отображаться на виджете при первом показе, чтобы дать пользователю краткую информацию о виджете. Важно понимать, что это синхронный метод, и данные нужно вернуть из него как можно быстрее.
/// Вариант нашего виджета, когда он будет показан впервые
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, percentValue: 48, smallCupMode: smallCupMode)
}
Метод getSnapshot()
отвечает за предоставление виджету актуальных данных на момент первого показа, например, в момент выбора виджета в галерее.
/// Актуальные данные виджета на разовый показ (Например в галерее виджетов)
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: .now,
percentValue: currentValue, smallCupMode: smallCupMode)
completion(entry)
}
Метод getTimeline()
отвечает за предоставление виджету данных для отображения на протяжении определенного времени. Это главный метод, который используется для отображения данных на виджете.
/// Варианты данных нашего виджета в разные моменты времени
func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
let entries = [
SimpleEntry(date: .now,
percentValue: currentValue, smallCupMode: smallCupMode)
]
let timeline = Timeline(entries: entries, policy: .never)
completion(timeline)
}
Важное замечание: Следует помнить, что в целях оптимизации виджеты работают не совсем обычным образом в отличие от приложений. При запуске система в отдельном процессе обнаруживает поставщиков данных и запрашивает таймлайны для отображения виджетов. В этом процессе нельзя получить доступ к вашему основному приложению.
Во всех методах для передачи данных мы готовим структуру SimpleEntry
, в которую упакованы все необходимые параметры. Обязательный параметр — это date, который необходим для соответствия протоколу TimeLineEntry
. По сути, это временная метка, указывающая, в какой момент данный объект должен будет использоваться виджетом.
/// Данные для нашего виджета
struct SimpleEntry: TimelineEntry {
let date: Date
let percentValue: Int
var smallCupMode: Bool
}
Переходим к самому основному. (WidgetName)EntryView
— это уже сам View нашего виджета на SwiftUI, в body
, по аналогии с обычной View структурой, мы рисуем виджет под наши задачи.
При разработке рекомендуется опираться на гайдлайны от Apple для виджетов.
/// Тело виджета
struct TrackerWidgetEntryView: View {
var entry: Provider.Entry
var body: some View {
HStack {
Text("hello")
Text("world")
}
}
}
Сюда мы вернемся чуть позже, а пока продолжим.
Итак, сама структура с названием нашего виджета — это ее определяющий контейнер, который содержит kind
— идентификатор данного виджета. В отличие от стандартного View, здесь body является типом WidgetConfiguration
, и вернуть мы должны объект, соответствующий данному протоколу.
В целом, мы можем создать кастомную конфигурацию, но в данном руководстве мы остановимся именно на готовой статической конфигурации, этого будет достаточно для демонстрации.
/// Сам контейнер с виджетом, содержащий в том числе и настройки нашего виджета
struct TrackerWidget: Widget {
let kind: String = "waterTrackerWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
TrackerWidgetEntryView(entry: entry)
}
.configurationDisplayName("Трекер воды") // Имя в галерее
.description("Помогает следить за количеством выпитой воды") // Описание
.supportedFamilies([.systemSmall, .systemMedium]) // Варианты виджета
}
}
Теперь вернемся к нашему EntryView
. В body расписываем то, как будет выглядеть наш виджет, пока без кнопок. У нас есть экземпляр класса SimpleEntry
под названием entry — это как раз наши упакованные данные, которые мы должны использовать. В нашем примере создадим аналогичный круглый индикатор заполнения дневного плана жидкости.
Так как наш виджет должен отображать актуальное состояние индикатора, который может быть изменен, в том числе и в самом приложении, нам нужно напрямую из основного приложения вызвать принудительное обновление виджета. Для этого в него импортируем WidgetKit, и после того, как пользователь в приложении сделал запись, необходимо отправить команду на обновление таймлайнов виджета.
func addCup() {
logEntry() // Добавляем запись в журнал
updateWidget() // Обновляем виджет
}
func updateWidget() {
// Ключевая строчка кода для форсированного обновления виджета
WidgetCenter.shared.reloadTimelines(ofKind: "waterTrackerWidget")
}
На данном этапе мы имеем самый обычный виджет, который отображает индикатор и переводит в приложение по тапу на него. Самое время приступить к добавлению интерактивности.
Добавление интерактивности
Поскольку виджеты работают отдельно от основного приложения, даже когда оно закрыто, их код выполняется системой в другом процессе, не связанным с основным приложением. Для того, чтобы исполнить описанный в приложении код, нам необходим способ предоставить какие-либо действия через расширение системе, чтобы она могла их выполнить. Как раз для решения этой проблемы Apple предоставила новый механизм App Intents с использованием нового протокола AppIntent
. AppIntent — это протокол, позволяющий определять в коде действия, которые могут выполняться системой. Это как раз то, что нам нужно.
Hidden text
Немного об Intent’ах
Есть похожие механизмы на основе Intent, например, для выполнения действий в приложении через Siri, реализация App Intents позволит дополнительно расширить пользовательские возможности приложения за счет доступа Siri и Shortcut к созданным Intent сообщениям о действиях.
Чтобы использовать новый механизм, мы создаем дополнительную структуру LogEntryIntent
и, после импорта AppIntents, подписываемся под протокол AppIntent.
Для соответствия протоколу реализуем статические переменные title и description.
/// Структура, описывающая действие при нажатие кнопки на виджете, вызывающей данное действие
struct LogEntryIntent: AppIntent {
static var title: LocalizedStringResource = "Запись в журнал"
static var description: IntentDescription? = IntentDescription("Добавление записи к сегодняшнему значению")
}
Теперь нам необходимо реализовать метод perform()
, именно он будет отрабатывать, когда на кнопку виджета будет производиться нажатие.
func perform() async throws -> some IntentResult {
let dataProvider = UserDatabase.self // Получаем наш класс для управления БД
dataProvider.log() // Делаем запись в журнал
return .result() // Возвращаем пустой результат
}
Важное замечание: Также мы можем определить дополнительные входные параметры для Intent. Для этого необходимо использовать обертку свойств @Parameter
. Сохранённые параметры без данной обертки система обработать не сможет.
/// Структура, описывающая действие при нажатие кнопки на виджете, вызывающей данное действие
struct LogEntryIntent: AppIntent {
@Parameter(title: "Scope")
var scope: TimeScope
}
В нашем случае данный метод будет получать актуальное значение для выпитой воды на текущую дату и увеличивать ее на литраж одной стандартной кружки, после чего сохранит итоговые данные в UserDefaults. В качестве возвращаемого значения используем пустой результат .result()
Важное замечание: после возвращения из метода perform()
, система гарантированно запрашивает новые таймлайны для отображения актуальной информации в виджете.
Теперь добавим инструменты для взаимодействия с виджетом. На момент написания этой статьи у нас в запасе имеется два инструмента для получения действий от пользователя.
Данные инструменты получили дополнительный инициализатор с параметром intent, принимающим AppIntent объект
/// Button(intent: <#T##AppIntent#>, label: <#T##() -> _#>)
Button(intent: LogEntryDurationIntent()) {
HStack(spacing: 2) {
Text("+")
.font(.system(size: 16))
.foregroundStyle(Palette.textColor)
Image(systemName: "drop")
.resizable()
.frame(width: 10, height: 15)
.foregroundStyle(Palette.textColor)
}.padding(.horizontal)
}.tint(Palette.indicatorColor)
Обратите внимание, что кнопка на виджете представляется в полупрозрачном контейнере, вы можете управлять цветом этого контейнера через модификатор .tint()
Теперь при взаимодействии с данной кнопкой система запустит переданный Intent, который исполнит наш код в методе perform (). Этот метод — асинхронный, поэтому возможна задержка перед отображением новых данных. Вы можете использовать модификатор .invalidateContent()
в ваших View, которые будут обновляться с добавлением к ним системной анимации загрузки.
HStack(alignment: .bottom, spacing: 2) {
Text(String(entry.percentValue))
.font(.system(size: 19, weight: .bold, design: .rounded))
.invalidatableContent() // Анимация при ожидании получения нового значения
Text("%")
.font(.system(size: 14, weight: .bold))
.padding(.bottom, 1)
}.foregroundStyle(Palette.textColor)
Hidden text
Немного об анимации в виджетах
Виджеты используют другой подход к анимации, в отличие от обычного SwiftUI. При обработке нового entry система получает параметры, которые будут изменены, и анимирует только их. Тем не менее, вы можете использовать все транзитные анимации через модификатор .transition()
Text("46")
.font(.system(size: 16))
.contentTransition(.numericText()) // Анимация для numeric контента
В демонстрационном приложении мы реализовали оба инструмента взаимодействия. На виджете среднего размера мы добавили искусственную задержку перед обновлением данных для того, чтобы показать анимацию ожидания обновления из модификатора .invalidateContent()
На этом процесс создания интерактивного виджета завершен.
На наш взгляд, данное, безусловно, крупное обновление виджетов iOS может подарить вторую жизнь этому полезному расширению. Помимо того, что многие системные виджеты стали удобнее и функциональнее, у огромного числа проектов появилась возможность выносить важные функции на главный экран для быстрого доступа. Не исключено, что в скором времени мы сможем быстро пользоваться главными функциями наших любимых приложений прямо с главного экрана, без загрузок и переключений.
Благодарим за внимание!