Разберём, как можно отказаться от системного UINavigationBar и собрать собственный навигационный бар на обычном UIView: с коллапсирующим large title как у Apple, со встроенной строкой поиска, с произвольными панелями под заголовком — и так, чтобы он работал не только с UITableView, но и со списками на Texture (AsyncDisplayKit).

Я делал такой компонент для крупного приложения, и он давно живёт в продакшене. Ниже — устройство и, что важнее, компромиссы: на мой взгляд, честный разбор обходных путей полезнее причёсанной версии, где всё сходится с первого раза.

Зачем нужен собственный navbar

Набор требований был такой:

  • large title, плавно сжимающийся при скролле — как системный;

  • под заголовком — строка поиска, которая тоже коллапсит при скролле, а в активном состоянии разворачивается на всю ширину;

  • под поиском — произвольные панели, часть из которых должна уезжать при скролле, часть — оставаться;

  • размытый фон (UIVisualEffectView), появляющийся и исчезающий в зависимости от позиции скролла;

  • и, что важнее всего, совместимость с разными типами скролл-контейнеров: часть экранов на UIKit, часть — на Texture (ASTableNode / ASCollectionNode).

Системный UINavigationBar с prefersLargeTitles и UISearchController в теории закрывает многое. Но как только под заголовок добавляются кастомные панели, нужно точно управлять моментом включения блюра, тонко настраивать снаппинг и при этом подружить всё с Texture — точек контроля почти не остаётся. В какой-то момент дешевле оказывается собственный UIView, который не притворяется навбаром, а просто живёт сверху экрана и сам управляет инсетами скролла.

Назовём его CollapsingNavBar. Дальше — по частям.

Анатомия: бар как вертикальный стек панелей

Раскладка прямолинейна — вертикальный UIStackView, в котором по порядку лежат панели:

stack.addArrangedSubviews(
    topPanel,          // фиксированные 44pt: left-кнопка, title, right-кнопки
    spacePanel,        // растягивающийся зазор (как у системного large title)
    largeTitlePanel,   // большой заголовок
    searchPanel,       // строка поиска
    closablePanel,     // панель, которая уезжает при скролле
    pinnedPanel,       // панель, которая остаётся
    separatorLine      // нижняя разделительная линия
)

Под фоном лежат backgroundView (сплошной цвет) и blurView (UIVisualEffectView); они подменяются в зависимости от того, раскрыт large title или нет.

Каждая панель — это UIView с констрейнтами, причём на ключевые NSLayoutConstraint мы держим слабые ссылки, чтобы менять их constant при скролле:

private weak var spacePanelHeight: NSLayoutConstraint?
private weak var largeTitleTop: NSLayoutConstraint?
private weak var searchTop: NSLayoutConstraint?
private weak var searchHeight: NSLayoutConstraint?
private weak var closablePanelTop: NSLayoutConstraint?

То есть анимация коллапса — это не работа с CALayer, а пересчёт constant у констрейнтов на каждый тик скролла. Дальше станет видно, какие из этого вытекают ограничения.

Как бар цепляется к скроллу

Основная идея: бар сам владеет contentInset.top скролла. При подключении он первым делом отбирает у системы управление инсетами:

private func sync(with scrollView: UIScrollView) {
    guard stack.bounds.height != 0 else { return }

    // дальше за инсеты, scroll-to-top и всё связанное отвечает сам бар
    scrollView.scrollsToTop = false
    scrollView.contentInsetAdjustmentBehavior = .never

    let maxHeight = expandedHeight()
    scrollView.contentInset.top = maxHeight
    scrollView.contentOffset.y = -maxHeight
}

scrollsToTop = false и contentInsetAdjustmentBehavior = .never означают, что вся ответственность за корректность инсетов теперь на компоненте.

Чтобы реагировать на изменения состояния скролла, бар подписывается на набор KVO-обзёрверов:

insetObserver       = scrollView.observe(\.contentInset)  { ... }
contentSizeObserver = scrollView.observe(\.contentSize)   { ... }
offsetObserver      = scrollView.observe(\.contentOffset) { ... }

Отдельно отмечу: сам коллапс бара завязан на KVO contentOffset, а не на scrollViewDidScroll. Так удобнее по двум причинам. Во-первых, KVO ловит любое изменение оффсета, включая программное. Во-вторых, оно не зависит от того, кто в данный момент является делегатом скролла, — а делегат понадобится для отдельной задачи (снаппинга), и совмещать обе функции в одном механизме неудобно.

expandedHeight() — измерение полностью раскрытого бара

Чтобы выставить корректный contentInset.top, нужно знать высоту полностью раскрытого бара. Поскольку бар динамический (панели появляются и исчезают), высоту проще измерить напрямую: временно выставить все констрейнты в «раскрытое» состояние, прогнать layout, снять maxY последней панели и вернуть констрейнты обратно.

private func expandedHeight() -> CGFloat {
    // на время измерения глушим собственный обзёрвер bounds стека,
    // иначе временное изменение констрейнтов спровоцирует рекурсивный пересчёт
    ignoreStackBoundsChanges = true
    defer { ignoreStackBoundsChanges = false }

    let saved = snapshotConstraints()      // запомнить текущие constant'ы
    applyExpandedConstraints()             // выставить «раскрытое» состояние
    stack.layoutIfNeeded()

    var height = (stack.arrangedSubviews.last?.frame.maxY ?? 0) + safeAreaInsets.top

    restoreConstraints(saved)              // вернуть как было
    stack.layoutIfNeeded()

    if let refreshControl = scrollView?.refreshControl, refreshControl.isRefreshing {
        height += refreshControl.bounds.height
    }
    return height
}

Флаг ignoreStackBoundsChanges здесь принципиален: бар подписан на изменение bounds стека, а в этой функции сам меняет его туда и обратно. Это сквозная особенность всего класса — компонент постоянно меняет то, на что сам же подписан, и должен аккуратно глушить собственные обзёрверы, чтобы не уйти в бесконечный цикл.

Отдельная неприятность, которую стоит упомянуть: при таком измерении высота иногда «дрожит» на один пиксель (например, чередуется 233 и 234). Достоверной первопричины я не нашёл, поэтому практическое решение — сравнивать разницу высот с порогом в 1pt и не реагировать, если она меньше. Для продакшена этого оказалось достаточно, хотя осадок остаётся.

Коллапс: последовательное сжатие панелей

Основная логика — в обзёрвере contentOffset. Считается величина «утопленности» скролла, и по ней панели сжимаются сверху вниз в строгом порядке: сначала зазор, затем строка поиска, затем нижние панели и в последнюю очередь — large title.

offsetObserver = scrollView.observe(\.contentOffset, options: [.old, .new]) { [weak self] scrollView, change in
    guard let self,
          self.searchBar?.isActive != true,
          let old = change.oldValue?.y, let new = change.newValue?.y, old != new
    else { return }

    var offset = scrollView.contentOffset.y + scrollView.contentInset.top
               - self.startOffset + self.startCollapse

    // 1. зазор между топ-панелью и заголовком — как у системного navbar
    if let c = self.spacePanelHeight {
        c.constant = max(-offset, 0)
        self.spacePanel.isHidden = c.constant == 0
    }

    // 2. строка поиска: сначала ужимаем по высоте, затем поднимаем наверх
    if let searchBar, let searchHeight, let searchTop {
        let target = min(searchBar.intrinsicContentSize.height - offset,
                         searchBar.intrinsicContentSize.height)
        searchHeight.constant = max(0, target)
        // попутно плавно гасим иконку лупы и плейсхолдер
        // ...
        offset -= (defaultSearchHeight - self.searchPanel.bounds.height)
    }

    // 3. closable-панель
    // 4. large title — поднимаем в последнюю очередь, с кросс-фейдом на обычный заголовок
    if let largeTitleTop {
        // ...
        let collapsed = self.largeTitlePanel.bounds.height < threshold
        self.crossfade(show: collapsed ? self.compactTitle : self.largeTitle,
                       hide: collapsed ? self.largeTitle : self.compactTitle)
    }
}

Здесь стоит остановиться на двух деталях.

Кросс-фейд заголовка. Когда large title сжимается ниже порога, нужно плавно показать обычный заголовок по центру топ-панели (и наоборот при обратном движении). Важный нюанс — нельзя запускать новую анимацию поверх уже идущей, иначе заголовок начинает мерцать. Поэтому у вьюх заводится флаг «анимация уже идёт», и повторный запуск отбрасывается:

private func crossfade(show: UIView, hide: UIView, duration: TimeInterval = 0.1) {
    guard show.isHidden || show.alpha != 1 else { return }
    show.isAnimating = true; hide.isAnimating = true
    show.alpha = 0; show.isHidden = false

    UIView.animate(withDuration: duration, delay: 0, options: .beginFromCurrentState) {
        show.alpha = 1
        hide.alpha = 0
    } completion: { finished in
        guard finished else { return }
        show.isAnimating = false; hide.isAnimating = false
        hide.isHidden = true
    }
}

startOffset и startCollapse. Это две переменные состояния, отвечающие на вопрос «откуда отсчитывать сжатие». Если экран открыт в самом верху списка, всё просто: бар сжимается при скролле вниз. Но если подключается скролл, уже куда-то проскролленный (например, при переключении табов), нужно учесть, что бар уже частично сжат, и не дёргать его лишний раз. Это один из самых хрупких участков логики, и в реальном коде он у меня сопровождён подробным комментарием — суть в том, что точка отсчёта сжатия пересчитывается, когда пользователь скроллит вверх, и обнуляется при достижении верха списка.

Снаппинг: доводка панели до целого состояния

Если просто сжимать панели по оффсету, то после отпускания пальца large title может остаться раскрытым, например, на 40% — выглядит незавершённо. Системный навбар умеет доводить заголовок до ближайшего состояния (полностью открыт или полностью закрыт), и от своего компонента хочется того же.

Для этого уже нужен делегат скролла: момент доводки ловится в scrollViewWillEndDragging, где можно поправить targetContentOffset — точку, в которую скролл прилетит после инерции.

scrollProxy.onWillEndDragging = { [weak self] scrollView, velocity, targetContentOffset in
    guard let self, abs(velocity.y) > 0 else { return }
    // считаем, какими были бы высоты панелей в точке targetContentOffset,
    // и сдвигаем эту точку так, чтобы панель оказалась либо полностью открытой,
    // либо полностью закрытой — но не посередине
    let diff = self.searchPanelBaseHeight - projectedSearchHeight
    if diff < self.searchPanelBaseHeight / 2 {
        targetContentOffset.pointee.y -= diff             // дораскрыть
    } else if projectedSearchHeight != 0 {
        targetContentOffset.pointee.y += projectedSearchHeight   // дозакрыть
    }
    // аналогично для large title
}

На случай, когда инерции нет вообще (палец отпустили почти без скорости), предусмотрен запасной путь через scrollViewDidEndDragging / scrollViewDidEndDecelerating, который доводит панель уже анимированным setContentOffset(_:animated:). Логика такая: если будет deceleration — всё посчитано на предыдущем шаге; если же где-то возникла неточность — её исправит вызов той же доводки после полной остановки скролла. То есть второй путь работает как страховка от погрешностей основного расчёта.

Делегат-прокси: работа со скроллом без перехвата делегата у экрана

Это наиболее содержательная инженерная часть.

Проблема: для снаппинга нужен делегат скролла. Но делегат уже занят — у UITableView это контроллер экрана, у Texture это ASTableNode.delegate. Если просто забрать делегат себе, экран перестанет получать didSelectRow, willDisplay, batch-fetch и десятки других вызовов. Если оставить делегат экрану — снаппинг будет негде реализовать.

Решение — прокси-делегат, который форвардит все вызовы оригинальному делегату и попутно дёргает собственные хендлеры. Здесь две сложности:

  1. Это нужно сделать типобезопасно средствами Swift, а не через NSProxy / message-forwarding: протоколы Texture так просто не проксируются.

  2. Типов контейнеров несколько — UIScrollView, UITableView, UICollectionView, ASTableNode, ASCollectionNode, и у каждого свой протокол делегата.

Базовый прокси делается дженериком по типу оригинального делегата:

class ScrollProxy<Delegate: UIScrollViewDelegate>: NSObject, UIScrollViewDelegate {
    weak var target: Delegate?

    var onWillEndDragging: ((UIScrollView, CGPoint, UnsafeMutablePointer<CGPoint>) -> Void)?
    var onDidEndDragging:  ((UIScrollView, Bool) -> Void)?
    var onDidEndDecelerating: ((UIScrollView) -> Void)?

    init(target: Delegate?) { self.target = target }

    func scrollViewWillEndDragging(_ scrollView: UIScrollView,
                                   withVelocity velocity: CGPoint,
                                   targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        target?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity,
                                           targetContentOffset: targetContentOffset)
        onWillEndDragging?(scrollView, velocity, targetContentOffset)   // собственный хук
    }
    // ... и так вручную форвардятся все остальные методы UIScrollViewDelegate
}

target объявлен weak, чтобы не удерживать экран. Из-за этого само свойство, хранящее прокси в баре, приходится держать сильной ссылкой — иначе прокси будет освобождён сразу после установки.

Под каждый тип контейнера — подкласс, наследующий базовый прокси и реализующий специфичные методы протокола:

final class TableNodeProxy: ScrollProxy<ASTableDelegate>, ASTableDelegate {
    func tableNode(_ node: ASTableNode, didSelectRowAt indexPath: IndexPath) {
        target?.tableNode?(node, didSelectRowAt: indexPath)
    }
    func shouldBatchFetch(for node: ASTableNode) -> Bool {
        target?.shouldBatchFetch?(for: node) ?? false
    }
    // ... ещё ~30 методов ASTableDelegate, каждый форвардится вручную
}

final class CollectionNodeProxy: ScrollProxy<ASCollectionDelegate>, ASCollectionDelegateFlowLayout { ... }
final class TableViewProxy: ScrollProxy<UITableViewDelegate>, UITableViewDelegate { ... }
final class CollectionViewProxy: ScrollProxy<UICollectionViewDelegate>, UICollectionViewDelegate { ... }

Да, десятки методов делегата форвардятся вручную, по строке на каждый. Это многословно, но предсказуемо и проверяется компилятором — никакой рантайм-магии.

Подмена делегата происходит при подключении нового скролла: по его типу выбирается нужный подкласс прокси, а старому контейнеру возвращается его оригинальный делегат:

private func attach(_ scrollView: UIScrollView) {
    // вернуть прежнему контейнеру его оригинальный делегат
    detachCurrentProxy()

    // навесить подходящий прокси на новый контейнер
    if let node = (scrollView as? ASTableView)?.tableNode {
        let proxy = TableNodeProxy(target: node.delegate)
        self.scrollProxy = proxy        // сильная ссылка
        node.delegate = proxy
    } else if let node = (scrollView as? ASCollectionView)?.collectionNode {
        // ...
    } else if let table = scrollView as? UITableView {
        // ...
    } // ... UICollectionView / просто UIScrollView
}

Возможность прозрачно встроиться между экраном и его скроллом сразу для двух стеков — UIKit и Texture — оказалась наиболее удачным решением во всей этой архитектуре.

Компромиссы и обходные пути

Ниже — вещи, без которых компонент не работает корректно, но которые редко попадают в учебники.

1. Свиззл UIRefreshControl.endRefreshing

При pull-to-refresh iOS на endRefreshing сам подкручивает contentInset.top. Но в этот момент isRefreshing ещё равен true, из-за чего расчёт инсета (он зависит от высоты refresh control) выдаёт неверное значение. Один из вариантов лечения — подменить реализацию endRefreshing и после неё «пнуть» инсет на +1/−1, чтобы бар пересчитался по KVO:

extension UIRefreshControl {
    @objc dynamic func swizzled_endRefreshing() {
        swizzled_endRefreshing()                  // вызвать оригинал (после swizzle это он и есть)
        guard let scrollView = superview as? UIScrollView else { return }
        scrollView.contentInset.top += 1
        scrollView.contentInset.top -= 1          // спровоцировать KVO contentInset
    }
}

private func swizzleRefreshControlOnce() {
    guard !Self.didSwizzle else { return }
    Self.didSwizzle = true
    let original = class_getInstanceMethod(UIRefreshControl.self, #selector(UIRefreshControl.endRefreshing))!
    let swizzled = class_getInstanceMethod(UIRefreshControl.self, #selector(UIRefreshControl.swizzled_endRefreshing))!
    method_exchangeImplementations(original, swizzled)
}

Флаг didSwizzle гарантирует, что подмена произойдёт ровно один раз на всё приложение. Свиззл системного класса в продакшене — не лучшая практика, и я держу это место на особом контроле; но способа поймать нужный момент без приватных хаков я не нашёл. Это осознанный компромисс, а не рекомендация.

2. scroll-to-top через вспомогательный скролл

Системный scroll-to-top (тап по статус-бару) просто мгновенно прыгает к верху списка. Мне же хотелось собственную анимацию доводки — с эффектом пружины (лёгким overshoot у верхней границы), которую системное поведение не даёт настроить. Поэтому штатный scroll-to-top отключается (scrollsToTop = false), а вместо него ставится свой: поверх бара кладётся невидимый UIScrollView с scrollsToTop = true, и тап ловится через его делегат:

private lazy var statusBarTapCatcher: UIScrollView = {
    let view = UIScrollView()
    view.delegate = scrollToTopCatcher
    view.contentSize = CGSize(width: 0, height: 1000)
    view.contentOffset.y = 1     // чтобы было куда «скроллиться к верху»
    return view
}()

private final class ScrollToTopCatcher: NSObject, UIScrollViewDelegate {
    let action: () -> Void
    init(action: @escaping () -> Void) { self.action = action }

    func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
        defer { action() }
        return false             // сам не скроллюсь, но тап пойман — запускаю свою анимацию
    }
}

Ради этого вся анимация scroll-to-top оказывается под контролем: contentOffset гоняется через display-link-аниматор с подобранными кривыми Безье (с y > 1 на конце — это и даёт пружинистый overshoot), а длительность считается от пройденной дистанции, причём под разные дистанции применяются разные timing-функции. Это сложнее минимально необходимого, но именно так и получается тот самый эффект пружины, которого хотелось.

3. Защита от реакции на собственные изменения

Поскольку бар подписан по KVO на contentInset, contentOffset, contentSize, bounds и сам же всё это меняет, заметная часть кода — это флаги и guard-проверки «не я ли только что это изменил»: ignoreStackBoundsChanges, проверка «это тот самый обрабатываемый скролл», пороги в 1pt. Без них компонент уходит в рекурсивный layout и фриз. Это прямое следствие ручного управления инсетами.

Что можно было сделать иначе

Заметная часть сложности (1pt-эффекты, рекурсивные KVO, свиззл refresh control) растёт из управления contentInset: компонент синхронизирует собственную высоту с инсетом скролла. Соблазнительно решить, что «надо было сделать иначе» — например, держать бар оверлеем над скроллом и не трогать инсеты. Но на практике это, кажется, не упрощение, а наоборот: оверлею всё равно пришлось бы вручную сдвигать контент, согласовывать его с индикаторами прокрутки, refresh control, scroll-to-top и safe area — то есть воспроизводить ровно ту работу, которую сейчас за нас делает contentInset, только без помощи системы. Так что управление инсетом здесь — не лишнее усложнение, а, похоже, наименее болезненный из доступных вариантов. Сложность тут не в выборе подхода, а в самой задаче: повторить системное поведение, не имея системного доступа.

Что точно оправдало себя:

  • прокси-делегат как дженерик — единый механизм для UIKit и Texture, расширяется новым подклассом за считанные минуты;

  • коллапс через KVO contentOffset вместо делегата — это отвязало визуальное поведение от того, кто владеет делегатом скролла;

  • снаппинг через targetContentOffset с запасным путём — даёт плавную доводку без рывков.

Комментарии (0)