Приветствую! Недавно на существующем экране появился новый функционал, появление которого дизайн решил обыграть.

Не судите строго за края шторки, не хотел тратить время
Не судите строго за края шторки, не хотел тратить время

Суть в следующем - открываем экран, появляется шторка с описанием нового функционала и одновременно срабатывает подсветка добавленных элементов. И так, включаем 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 сек). На время показа так же дизейблить контент, но у юзера будет некая приятная анимация и это не будет восприниматься как баг. Дизейблить контент нужно будет, чтобы юзер случайно не скипнул наш онбординг, а закрыл его тогда когда показ отработает.

Список литературы:

Комментарии (0)