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