Всем привет!

В этой статье я бы хотел рассказать свой опыт создания липких заголовков или 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 за исходным кодом.

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

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