Sticky Header в SwiftUI

от автора

Всем привет!

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/752670/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *