В этом году для iOS-разработчиков появилось сразу несколько интересных возможностей посадить батарейку айфона улучшить пользовательский опыт, одна из таких — новые виджеты. Пока мы все находимся в ожидании выхода релизной версии ОС, хотел бы поделиться опытом написания виджета для приложения «Кошелёк» и рассказать, с какими возможностями и ограничениями наша команда столкнулась на бета-версиях Xcode.
Начнем с определения — виджетами называются представления, показывающие актуальную информацию без запуска основного мобильного приложения и всегда находящиеся под рукой у пользователя. Возможность их использовать уже есть в iOS (Today Extension), начиная с iOS 8, но мой сугубо личный опыт их использования довольно печален — под них хоть и выделен специальный рабочий стол с виджетами, но я всё равно редко туда попадаю, привычка не выработалась.
Как итог, в iOS 14 мы видим перерождение виджетов, глубже интегрированных в экосистему, и более удобных для пользователя (в теории).
Работа с картами лояльности — одна из основных функций нашего приложения «Кошелёк». Периодически в отзывах в App Store появляются предложения от пользователей о возможности добавления виджета в Today. Пользователи, находясь у кассы, хотели бы как можно быстрее показать карту, получить скидку и убежать по своим делам, ведь промедление на любой квант времени вызывает те самые укоризненные взгляды в очереди. В нашем случае виджет может сэкономить несколько пользовательских действий для открытия карты, таким образом сделав операцию оплаты товара на кассе более быстрой. Магазины тоже будут благодарны — меньше очередей на кассе.
В этом году Apple неожиданно для всех выпустила релиз iOS практически сразу после презентации, оставив разработчикам сутки на доработки своих приложений на Xcode GM, но мы оказались готовы к релизу, так как свой вариант виджета наша iOS-команда стала делать еще на бета-версиях Xcode. Сейчас виджет находится на ревью в App Store. Обновление устройств до новой iOS по статистике происходит довольно быстро; скорее всего, пользователи пойдут проверять, у каких приложений виджеты уже есть, найдут наш и будут счастливы.
В дальнейшем мы хотели бы добавить еще больше актуальной информации — например, баланс, штрихкод, последние непрочитанные сообщения от партнёров и уведомления (например, что пользователям необходимо совершить действие — подтвердить или активировать карту). На текущий момент результат выглядит следующим образом:
Добавление виджета в проект
Как и другие подобные дополнительные возможности, виджет добавляется как расширение (extension) к основному проекту. После добавления Xcode любезно генерирует код виджета и других основных классов. Вот тут нас ждала первая интересная особенность — для нашего проекта этот код не компилировался, так как в одном из файлов автоматически подставлялся префикс в названиях класса (да-да, те самые Obj-C префиксы!), а в генерируемых файлах — нет. Как говорится, не боги горшки обжигают, видимо, разные команды внутри Apple не договорились между собой. Будем надеяться, что к релизной версии исправят. Для того, чтобы настроить префикс своего проекта, в File Inspector основного таргета приложения заполните поле Class Prefix.
Для тех, кто следил за новинками WWDC, не секрет, что реализация виджетов возможна только с использованием SwiftUI. Интересный момент, что таким образом Apple форсит обновление на свои технологии: даже если основное приложение написано с использованием UIKit, то тут, будьте любезны, только SwiftUI. С другой стороны, это хорошая возможность попробовать новый фреймворк для написания фичи, в этом случае он удобно вписывается в процесс — никаких изменений состояния, никакой навигации, требуется только задекларировать статичный UI. То есть вместе с новым фреймворком появились и новые ограничения, потому как старые виджеты в Today могут содержать больше логики и анимацию.
Одно из основных нововведений SwiftUI — возможность предпросмотра без запуска на симуляторе или девайсе (preview). Классная вещь, но, к сожалению, на больших проектах (в нашем — ~400K строк кода) работает крайне медленно даже на топовых макбуках, быстрее запустить на девайсе. Альтернатива такому способу — иметь под рукой пустой проект или плейграунд для быстрого прототипирования.
Возможность для дебага также есть с помощью выделенной Xcode схемы. На симуляторе отладка работает нестабильно даже к версии Xcode 12 beta 6, поэтому лучше пожертвовать один из тестовых девайсов, обновить до iOS 14 и тестировать на нем. Будьте готовы, что эта часть и на релизных версиях будет работать не как ожидается.
Интерфейс
На выбор пользователю даются разные типы (WidgetFamily) виджетов трёх размеров — small, medium, large.
Для регистрации необходимо явно указать поддерживаемые:
struct CardListWidget: Widget {
public var body: some WidgetConfiguration {
IntentConfiguration(kind: “CardListWidgetKind”,
intent: DynamicMultiSelectionIntent.self,
provider: CardListProvider()) { entry in
CardListEntryView(entry: entry)
}
.configurationDisplayName("Быстрый доступ")
.description("Карты, которые вы используете чаще всего")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
Мы с командой решили остановиться на small и medium — выводить одну любимую карту для маленького виджета или 4 для medium.
Добавление виджета на рабочий стол происходит из центра управления, там пользователь выбирает необходимый ему вид:
Цвет кнопки «Добавить виджет» кастомизируем с помощью Assets.xcassets -> AccentColor, имя виджета с описанием тоже (пример кода выше).
Если уперлись в ограничение по количеству поддерживаемых видов, то можно расширить его с помощью WidgetBundle:
@main
struct WalletBundle: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
CardListWidget()
MySecondWidget()
}
}
Так как виджет показывает слепок некоторого состояния, то единственная возможность для пользовательского интерактива — это переход в основное приложение по нажатию на какой-то элемент или весь виджет. Никакой анимации, навигации и переходов на другие view. Но есть возможность прокинуть диплинку в основное приложение. При этом для small виджета зоной нажатия является вся область, и в этом случае используем widgetURL(_:) метод. Для medium и big доступны нажатия по view, и в этом нам поможет структура Link из SwiftUI.
Link(destination: card.url) {
CardView(card: card)
}
Финальный вид виджета двух размеров получился следующим:
При проектировании интерфейса виджета могут помочь следующие правила и требования (согласно гайдлайнам Apple):
- Сфокусируйте виджет на одной идее и проблеме, не пытайтесь повторить всю функциональность приложения.
- В зависимости от размера выводите больше информации, а не просто масштабируйте контент.
- Выводите динамическую информацию, которая может меняться в течение дня. Крайности в виде полностью статической информации и информации, меняющейся ежеминутно, не приветствуются.
- Виджет должен давать актуальную информацию пользователям, а не быть еще одним способом открыть приложение.
Внешний вид настроили. Следующий шаг — выбрать, какие карты и каким образом показывать пользователю. Карт может быть явно больше четырёх. Рассмотрим несколько вариантов:
- Дать возможность пользователю выбирать карты. Кто, как не он, знает, какие карты важнее!
- Показывать последние использованные карты.
- Сделать более умный алгоритм, ориентируясь, например, на время и день недели и статистику (если пользователь по будням вечером ходит во фруктовую лавку у дома, а на выходных ездит в гипермаркет, то можно помочь пользователю в этом моменте и показывать нужную карту)
В рамках прототипа остановились на первом варианте, чтобы заодно попробовать возможность настраивать параметры прямо на виджете. Не нужно делать специальный экран внутри приложения. Правда, настолько ли пользователи, как говорится, experienced, чтобы найти эти настройки?
Пользовательские настройки виджета
Настройки формируются с помощью интентов (привет Андроид-разработчикам) — при создании нового виджета файл интента добавляется в проект автоматически. Кодогенератор подготовит класс-наследник от INIntent, который является частью фреймворка SiriKit. В параметрах интента стоит магическая опция «Intent is eligible for widgets». Доступны несколько типов параметров, можно настраивать свои подтипы. Так как данные в нашем случае — это динамический список, то еще устанавливаем пункт «Options are provided dynamically».
Для разных типов виджета настраиваем максимальное количество элементов в списке — для small 1, для medium 4.
Этот тип интента используется виджетом как источник данных.
Далее настроенный класс интента необходимо поставить в конфигурацию IntentConfiguration.
struct CardListWidget: Widget {
public var body: some WidgetConfiguration {
IntentConfiguration(kind: WidgetConstants.widgetKind,
intent: DynamicMultiSelectionIntent.self,
provider: CardListProvider()) { entry in
CardListEntryView(entry: entry)
}
.configurationDisplayName("Быстрый доступ")
.description("Карты, которые вы используете чаще всего.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
В случае, если пользовательские настройки не требуются, то есть альтернатива в виде класса StaticConfiguration, которая работает без указания интента.
Изменяемыми на экране настроек являются title и description.
Название у виджета должно помещаться на одну строку, иначе оно обрезается. При этом допустимая длина для экрана добавления и настроек виджета отличаются.
Примеры максимальной длины названия для некоторых устройств:
iPhone 11 Pro Max
28 для настроек
21 для меню добавления
iPhone 11 Pro
25 для настроек
19 для меню добавления
iPhone SE
24 для настроек
19 для меню добавления
Описание является многострочным. В случае очень длинного текста в настройках контент можно проскролить. А вот на экране добавления сначала сжимается превью у виджета, а потом с версткой происходит что-то страшное.
Также можно изменить цвет фона и значения параметров WidgetBackground и AccentColor — по умолчанию они уже лежат в Assets. При необходимости их можно переименовать в конфигурации виджета в Build Settings у группы Asset Catalog Compiler — Options в полях Widget Background Color Name и Global Accent Color Name соответственно.
Некоторые параметры могут быть скрыты (или показаны) в зависимости от выбранного значения в другом параметре через настройку Relationship.
Стоит отметить, что UI для редактирования параметра зависит от его типа. К примеру, если укажем Boolean, то мы увидим UISwitch, а если Integer, то тут у нас уже выбор из двух вариантов: ввод через UITextfield или пошаговое изменение через UIStepper.
Взаимодействие с основным приложением.
Связку настроили, осталось определить, откуда сам интент возьмет реальные данные. Мостик с основным приложением в этом случае — файл в общей группе (App Groups). Основное приложение пишет, виджет — читает.
Для получения URL к общей группе используется следующий метод:
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: “group.ru.yourcompany.yourawesomeapp”)
Сохраняем всех кандидатов, так как они будут использоваться пользователем в настройках как словарь для выбора.
Далее операционная система должна узнать, что данные обновились, для этого вызываем:
WidgetCenter.shared.reloadAllTimelines()
// Или WidgetCenter.shared.reloadTimelines(ofKind: "kind")
Так как вызов метода будет перезагружать контент виджета и весь таймлайн, используйте его, когда данные действительно обновились, чтобы лишний раз не нагружать систему.
Обновление данных
В целях бережного отношения к батарейке пользовательского девайса Apple продумали механизм обновления данных на виджете с использованием timeline — механизма генерации слепков (snapshot). Напрямую разработчик не обновляет и не управляет view, но зато предоставляет расписание, руководствуясь которым, операционная система нарежет снапшотов в бэкграунде.
Обновление происходит по следующим событиям:
- Вызов используемого ранее WidgetCenter.shared.reloadAllTimelines()
- При добавлении виджета пользователем на рабочий стол
- При редактировании настроек.
Также в распоряжении разработчика три вида политик по обновлению таймлайнов (TimelineReloadPolicy):
atEnd — обновление после показа последнего cнапшота
never — обновление только в случае принудительного вызова
after(_:) — обновление через определенный промежуток времени.
В нашем случае достаточно попросить систему сделать один снепшот до момента, пока данные карт не обновились в основном приложении:
struct CardListProvider: IntentTimelineProvider {
public typealias Intent = DynamicMultiSelectionIntent
public typealias Entry = CardListEntry
public func placeholder(in context: Context) -> Self.Entry {
return CardListEntry(date: Date(), cards: testData)
}
public func getSnapshot(for configuration: Self.Intent, in context: Self.Context, completion: @escaping (Self.Entry) -> Void) {
let entry = CardListEntry(date: Date(), cards: testData)
completion(entry)
}
public func getTimeline(for configuration: Self.Intent, in context: Self.Context, completion: @escaping (Timeline<Self.Entry>) -> Void) {
let cards: [WidgetCard]? = configuration.cards?.compactMap { card in
let id = card.identifier
let storedCards = SharedStorage.widgetRepository.restore()
return storedCards.first(where: { widgetCard in widgetCard.id == id })
}
let entry = CardListEntry(date: Date(), cards: cards ?? [])
let timeline = Timeline(entries: [entry], policy: .never)
completion(timeline)
}
}
struct CardListEntry: TimelineEntry {
public let date: Date
public let cards: [WidgetCard]
}
Более гибкий вариант пригодился бы при использовании автоматического алгоритма подбора карточек в зависимости от дня недели и времени.
Отдельно стоит отметить показ виджета, если он находится в стэке из виджетов (Smart Stack). В этом случае для управления приоритетами мы можем воспользоваться двумя вариантами: Siri Suggestions или через установку значения relevance у TimelineEntry с типом TimelineEntryRelevance. TimelineEntryRelevance содержит два параметра:
score — приоритет текущего снапшота относительно других снапшотов;
duration — время, пока виджет остается актуальным и система может поставить его на верхнюю позицию в стэке.
Оба способа, а также возможности конфигурации виджета, были подробно рассмотрены на сессии WWDC.
Также необходимо рассказать о том, как поддерживать актуальное отображение даты и времени. Так как мы не можем регулярно обновлять содержимое виджета, то для компонента Text было добавлено несколько стилей. При использовании стиля система автоматически обновляет содержимое компонента, пока виджет находится на экране. Возможно, в будущем такой же подход распространится и на другие SwiftUI компоненты.
Text поддерживает следующие стили:
relative — разница времени между текущей и заданной датами. Тут стоит отметить: если дата указана в будущем, то начинается обратный отсчет, а после показывается дата от момента достижения нуля. Такое же поведение будет и для следующих двух стилей;
offset — аналогично предыдущему, но есть индикация в виде префикса с ±;
timer — аналог таймера;
date — отображение даты;
time — отображение времени.
Кроме этого, есть возможность отобразить промежуток времени между датами, просто указав интервал.
let components = DateComponents(minute: 10, second: 0)
let futureDate = Calendar.current.date(byAdding: components, to: Date())!
VStack {
Text(futureDate, style: .relative)
.multilineTextAlignment(.center)
Text(futureDate, style: .offset)
.multilineTextAlignment(.center)
Text(futureDate, style: .timer)
.multilineTextAlignment(.center)
Text(Date(), style: .date)
.multilineTextAlignment(.center)
Text(Date(), style: .time)
.multilineTextAlignment(.center)
Text(Date() ... futureDate)
.multilineTextAlignment(.center)
}
Превью виджета
При первом отображении виджет будет открыт в режиме превью, для этого нам необходимо вернуть TimeLineEntry в методе placeholder(in:). В нашем случае это выглядит так:
func placeholder(in context: Context) -> Self.Entry {
return CardListEntry(date: Date(), cards: testData)
}
После чего к view применяется модификатор redacted(reason:) с параметром placeholder. При этом элементы на виджете отображаются размытыми.
Мы можем отказаться от этого эффекта у части элементов, использовав unredacted() модификатор.
Также в документации сказано, что вызов метода placeholder(in:) происходит синхронно и результат должен вернуться максимально быстро, в отличие от getSnapshot(in:completion:) и getTimeline(in:completion:)
Скругление элементов
В гайдлайнах рекомендуется согласовать скругление у элементов со скруглением виджета, для этого в iOS 14 была добавлена структура ContainerRelativeShape, которая позволяет применить к view форму контейнера.
.clipShape(ContainerRelativeShape())
Поддержка Objective-C
В случае необходимости добавить в виджет код на Objective-C (например, у нас на нем написана генерация изображений штрихкодов) всё происходит стандартным способом через добавление Objective-C bridging header. Единственная проблема, с которой мы столкнулись — при сборке Xcode перестал видеть автогенерируемые файлы интентов, поэтому мы также добавили их в bridging header:
#import "DynamicCardSelectionIntent.h"
#import "CardSelectionIntent.h"
#import "DynamicMultiSelectionIntent.h"
Размер приложения
Тестирование проводилось на Xcode 12 beta 6
Без виджета: 61.6 Мб
С виджетом: 62.2 Мб
Резюмирую основные моменты, которые рассмотрели в статье:
- Виджеты — отличная возможность пощупать SwiftUI на практике. Добавляйте их в проект, даже если минимальная поддерживаемая версия ниже iOS 14.
- WidgetBundle используется для увеличения числа доступных виджетов, вот отличный пример как много различных виджетов имеет приложение ApolloReddit.
- Для добавления пользовательских настроек на самом виджете поможет IntentConfiguration или StaticConfiguration, если пользовательские настройки не нужны.
- Общая папка на файловой системе в общей группе App Groups поможет синхронизировать данные с основным приложением.
- На выбор разработчику предоставляется несколько политик обновления таймлайна (atEnd, never, after(_:)).
На этом тернистый путь разработки виджета на бета-версиях Xcode можно считать завершенным, остался один простой шаг — пройти ревью в App Store.
Спасибо, что дочитали до конца, буду рад предложениям и комментариям. Пройдите, пожалуйста, небольшой опрос, посмотрим, насколько виджеты популярны среди пользователей и разработчиков.
Комментариев нет:
Отправить комментарий