Всем привет! Меня зовут Саша, я iOS-разработчик компании Ozon. Я занимаюсь разработкой и развитием мобильного приложения продавца. Сегодня хотел бы поделиться опытом нашей команды по кастомизации онбординга для вашего мобильного приложения.
Представьте, ваша команда несколько недель разрабатывает фичу, которая ну очень упростит жизнь пользователю. Вы уже готовы её выкатить, но возникают вопросы:
Как представить новую фичу пользователю?
Как сделать так, чтобы пользователь не пропустил добавленный функционал?
Как повысить количество взаимодействий с новым функционалом?
И эти вопросы действительно важны, ведь все мы рады дополнять и улучшать наши приложения, особенно в нынешней конкурентной среде, но пользователь может просто не заметить добавленную кнопку, которая, по нашим грандиозным планам, должна была увеличить показатели продаж компании.
Также возникает вопрос упущенного этапа продажи новой фичи — подача обновления пользователю.
Наш опыт в Ozon показывает, что отличным решением по информированию пользователя о новом функционале в мобильном приложении является онбординг. Его реализацию в нашем исполнении я и хотел бы сегодня рассмотреть в данной статье.
Определение подхода
Рассмотрим существующие подходы к подаче нового функционала.
Строгое выделение нового функционала (особый цвет, размер и прочее). Такой подход работает, но сильно бьёт по пользовательскому опыту, так как это не всегда эстетично или уместно в рамках существующей дизайн-системы.
Демонстрация онбординга с краткой информацией по внесённым изменениям при обновлении. Этот вариант более выгодный с точки зрения пользовательского опыта, но из такого онбординга не всегда ясно, про что идёт речь в описании и где находится та самая заветная кнопка.
Вы можете сказать, что такая парадигма чем-то напоминает задний фон с интервью Павла Дурова, но, на мой взгляд, ничего критичного нет, скорее, она выглядит так:
Тут мы подбираемся к нашему подходу, который состоит в демонстрации онбординга с подсветкой добавленных элементов и шторкой, содержащей краткую информацию о добавленном функционале.
Сценарий отображения онбординга:
пользователь открывает страницу с новой кнопкой;
затемняется экран, и подсвечиваются новые элементы UI;
появляется шторка с кратким описанием добавленного функционала;
после закрытия шторки затемнение исчезает, как и подсветка добавленных элементов.
В интернете можно найти различные варианты реализации такого механизма. В этой статье мы предложим свою реализацию. Она будет соответствовать следующим принципам:
масштабируемость;
простота интеграции на период добавления онбординга;
простота удаления после окончания периода информирования пользователей о новом функционале;
современный стек (SwiftUI).
Реализацию такого механизма будем рассматривать на iOS15, библиотека SwiftUI.
Итак, включаем lil peep — spotlight, надеваем любимую футболку Trasher, сдуваем пыль с прожектора, который давно без дела лежал на крыше, и начинаем решать задачи подсветки элементов.
Проведём декомпозицию этой задачи и выделим основные этапы разработки.
Получить область (границы) нужного элемента, который нужно подсветить.
Использовать полученные данные для корректной подсветки.
Передать их в элемент, который будет отвечать за отображение.
Собрать всё в шторку, которая будет выступать в качестве триггера этого функционала.
Реализация
1. Получение области представления элемента подсветки
Для получения и передачи области представления идеально подойдёт инструмент от Apple — preferenceKey. Напишем структуру для ключа элемента онбординга.
В качестве defaultValue для PreferenceKey выступает словарь, чтобы иметь в будущем возможность подсвечивать несколько элементов. Для этого каждому из них будет присвоен уникальный id.
Далее нужно собрать данные для передачи. И конечно, основа всего — это положение элемента на экране. Здесь воспользуемся anchorPreference. Напишем расширение для View:
public extension View {
/// Использование привязки для получения области границ представления
func onboardingHighlightElement(_ id: Int, radius: CGFloat = .zero) -> some View {
anchorPreference(key: OnboardingHighlightElementKey.self,value: .bounds) { anchor in
[id: OnboardingHighlightElement(anchor: anchor, id: id, radius: radius)]
}
}
}
Теперь просто добавим данную функцию на элемент UI, который хотим подсветить:
enum HighlightElement {
static let id = 1
static let cornerRadius: CGFloat = 6
}
/// Некая View на которой находится подсвечиваемый элемент
struct SomeContentView: View {
var body: some View {
VStack {
...
TargetElementView()
.onboardingHighlightElement(
HighlightElement.id,
radius: HighlightElement.cornerRadius
)
...
}
}
}
Хочу «подсветить» (ха-ха), что если элементов несколько, то id для каждого должен быть уникальный. И так как мы его прописываем руками, существует риск ошибиться и присвоить один и тот же id двум элементам, тогда подсветка сработает только на одном из них. Не упустите это из виду.
2. Подсветка элемента и использование полученных данных
Используем следующую последовательность действий:
Накрыть контент — в данном случае тёмным фоном.
Наложить маску mask — будет неким Rectangle (т.к. экран у нас квадратный).
На эту базу накладываем overlay.
В нем с помощью GeometryReader накладываем наши элементы онбординга относительно полученных данных.
Меняем дефолтный режим наложения blendMode на destinationOut.
view.mask {
Rectangle()
.ignoresSafeArea()
.overlay {
GeometryReader { proxy in
ForEach(highlightElements) { property in
let rect = proxy[property.anchor] RoundedRectangle(cornerRadius: property.radius)
.frame(width: rect.width, height: rect.height)
.position(x: rect.midX, y: rect.midY)
.blendMode(.destinationOut)
}
}
}
}
Нам нужно затемнить всю View вместе с safeArea, а сам элемент bottomSheet, который нужно будет повесить сверху, может (и, как правило, будет) располагаться ниже по иерархии, и получится ситуация, в которой затемнение сработает не на весь контент (допустим, не зацепит навбар / таббар или прочие аналогичные ситуации).
Выход? — fullScreenCover, но внимательный зритель скажет: «Как у него размыть задний фон»? Итак, схема следующая:
func clearBackground<Content: View>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
content: @escaping () -> Content
) -> some View {
fullScreenCover(isPresented: isPresented, onDismiss: onDismiss) {
ZStack {
content()
}
.background(ClearBackground())
}
}
Здесь нас интересует магия, которая происходит в ClearBackground. А там то, к чему иногда приходится прибегать в SwiftUI, даже в 2k24 — UIViewRepresentable:
public struct ClearBackground: UIViewRepresentable {
public init() {}
public func makeUIView(context: Context) -> UIView {
let view = UIView()
let vc = UIApplication.shared.firstWindow?.visibleViewController()
(vc?.presentedViewController ?? vc)?.view.backgroundColor = .clear
return view
}
public func updateUIView(_ uiView: UIView, context: Context) {}
}
public extension UIApplication {
@inlinable var firstWindow: UIWindow? {
connectedScenes
.lazy
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first(where: \.isKeyWindow)
}
}
public extension UIWindow {
func visibleViewController() -> UIViewController? {
var topController = rootViewController
while topController?.presentedViewController != nil {
topController = topController?.presentedViewController
}
if let navigationController = topController as? UINavigationController {
topController = navigationController.topViewController
}
if let tabBarController = topController as? UITabBarController {
let selectedViewController = tabBarController.selectedViewController
if let navigationController = selectedViewController as? UINavigationController {
topController = navigationController.topViewController
} else if selectedViewController != nil {
topController = selectedViewController
}
}
return topController
}
}
И давайте уже соберём всё в кучу, чтобы человек, который пришел это всё дело скопировать, понял, что нужно выделять:
EmptyView()
/// Наш контейнер с прозрачным фоном, который принимает в себя контент**
.clearBackground(isPresented: $isPresented, onDismiss: onDismiss) {
GeometryReader { geo in
ZStack(alignment: .bottom) {
/// Задний фон c нужным цветом opacity и прочим
Background()
/// Подсветка элементов
.mask {
Rectangle()
.ignoresSafeArea()
.overlay {
GeometryReader { proxy in
ForEach(highlightElements) { property in
let rect = proxy[property.anchor]
RoundedRectangle(cornerRadius: property.radius)
.frame(width: rect.width, height: rect.height)
.position(x: rect.midX, y: rect.midY)
.blendMode(.destinationOut)
}
}
}
}
content()
}
}
}
Подведём промежуточный итог:
мы определили элемент онбординга, который нужно подсветить;
подготовили обработку результата на стороне отображения элемента.
Перейдём к следующему пункту — необходимо соединить эти два островка маленьким и красивым мостиком.
3. Передача обновлённых данных в элемент, отвечающий за отображение
В качестве такого мостика идеально подойдёт backgroundPreferenceValue.
backgroundPreferenceValue(OnboardingHighlightElementKey.self) { items in
BottomSheet(highlightElements: Array(items.values))
}
Таким образом, элементы, отмеченные нами как onboardingHighlightElement, будут передавать свои данные ниже по иерархии, где их данные будут обработаны.
4. Написание шторки, которая будет заниматься отображением
Теперь можно сказать, что мы уже видим финишную черту. Итак, сделаем расширение для показа шторки:
public extension View {
@ViewBuilder
func onboardingBottomSheet(
item: Binding<BottomSheetData?>,
onDismiss: (() -> Void)? = nil
) -> some View {
backgroundPreferenceValue(OnboardingHighlightElementKey.self) { highlightElements in
BottomSheet(
highlightElements: Array(highlightElements.values),
onDismiss: onDismiss,
content: {
BottomSheetOnboardingView(data: item)
}
)
}
}
}
Далее используем наше расширение на нужном экране. Мы уже делали моковый экран, где добавляли id на элемент, добавим шторку туда же:
enum HighlightElement {
static let cornerRadius: CGFloat = 6
static let id = 1
}
struct SomeContentView: View {
@StateObject var viewModel = SomeContentViewModel()
var body: some View {
VStack {
...
NewElementView()
.onboardingHighlightElement(
HighlightElement.id,
radius: HighlightElement.cornerRadius
)
...
}
.onboardingBottomSheet(
$viewModel.onboardingViewModel,
onDismiss: viewModel.didDismissOnboarding
)
}
}
Технические особенности
Если дизайн запросит задержку перед показом онбординга (в нашем случае было 0,4 сек.), то у юзера появится «окно» для действия и он может успеть проскроллить ваш экран до момента, когда элемент онбординга окажется за safeArea. В таком случае подсветка обработает также по контуру элемента, но будет светить поверх навбара, что будет чистым багом. Могу выделить несколько путей решения этой проблемы:
отказаться от задержки — поделиться о технических особенностях реализации задержки и попросить дизайн отказаться от неё;
анимировать момент показа онбординга — добавить затемнение контента с плавным показом шторки (как раз на эти 0,4 сек). Пользователь будет наблюдать приятную анимацию, воспринимая её как запланированный ивент, а на время показа мы будем блокировать взаимодействие с контентом. Дизейблить контент нужно будет, чтобы юзер случайно не скипнул наш онбординг, а закрыл его тогда, когда показ отработает.
Заключение
Таким, как мне кажется, довольно нехитрым способом мы решили задачу с подсветкой конкретного элемента экрана. С точки зрения кода, разработанное решение:
легко встраивается в существующий функционал — спокойно интегрируется на любом уровне в иерархии View;
масштабируется — в случае необходимости смены подсвечиваемых элементов или других кейсов;
не используется сложных механизмов, которые могли бы плохо влиять на производительность экрана.
Таргет нашего приложения — iOS15, в связи с этим в статье не рассматривался вариант внедрения TipKit, так как библиотека свежая и держит таргет iOS17+. Вариант, как ею пользоваться, рассматривали ребята из OTUS в своей статье.