Если вам когда-нибудь приходила задача сделать страницу профиля как в популярных социальных сетях, то вы понимаете всю боль верстки такого дизайна на SwiftUI — особенно для версий iOS ниже 16. Готовые решения не гуглятся, полностью рабочих репозиториев нет, ИИ ещё не умеют в такие комплексные задачи, а Telegram скрывает похожий лэйаут за внутренними библиотеками. Давайте разбираться, что тут можно придумать.
Можете сразу заглянуть в репозиторий, чтобы понимать, о чем будем говорить
Требования
Сформулируем требования сразу, чтобы понимать, какие задачи перед нами стоят.
-
Статичный Navigation bar
-
Область с информацией о пользователе (header) — реагирует на вертикальный скролл, скрывается при скролле вверх, не должна перехватывать горизонтальные жесты
-
Tab bar (sticky header) — скроллится вместе с хэдером, при достижении navigation bar — «прилипает»
-
Контент (UICollectionView) — вертикальный скролл, горизонтальный paging между страницами (посты / лайки / и т.д.)
Ключевая сложность тут — одновременная работа вертикального и горизонтального скроллов и синхронизация их состояний.
Способы решения
Когда впервые видишь эту задачу, кажется, что вариантов много. На практике же всё немного сложнее. Я перепробовала несколько путей решения задачи, опишу вкратце по порядку, что пыталась сделать и что не получилось.
1. SwiftUI + GeometryReader
Идея простая — отслеживаем offset скролла, прокидываем его в хэдер, двигаем весь View через .offset(). Но тут быстро всплывают проблемы:
-
неидеальная синхронизация смещения коллекции и всей view
-
невозможна тонкая настройка позиций между
pages, поэтому происходят резкие скачки views -
невозможна адекватная настройка вертикального жеста (скролла) на хэдере
2. SwiftUI + два ScrollView
Второй вариант — разнести жесты: один ScrollView — горизонтальный paging, второй — вертикальный контент.
Проблема тут чисто платформенная — iOS 15:
-
нет нормального доступа к offset
-
нельзя гибко управлять приоритетами жестов
-
сложно синхронизировать состояние между несколькими скроллами Начиная с iOS 18, ситуация стала сильно лучше — вот хороший туториал: https://www.youtube.com/watch?v=c6lMKH5uKy8. Но для iOS 15 — увы, мимо.
3. UIKit + два ScrollView
Я попробовала повторить идею выше на UIKit. Почти получилось, но жесты между скроллами передавались только со второго касания.
А UX тут критичный — пользователь не должен «пробовать ещё раз».
4. UIKit + ручное управление offset’ами
Вернулась к первому варианту, но уже на UIKit — и тут дело наконец сдвинулось с мёртвой точки.
Общая идея и структура решения
Иерархия представлений получилась следующая:
UINavigationController
└── ProfileViewController
├── HeaderContainerView
│ └── UserInfoView
├── MenuBar
└── ProfilePageViewController (UIPageViewController)
├── GridViewController (ScrollableViewController)
│ └── UICollectionView
├── GridViewController (ScrollableViewController)
│ └── UICollectionView
└── GridViewController (ScrollableViewController)
└── UICollectionView
Главная идея в том, чтобы не класть UIPageViewController в UIScrollView, иначе UIPageViewController теряет свои размеры и не отображает контент, который потом в него положишь (UICollectionView). Поэтому UIPageViewController занимает весь экран, а вся логика «движения хедера» реализуется через констрейнты поверх него.
Реализация прилипания Таб бара (Sticky header)
Данное прилипание достигается за счет управления констрейнтом хэдера (к bottomAnchor которого прикреплен tab bar). А именно:
-
Через метод делегата
gridDidScrollмы передаем смещениеoffsetколлекции вProfileViewController -
В
ProfileViewControllerкоординируем максимумы, на которые можем сдвигать хэдер с таб баром, и задаем новое значениеheaderTopConstraint -
Сохраняем позицию в параметр
lastContentOffset, который передается на другие вкладки коллекции (чтобы позиция хэдера с таб баром сохранялась между пейджами)
Ключевые параметры:
minTopOffset — дальше которого хэдеру уезжать нельзя
maxTopOffset — начальное положение
lastContentOffset — сохраняется и перекидывается на другие вкладки, чтобы при свайпе между страницами хэдер не «прыгал»
Что делать с жестами на HeaderContainerView
В нашей иерархии сразу три потенциальных источника жестов:
UICollectionView — вертикальный скролл
UIPageViewController — горизонтальный скролл
HeaderContainerView — который вообще не должен ничего ловить
В такой иерархии жесты коллекции и пейджа никак не конфликтуют, поэтому тут все хорошо. А вот с жестами для HeaderContainerView нужно поработать. Для того, чтобы он пропускал жесты сквозь себя просто выключаем isUserInteractionEnabled.
private lazy var headerContainerView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.isUserInteractionEnabled = false
return view
}()
Сейчас у нас доступны вертикальный скролл самой коллекции и горизонтальный скролл от UIPageViewController, который нужно отключить. Для этого мы рассчитываем координаты HeaderContainerView.
-
находим
UIScrollViewвнутриUIPageViewController -
добавляем
panGestureRecognizer -
в
gesture.state == .beganпроверяем: находится ли жест в зоне хэдера и скрыт ли хэдер полностью -
и временно выключаем
scrollView.isScrollEnabled
Настройка размера коллекции
Высота коллекции определяется высотой, занимаемой ее контентом. Поэтому в случае, когда у нас мало элементов в коллекции, а мы хотим сдвинуть ее вверх, чтобы скрыть хэдер, у нас это сделать не получится — коллекция будет выталкиваться вниз.
Решение — искусственно увеличить scrollable area посредством увеличения внутреннего отступа снизу (bottom inset)
Производим нехитрые расчеты:
private func setupBottomInset() {
let contentHeight = collectionView.collectionViewLayout.collectionViewContentSize.height
let visibleHeight = collectionView.bounds.height
let targetHeight = visibleHeight - constant.headerHeight
if contentHeight < targetHeight {
let extra = visibleHeight - contentHeight - (-constant.minTopOffset)
collectionView.contentInset.bottom = extra
} else {
collectionView.contentInset.bottom = 20
}
}
Дополнительные настройки
В коде также есть много флагов и условий, которые я не описала — это просто дополнительная отладка позиций скролла и пейджей при инициализации или перелистывании. У UICollectionView и UIPageViewController есть свои особенности жизненного цикла, которые нужно учитывать.
Заключение
Мы реализовали все требования, благодаря правильному выбору иерархии представлений, магии простых вычислений и кастомных жестов.
Дублирую ссылку на репозиторий: https://github.com/talazaren/ProfilePage
Это не библиотека, а референс, который можно спокойно адаптировать под свои задачи (づ ◕‿◕ )づ
Автор: talazaren
