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)
}
}
Ну, вот — теперь и самураи с дизайнерами целы, и перфекционисты сыты:
Напильник откладываем, шампанское наливаем, а готовый проект при необходимости скачиваем здесь.