Всем привет!
В этой статье я бы хотел рассказать свой опыт создания липких заголовков или 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 также хотят себе липких хедеров в приложении!
Значит будем выкручиваться костылями!
Разработчики, которые не пишут или почти не пишут на 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 за исходным кодом.
Если остались дополнительные вопросы, пишите в комментариях, обязательно отвечу.