В iOS 18 стандартизировали паттерн дизайна, который можно назвать «заглавными карточками навигации» (navigation title cards), за неимением устоявшегося термина в HIG. В своей простейшей форме эти карточки появляются в верхней части списка контента и содержат краткий поясняющий текст.
Эта карточка заменяет заголовок в навигационной панели при прокрутке к верху. Когда карточка прокручивается за панель навигации, встроенный заголовок появляется, когда на виду — исчезает. Это небольшая, но важная деталь, поскольку она уменьшает ненужное дублирование информации и одновременно усиливает связь между заглавной карточкой и заголовком страницы.
Выше страница настроек Bluetooth в приложении «Настройки» iOS 18, на которой видно, как заголовок ярлыка исчезает при прокрутке содержимого.
Давайте посмотрим, как это можно сделать в SwiftUI.
Создаем карточку
Сама карточка довольно проста: она состоит из вертикального стека с изображением, заголовком и описательным текстом.
Существует несколько способов реализовать этот дизайн. Для целей этой статьи мы разработаем карточку, предназначенную для работы в контексте обычного List
.
VStack(spacing: 8) { Image(systemName: "carrot.fill") .foregroundStyle(.white) .font(.largeTitle) .bold() .padding() .background(.accent) .clipShape(RoundedRectangle(cornerRadius: 16)) Text("Feed") .font(.title) .bold() .fontDesign(.rounded) Text("Your latest PostHog events in a unified timeline.") .font(.callout) } .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding() .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 16))
Убираем встроенный заголовок
Обратите внимание, что на предыдущем скриншоте заголовок «Feed» отображается дважды. Чтобы заставить заголовок реагировать на прокрутку, нам нужно решить три взаимосвязанные задачи:
- Нам нужно знать, когда заголовок карты прокручивается за навигационную панель.
- Для этого нам нужно знать, насколько велика панель навигации, насколько велика метка заголовка карточки и где находится карточка по отношению к панели навигации.
- Исходя из вышесказанного, нам нужно иметь возможность изменять непрозрачность заголовка навигационной панели по мере прокрутки экрана.
Изменение прозрачности заголовка навигации
В SwiftUI невозможно напрямую менять прозрачность заголовка встроенной навигации при использовании navigationTitle(_:)
. На Stack Overflow я видел много рекомендаций использовать для этого UIAppearance
, но это хрупкий подход, который может иметь далеко идущие последствия для панелей навигации вашего приложения, если вы не будете осторожны.
Вместо этого мы предоставим кастомную строку заголовка и свойство состояния, которое позволит нам программно изменять его непрозрачность:
@State private var inlineTitleOpacity: Double = 0 var body: some View { ... .toolbar { ToolbarItem(placement: .principal) { Text("Feed") .font(.body) .bold() .fontDesign(.rounded) .dynamicTypeSize(.large ... .xxxLarge) .opacity(inlineTitleOpacity) } } }
Определение положения содержимого
Теперь, когда мы можем изменять непрозрачность встроенного заголовка, нам нужно рассчитать эту непрозрачность, используя положение нашей навигационной карточки по отношению к панели навигации, то есть расстояние между ними.
Для этого мы воспользуемся новым API iOS 18 onScrollGeometryChange(for:of:action:)
. Этот API позволяет нам вызывать событие каждый раз, когда изменяется какой-то аспект геометрии нашего представления прокрутки. В данном случае нас интересует только верхний отступ контента.
@State private var scrollTopEdgeInset: Double = 0 var body: some View { ... .onScrollGeometryChange(for: Double.self, of: { geometry in geometry.contentInsets.top }, action: { oldValue, newValue in scrollTopEdgeInset = newValue }) }
Теперь у нас есть вся информация, необходимая для расчета свойства inlineTitleOpacity
.
Вычисление прозрачности заголовка
Чтобы завершить решение нашей задачи, мы воспользуемся API, представленным в iOS 16: onGeometryChange(for:of:action:)
. Этот метод вызывает событие при изменении вычисляемого значения, что делает его эффективным способом создания эффектов, управляемых смещением при прокрутке, как тот, который мы хотим построить.
Text("Feed") .font(.title) .bold() .fontDesign(.rounded) .onGeometryChange(for: Double.self) { proxy in let frame = proxy.frame(in: .scrollView) return min(1, max(0, (scrollTopEdgeInset - frame.minY) / frame.height)) } action: { inlineTitleOpacity in self.inlineTitleOpacity = inlineTitleOpacity }
Вот и все! Вот полное решение для справки:
struct FeedView: View { @State private var inlineTitleOpacity: Double = 0 @State private var scrollTopEdgeInset: Double = 0 var body: some View { List { Section { VStack(spacing: 8) { Image(systemName: "carrot.fill") .foregroundStyle(.white) .font(.largeTitle) .bold() .padding() .background(.accent) .clipShape(RoundedRectangle(cornerRadius: 16)) Text("Feed") .font(.title) .bold() .fontDesign(.rounded) .onGeometryChange(for: Double.self) { proxy in let frame = proxy.frame(in: .scrollView) return min(1, max(0, (scrollTopEdgeInset - frame.minY) / frame.height)) } action: { inlineTitleOpacity in self.inlineTitleOpacity = inlineTitleOpacity } Text("Your latest PostHog events in a unified timeline.") .font(.callout) } .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding() .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 16)) } .listSectionSeparator(.hidden) Text("Cell 1") Text("Cell 2") Text("Cell 3") } .onScrollGeometryChange(for: Double.self, of: { geometry in geometry.contentInsets.top }, action: { oldValue, newValue in scrollTopEdgeInset = newValue }) .toolbar { ToolbarItem(placement: .principal) { Text("Feed") .font(.body) .bold() .fontDesign(.rounded) .dynamicTypeSize(.large ... .xxxLarge) .opacity(inlineTitleOpacity) } } .listStyle(.plain) .navigationBarTitleDisplayMode(.inline) } }