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

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

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

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

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

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

e1af5c72d715272eec914982871dc4f7.jpeg

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

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

  • адаптировать 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)
            )
    }
}

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

1ca692677ce86a017add5218a046fd3d.png

Теперь можно сделать 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 просто скроллится и никак не отслеживает выделенный элемент:

3ecd4e9126604c45ea40093a1f3e376d.gif

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

А теперь к сути: останавливаем элемент в 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, и в принципе, можно откупоривать недопитое новогоднее шампанское:

e66028848401cd1a519c946a64e75ae7.gif

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

Однако если присмотреться, можно заметить, что прокручиваемые элементы видны под закрепленным выделенным элементом. Это происходит из-за скругленных углов 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)
    }
}

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

a106208275506e3a2b0826b5b8221662.gif

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

© Habrahabr.ru