Если вам когда-нибудь приходила задача сделать страницу профиля как в популярных социальных сетях, то вы понимаете всю боль верстки такого дизайна на SwiftUI — особенно для версий iOS ниже 16. Готовые решения не гуглятся, полностью рабочих репозиториев нет, ИИ ещё не умеют в такие комплексные задачи, а Telegram скрывает похожий лэйаут за внутренними библиотеками. Давайте разбираться, что тут можно придумать.

Можете сразу заглянуть в репозиторий, чтобы понимать, о чем будем говорить

Финальный вариант (к чему стремимся)
Финальный вариант (к чему стремимся)

Требования

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

  1. Статичный Navigation bar

  2. Область с информацией о пользователе (header) — реагирует на вертикальный скролл, скрывается при скролле вверх, не должна перехватывать горизонтальные жесты

  3. Tab bar (sticky header) — скроллится вместе с хэдером, при достижении navigation bar — «прилипает»

  4. Контент (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). А именно:

  1. Через метод делегата gridDidScroll мы передаем смещение offset коллекции в ProfileViewController

  2. В ProfileViewController координируем максимумы, на которые можем сдвигать хэдер с таб баром, и задаем новое значение headerTopConstraint

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