ScrollView с прилипающим выделенным элементом на SwiftUI

от автора

Привет! Меня зовут Юля, я iOS-разработчик и накануне Нового года дизайнеры подарили мне макеты к новой фиче, посмотрев на которые я облегченно вздохнула: просто ScrollView, в котором есть просто один выделенный элемент, который при скролле вверх просто приклеивается к верхней границе этого самого ScrollView. Делов-то…

А делов оказалось на полтора дня. Потому что примерно на десятой ссылке всемогущий Гугл возмущенно развел руками: “На SwiftUI все порядочные люди делают ScrollView с приклеивающимся хедером. А чтобы какой ни попадя элемент прилеплять — это вы безобразие какое-то придумали…”.

Вообщем, стало понятно, что списать эту домашку не получится. Поэтому пришлось делать самой. И теперь хочу ей поделиться — чтобы ваши домашки готовились быстрее.

А все дело в том, что в завершившемся году стек проекта, над которым я работаю совместно со своими коллегами, пополнился новым фреймворком, и новые UI-компоненты у нас теперь “модно-молодежно” реализуются на SwiftUI.

Внедрение новой технологии — это всегда увлекательно и интересно, но стоит признать, что иногда довольно простая на первый взгляд задача может серьезно озадачить пушистый разработческий мозг.

Какие варианты?

Прислушавшись к тому, что подскажет мне сердце, я получила два варианта решения:

  • адаптировать UIScrollView для использования в SwiftUI через UIViewRepresentable

  • реализовать кастомную View на SwiftUI

Ранее мне довелось адаптировать несколько не очень простых кастомных UIView для SwiftUI через UIViewRepresentable. И, честно говоря, вспоминая то художественное хождение по граблям, я не очень хотела ввязываться в такую же историю для UIScrollView: очевидно, что одной реализацией протокола UIViewRepresentable здесь не обойтись, и нужно будет разбираться еще и с методами для UIScrollViewDelegate.

Поэтому можете считать меня плохим самураем, но я сочла этот путь сложным и выбрала второй способ.

ScrollView на SwiftUI. Начало

Начала я с того, что сделала простую View для того, чтобы в дальнейшем использовать ее в качестве элемента ScrollView:

struct ItemView: View {     let index: Int     let isSelected: Bool          var body: some View {         RoundedRectangle(cornerRadius: 8)             .foregroundColor(isSelected ? Color.green : Color.gray)             .frame(height: 50)             .overlay(                 Text("Item \(index)")                     .foregroundColor(Color.white)             )     } }

Возможно, в этом месте вы посчитаете меня не только плохим самураем, но еще и плохим дизайнером, но для демонстрационных целей дизайн вполне достаточный, я считаю:

Теперь можно сделать ScrollView, в котором элементы ItemView будут выделяться по тапу:

struct PinnedItemScrollView: View {     @State private var selectedItemIndex: Int?          var body: some View {         VStack {             Spacer()             Text("ScrollView with pinned selected item")                              ScrollView {                 VStack(spacing: 8) {                     ForEach(1..<21) { index in                         ItemView(index: index, isSelected: index == selectedItemIndex)                             .onTapGesture {                                 withAnimation {                                     selectedItemIndex = index                                 }                             }                     }                 }                 .padding()             }             .background(Color.white)         }     } }

Пока наш ScrollView просто скроллится и никак не отслеживает выделенный элемент:

Собственно, на этом подготовительный этап завершен и можно доставать напильник — будем дорабатывать эту заготовку.

А теперь к сути: останавливаем элемент в ScrollView

Основная идея реализации прилипания выделенного элемента ScrollView к верхней (видимой) границе заключается в том, чтобы в зависимости от значения оффсета этого элемента показывать для него соответствующую View поверх всего ScrollView в ZStack.

Таким образом, первое, что нужно сделать — это научить ScrollView отслеживать положение выделенного элемента. Для этого потребуется передать данные о положении выделенного элемента в ScrollView.

Чтобы это сделать, воспользуемся механизмом предпочтений (preferences) в SwiftUI, который позволяет передавать данные по иерархии от дочерней View (в нашем случае ItemView) в родительскую View (в нашем случае PinnedItemScrollView).

Создадим ключ настройки OffsetPreferenceKey:

struct OffsetPreferenceKey: PreferenceKey {     typealias Value = CGFloat     static var defaultValue: CGFloat = 0.0          static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {         value += nextValue()     } }

Этот ключ будем использовать для определения вертикального оффсета (Y-координаты) ItemView.

Для ItemView зададим в качестве фона GeometryReader, чтобы установить исходный вертикальный оффсет для ItemView,и при изменении вертикального оффсета ItemView будем сохранять это значение для текущего выделенного элемента в переменную selectedItemOffset:

struct PinnedItemScrollView: View {     @State private var selectedItemIndex: Int?     @State private var selectedItemOffset: CGFloat?          var body: some View {         VStack {             Spacer()             Text("ScrollView with pinned selected item")                              ScrollView {                 VStack(spacing: 8) {                     ForEach(1..<21) { index in                         ItemView(index: index, isSelected: index == selectedItemIndex)                             .onTapGesture {                                 withAnimation {                                     selectedItemIndex = index                                     selectedItemOffset = nil                                 }                             }                             .background(                                 GeometryReader { geometry in                                     Color.clear                                         .preference(key: OffsetPreferenceKey.self,                                                     value: geometry.frame(in: .global).minY)                                 }                             )                             .onPreferenceChange(OffsetPreferenceKey.self) { value in                                 if index == selectedItemIndex {                                     selectedItemOffset = value                                 }                             }                     }                 }                 .padding()             }             .background(Color.white)         }     } }

Использование GeometryReader в качестве фона позволяет получить доступ к фрейму ImageView, поскольку в этом случае их размеры будут одинаковыми.

Исходным вертикальным оффсетом для ItemView будем считать его верхнюю границу, которой соответствует минимальная Y-координата фрейма View (geometry.frame(in: .global).minY).

selectedItemOffset — опциональная переменная, значение которой при тапе по ItemView устанавливается в nilдля дальнейшего определения необходимости приклеивания ItemView к верхней границе PinnedItemScrollView.

Зная оффсет для ItemView, можно определить момент, когда выделенный элемент должен быть приклеен к границе PinnedItemScrollView. А делать это нужно тогда, когда верхняя граница фрейма ImageViewсовпала с верхней границей фрейма PinnedItemScrollView или сместилась выше этой границы, т.е. когда минимальная Y-координата фрейма ItemView для выделенного элемента становится не больше минимальной Y-координаты фрейма PinnedItemScrollView в том же координатном пространстве.

Чтобы получить доступ к фрейму PinnedItemScrollView используем еще один GeometryReader, но уже для ScrollView. И для того, чтобы создать эффект прилипания выделенной ItemView при выполнении указанного выше условия будем показывать эту ItemView поверх ScrollView в ZStack:

GeometryReader { geometry in                 ZStack {                     ScrollView {                         VStack(spacing: 8) {                             ForEach(1..<21) { index in                                 ItemView(index: index, isSelected: index == selectedItemIndex)                                     .onTapGesture {                                         withAnimation {                                             selectedItemIndex = index                                             selectedItemOffset = nil                                         }                                     }                                     .background(                                         GeometryReader { geometry in                                             Color.clear                                                 .preference(key: OffsetPreferenceKey.self,                                                             value: geometry.frame(in: .global).minY)                                         }                                     )                                     .onPreferenceChange(OffsetPreferenceKey.self) { value in                                         if index == selectedItemIndex {                                             selectedItemOffset = value                                         }                                     }                             }                         }                         .padding()                     }                     .background(Color.white)                                          // Pinned selected item view to the top of ScrollView                     VStack {                         if let selectedItemIndex,                            let selectedItemOffset,                            selectedItemOffset < geometry.frame(in: .global).minY {                             withAnimation {                                 ItemView(index: selectedItemIndex, isSelected: true)                                     .padding([.top], 0.0)                                     .padding([.leading, .trailing], 16.0)                             }                         }                                                  Spacer()                     }                 }             }

Выделенный элемент при скролле, наконец, приклеивается к границе ScrollView, и в принципе, можно откупоривать недопитое новогоднее шампанское:

Последние штрихи

Однако если присмотреться, можно заметить, что прокручиваемые элементы видны под закрепленным выделенным элементом. Это происходит из-за скругленных углов ItemView. И если самурай и дизайнер из меня такие себе, то перфекционист — мое второе имя, поэтому я пока не убираю напильник и немного доработаю ItemView таким образом, чтобы в выделенном состоянии дополнительно добавить фон, который будет скрывать прокручиваемые элементы под прилепленной ItemView.

К этому моменту Google стал уже более сговорчив и для реализации такого фона любезно предложил мне воспользоваться услугами StackOverflow (приведенная структура RoundedCorner заимствована отсюда):

struct RoundedCorner: Shape {     var radius: CGFloat = .infinity     var corners: UIRectCorner = .allCorners      func path(in rect: CGRect) -> Path {         let path = UIBezierPath(roundedRect: rect,                                 byRoundingCorners: corners,                                 cornerRadii: CGSize(width: radius, height: radius))         return Path(path.cgPath)     } }

Дальше эту структуру применяю к своему незатейливому дизайну:

struct ItemView: View {     let index: Int     let isSelected: Bool          var body: some View {         ZStack {             if isSelected {                 Rectangle()                     .clipShape(                         RoundedCorner(radius: 8, corners: [.bottomLeft, .bottomRight])                     )                     .foregroundColor(Color.white)             }                          RoundedRectangle(cornerRadius: 8)                 .foregroundColor(isSelected ? Color.green : Color.gray)                 .overlay(                     Text("Item \(index)")                         .foregroundColor(Color.white)                 )         }         .frame(height: 50)     } }

Ну, вот — теперь и самураи с дизайнерами целы, и перфекционисты сыты:

Напильник откладываем, шампанское наливаем, а готовый проект при необходимости скачиваем здесь.


ссылка на оригинал статьи https://habr.com/ru/articles/784768/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *