Всем привет!

В этой статье я бы хотел рассказать свой опыт создания липких заголовков или Sticky Header с использованием SwiftUI (в дальнейшем SUI).

Мы сделаем с вами такой кастомный хедер, а так же вы поймете как мы можем получать доступ к UIKit-овой изнанке SwiftUI.

Почему я решил написать эту статью?

  • При переходе с UIKit на SwiftUI мне не хватало чувства контроля. Я не понимал, как получить доступ к состоянию моих View и как тонко и точно настраивать их поведение. Статья может быть полезна людям с такими же проблемами.

  • Sticky Header – часть почти любого мобильного приложения, а в русскоязычном интернете (да и в англоязычном тоже) очень мало информации о том как сделать кастомный липкий заголовок на SUI.

  • В SUI нет нативного и удобного способа создания такого header-а (начиная с iOS 17 в SUI добавили .visualEffect модификатор, который позволяет получить доступ к офсету скрола.)
    Но когда мы поднимем свои таргеты в реальных проектах до iOS 17 - очень большой вопрос.

Из чего же состоит экран с липким заголовком?

Предупрежу что сама реализация такого header-а в SUI отходит от парадигмы этого фреймворка (декларативность) и выполняется в императивном стиле.

Что бы наш header стал по настоящему sticky, нам надо получать состояние прокрутки (смещение по оси Y) ScrollView и смещать на такое же количество поинтов наш header, создавая эффект неподвижности.

Базовый принцип работы липкого хедера
Базовый принцип работы липкого хедера

Как в SUI можно получать смещение по оси Y ScrollView?

Те кто больше слышал о SwiftUI, чем с ним работал подумают что это супер просто, в SUI куча реактивщины, скорее всего есть какой нибудь модификатор куда можно передать Binding<CGFloat> и дело с концом.

Ответ: НЕТ! До iOS 17 такого модификатора не существует, а наши пользователи с iOS 14 также хотят себе липких хедеров в приложении!

Значит будем выкручиваться костылями!

Сочетаем скорость SwiftUI и возможности UIKIt
Сочетаем скорость SwiftUI и возможности UIKIt

Разработчики, которые не пишут или почти не пишут на SUI удивятся, ведь получать состояние скролла в UIKit очень легко, достаточно прост��... 

Реализовать методы делегата UIScrollView!

Существует целая пачка методов для UIScrollView которые покрывают практически все что мы можем пожелать.

Осталось только каким то образом заиметь нашему ScrollView такого же делегата как и UIScrollView и реализовать его методы.

К счастью, ScrollView под капотом и есть UIScrollView! 

Далее без всяких доп библиотек мы получим доступ к ScrollVIew как к UIScrollView 

struct ScrollDetector: UIViewRepresentable {
    
    //Замыкание в которое будет передаваться текущий offset
    var onScroll: (CGFloat) -> Void
    
    //Замыкание которое вызывается когда пользователь отпускает палец
    var onDraggingEnd: (CGFloat, CGFloat) -> Void
    
    
    //Класс-делегат нашего ScrollView
    class Coordinator: NSObject, UIScrollViewDelegate {
        
        var parent: ScrollDetector

        var isDelegateAdded: Bool = false
        
        init(parent: ScrollDetector) {
            self.parent = parent
        }
        
        //методы UIScrollViewDelegate
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            parent.onScroll(scrollView.contentOffset.y)
        }
        
        func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
            parent.onDraggingEnd(targetContentOffset.pointee.y, velocity.y)
        }
        
        func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
            parent.onDraggingEnd(scrollView.contentOffset.y, 0)
        }
        
        //тут могли бы быть другие методы UIScrollViewDelegate
        //так как у нас в распоряжении ПОЛНОЦЕННЫЙ ДЕЛЕГАТ от UIKit-ового UIScrollView!
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    //При создании пустой UIView находим UIScrollView и назначаем ему в делегаты наш coordinator
    func makeUIView(context: Context) -> UIView {
        let uiView = UIView()
        DispatchQueue.main.async {
            if let scrollView = recursiveFindScrollView(view: uiView), !context.coordinator.isDelegateAdded {
                scrollView.delegate = context.coordinator
                context.coordinator.isDelegateAdded = true
            }
        }
        return uiView
    }
    
    //рекурсивно перебираем родителей нашей пустой UIView в поисках ближайшего UIScrollView
    func recursiveFindScrollView(view: UIView) -> UIScrollView? {
        if let scrollView = view as? UIScrollView {
            return scrollView
        } else {
            if let superview = view.superview {
                return recursiveFindScrollView(view: superview)
            } else {
                return nil
            }
        }
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {}
}

Мы создали переиспользуемый ScrollDetector откуда мы получаем доступ к делегату UIScrollView!

Приведу пример его использования в нашем случае:

struct MainScreen: View {
    var size: CGSize
    var safeArea: EdgeInsets
    
    @State private var offsetY: CGFloat = .zero
    
    var body: some View {
        
        ScrollViewReader { proxy in
            ScrollView(showsIndicators: false) {
                VStack {
                    createHeaderView()
                        .zIndex(1)
                    
                    createMainContent()
                }
                .id("mainScrollView")
                .background {
                    ScrollDetector { offset in
                        offsetY = -offset
                    } onDraggingEnd: { offset, velocity in
                        if needToScroll(offset: offset, velocity: velocity) {
                            withAnimation(.default) {
                                proxy.scrollTo("mainScrollView", anchor: .top)
                            }
                        }
                    }
                }
            }
        }
    }

Имея полный доступ к состоянию ScrollView, мы ограничены только нашей фантазией.

Код для создания эффекта инерции и расчета положения/размера скролла:

    //данная функция создает эффект "инерции"
    private func needToScroll(offset: CGFloat, velocity: CGFloat) -> Bool {
        let headerHeight = (size.height * 0.25) + safeArea.top
        let minimumHeaderHeigth = 64 + safeArea.top
        
        let targetEnd = offset + (velocity * 45)
        
        return targetEnd < (headerHeight - minimumHeaderHeigth) && targetEnd > 0
    }
    
    //тут вся математика по расчету текущего положения/размера хедера и его контента
    @ViewBuilder
    private func createHeaderView() -> some View {
        let headerHeight = (size.height * 0.25) + safeArea.top
        let minimumHeaderHeigth = 64 + safeArea.top
        let progress = max(min(-offsetY / (headerHeight - minimumHeaderHeigth), 1), 0)
    
        GeometryReader { _ in
            ZStack {
                Rectangle()
                    .fill(Color("habrColor").gradient)
                
                VStack(spacing: 15) {
                    GeometryReader {
                        let rect = $0.frame(in: .global)
                        
                        let halfScaledHeight = (rect.height * 0.2) * 0.5
                        let midY = rect.midY
                        
                        let bottomPadding: CGFloat = 16
                        let reseizedOffsetY = (midY - (minimumHeaderHeigth - halfScaledHeight - bottomPadding))
                        
                        Image("habr")
                            .resizable()
                            .renderingMode(.template)
                            .frame(width: rect.width, height: rect.height)
                            .clipShape(Circle())
                            .foregroundColor(Color(.white))
                            .scaleEffect(1 - (progress * 0.5), anchor: .leading)
                            .offset(x: -(rect.minX - 16) * progress, y: -reseizedOffsetY * progress - (progress * 16))
                    }
                    .frame(width: headerHeight * 0.5, height: headerHeight * 0.5)
                    
                    Text("Привет, Хабр?")
                        .font(.title)
                        .fontWeight(.bold)
                        .foregroundColor(.white)
                        .scaleEffect(1 - (progress * 0.1))
                        .offset(y: -2 * progress)
                    
                }
                .padding(.top, safeArea.top)
                .padding(.bottom)
                
            }
            .shadow(color: .black.opacity(0.2), radius: 25)
            .frame(height: max((headerHeight + offsetY), minimumHeaderHeigth), alignment: .bottom)
            
        }
        .frame(height: headerHeight, alignment: .bottom)
        .offset(y: -offsetY)
    }

Если вас заинтересовал данный способ и вы хотите сделать что то похожее, добро пожаловать на мой GitHub за исходным кодом.

Если остались дополнительные вопросы, п��шите в комментариях, обязательно отвечу.