Привет! Меня зовут Юля, я 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)
    }
}

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

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

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


  1. kotovsky_art
    05.01.2024 23:21

    Идея неплохая, но почему бы не использовать LazyVStack с pinnedViews? или религия таргет не позволяет? :)


    1. Buzly
      05.01.2024 23:21

      вроде такой подход не сработает так как pinnedViews норм для хэдеров и футеров, а тут хотели любой айтем пинить


      1. kotovsky_art
        05.01.2024 23:21

        Вызов был принят ) Собственно наскоро запилил примерчик.

        Выглядит вот так:

        Код не претендует на перфенционизм автора, но реализован через хедер секции. А если еще и с напильником, чтобы краюшки скруглить где надо...

        enum Constants {
            static let scrollViewCoordinateSpace = "ScrollView"
            static let itemHeight: CGFloat = 40
        }
        
        struct ContentView: View {
            enum ScrollDirection {
                case idle
                case up
                case down
            }
            
            @State private var selectedItemIndex: Int? = .none
            @State private var selectedItemOffset = CGFloat.zero
            @State private var scrollDirection: ScrollDirection = .idle
            
            var offsetFactor: CGFloat {
                switch scrollDirection {
                    case .idle, .down: 0
                    case .up: Constants.itemHeight
                }
            }
            
            var body: some View {
                NavigationStack {
                    ScrollView {
                        LazyVStack(pinnedViews: [.sectionHeaders]) {
                            Section {
                                ForEach(1...100, id: \.self) { count in
                                    itemView(for: count)
                                        .background(GeometryReader { proxy in
                                            if selectedItemIndex == count {
                                                Color.clear.preference(
                                                    key: ViewOffsetKey.self,
                                                    value: proxy.frame(in: .named(Constants.scrollViewCoordinateSpace)).minY
                                                )
                                            }
                                        })
                                }
                            } header: {
                                selectedPinnedView
                            }
                        }
                        .padding()
                        .onPreferenceChange(ViewOffsetKey.self) {
                            if selectedItemOffset < $0 {
                                scrollDirection = .up
                            } else {
                                scrollDirection = .down
                            }
                            
                            if selectedItemOffset <= -UIScreen.main.bounds.height 
                                && $0 < selectedItemOffset { return }
                            
                            self.selectedItemOffset = $0
                        }
                    }
                    .coordinateSpace(name: Constants.scrollViewCoordinateSpace)
                    .navigationTitle("SelectedPinnedView")
                    .navigationBarTitleDisplayMode(.inline)
                }
            }
            
            func itemView(for index: Int) -> some View {
                Button(action: {
                    selectedItemIndex = index
                })  {
                    Text("Item \(index)")
                        .foregroundStyle(.white)
                        .frame(height: Constants.itemHeight)
                        .frame(maxWidth: .infinity)
                        .background(selectedItemIndex == index ? .green : .gray)
                        .clipShape(RoundedRectangle(cornerRadius: 8))
                        .contentShape(RoundedRectangle(cornerRadius: 8))
                }
                .buttonStyle(.plain)
            }
            
            @ViewBuilder
            var selectedPinnedView: some View {
                if let selectedItemIndex, selectedItemOffset < 0 + offsetFactor {
                    itemView(for: selectedItemIndex)
                }
            }
        }

        ЗЫ.: Отредактировал onPreferenceChange, заметил, что LazyVStack убирает View когда она выходит за 1 высоту экрана, отчего offset выставлялся в 0. Но теперь все гладко и шелковисто.


  1. EgorRulka
    05.01.2024 23:21

    Крутенько, только недавно задавался вопросом, сделать на подобии