Разберём, как можно отказаться от системного 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 и десятки других вызовов. Если оставить делегат экрану — снаппинг будет негде реализовать.
Решение — прокси-делегат, который форвардит все вызовы оригинальному делегату и попутно дёргает собственные хендлеры. Здесь две сложности:
Это нужно сделать типобезопасно средствами Swift, а не через
NSProxy/ message-forwarding: протоколы Texture так просто не проксируются.Типов контейнеров несколько —
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с запасным путём — даёт плавную доводку без рывков.