Приветствую! Недавно на существующем экране появился новый функционал, появление которого дизайн решил обыграть.
![Не судите строго за края шторки, не хотел тратить время Не судите строго за края шторки, не хотел тратить время](https://habrastorage.org/getpro/habr/upload_files/1c4/3f9/463/1c43f946329aa32dfb529aa201414837.png)
Суть в следующем - открываем экран, появляется шторка с описанием нового функционала и одновременно срабатывает подсветка добавленных элементов. И так, включаем lil peep - spotlight, надеваем любимую футболку трешер и начинаем решать проблему подсветки элементов.
Как я изначально увидел декомпозицию этой задачи:
Получить область / границы нужного элемента (который будет подсвечен);
Использовать полученные данные, чтобы корректно посвечивать контент, да еще и с минимальными усилиями;
Передать полученные данные в элемент, который будет отвечать за отображение;
Написать шторку, которая будет триггерить отображение.
#1. Получение области представления выделяемого элемента
Получение (и последующую передачу) области представления решил сделать через preferenceKey. Написал структуру для ключа элемента онбординга, значением в которой является словарь:
/// PreferenceKey элемента онбординга, который нужно подсветить
public struct OnboardingHighlightElementKey: PreferenceKey {
// MARK: - Static Properties
public static var defaultValue: [Int: OnboardingHighlightElement] = [:]
// MARK: - Static Functions
public static func reduce(
value: inout [Int: OnboardingHighlightElement],
nextValue: () -> [Int: OnboardingHighlightElement]
) {
value.merge(nextValue()) { $1 }
}
}
/// Модель данных элемента онбординга, которые нужно будет передать
public struct OnboardingHighlightElement: Identifiable {
public let anchor: Anchor<CGRect>
public let id: Int
public let radius: CGFloat
}
Дальше, нужно собирать данные для передачи. И конечно, то что нас интересует больше всего - это геометрия. Здесь воспользуемся 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)
]
}
}
}
Дальше нужно просто повесить наше расширение на элемент, который нужно подсветить:
private enum Constants {
enum HighlightElement {
static let id = 1
static let cornerRadius: CGFloat = 6
}
}
struct SomeContentView: View {
var body: some View {
VStack {
...
NewElementView()
.onboardingHighlightElement(
Constants.HighlightElement.id,
radius: Constants.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>,
isTransactionDisabled: Bool = false,
onDismiss: (() -> Void)? = nil,
content: @escaping () -> Content
) -> some View {
fullScreenCover(isPresented: isPresented, onDismiss: onDismiss) {
ZStack {
content()
}
.background(ClearBackground())
}
.transaction { transaction in
if isTransactionDisabled {
transaction.animation = nil
transaction.disablesAnimations = true
}
}
}
Не вдаемся в подробности сокрытия, они думаю и так понятны. Нас интересует магия, которая происходит в ClearBackground
. А там, то к чему иногда приходится прибегать в SwiftUI, даже в 2024 - UIViewRepresentable
. И так, код:
public struct ClearBackground: UIViewRepresentable {
// MARK: - Init
public init() {}
// MARK: - Functions
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 {
// MARK: - Computed Properties
@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: $bindingПроперти на показ, 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. Считывание / Передача данных в элемент, который будет отвечать за отображение
Мы пока очень хорошо идем. Давайте вспомним, что мы имеет:
Элемент онбординга, который нужно подсветить, мы определили, и даже пометили с помощью prefenceKey;
На стороне отображения, если к нам что-то придет, то мы будем готовы это обработать и показать.
Значит осталось только соединить эти два островка маленьким и красивым мостиком. И в качестве этого мостика идеально впишется 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 на элемент, добавим шторку туда же:
private enum Constants {
enum HighlightElement {
static let id = 1
static let cornerRadius: CGFloat = 6
}
}
struct SomeContentView: View {
@ObservedObject var viewModel: SomeContentViewModel
var body: some View {
VStack {
...
NewElementView()
.onboardingHighlightElement(
Constants.HighlightElement.id,
radius: Constants.HighlightElement.cornerRadius
)
...
}
.onboardingBottomSheet(
$viewModel.onboardingViewModel,
onDismiss: viewModel.didDismissOnboarding
)
}
}
Заключение
Таким, как мне кажется, довольно не хитрым способом мы решили задачу с подсветкой конкретного элемента экрана. Но стоит обратить внимание на следующий возможный баг - если дизайн запросит задержку перед показом онбординга (даже 0,4 сек.), то у юзера появится "окно для действия" и он может успеть проскроллить ваш экран (если это доступно) до момента когда элемент онбординга окажется за safeArea. В таком случае, подсветка обработает так же по контуру элемента, но будет светить поверх навБара, что будет чистым багом. На данном этапе я вижу несколько путей решения этой проблемы:
Отказаться от задержки - подсветить о технической сложности реализации задержки и попросить дизайн отказаться от нее;
Сократить время до минимума - допустим 0,2 сек и дизайблить контент на этот промежуток времени. За такой короткий срок юзер не успеет понять, и не поймет, что его действия блочат.
Анимировать момент показа онбординга - добавить плавное затемнение контента с плавным показом шторки (как раз на эти 0,4 сек). На время показа так же дизейблить контент, но у юзера будет некая приятная анимация и это не будет восприниматься как баг. Дизейблить контент нужно будет, чтобы юзер случайно не скипнул наш онбординг, а закрыл его тогда когда показ отработает.
Список литературы: