Если вам когда-нибудь приходила задача сделать страницу профиля как в популярных социальных сетях, то вы понимаете всю боль верстки такого дизайна на 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
Это не библиотека, а референс, который можно спокойно адаптировать под свои задачи (づ ◕‿◕ )づ
