Привет! Меня зовут Юля, я 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) } }
Ну, вот - теперь и самураи с дизайнерами целы, и перфекционисты сыты:

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