Site icon AppTractor

Карточка навигации для iOS 18 на SwiftUI

В 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» отображается дважды. Чтобы заставить заголовок реагировать на прокрутку, нам нужно решить три взаимосвязанные задачи:

  1. Нам нужно знать, когда заголовок карты прокручивается за навигационную панель.
  2. Для этого нам нужно знать, насколько велика панель навигации, насколько велика метка заголовка карточки и где находится карточка по отношению к панели навигации.
  3. Исходя из вышесказанного, нам нужно иметь возможность изменять непрозрачность заголовка навигационной панели по мере прокрутки экрана.

Примечание: в iOS 18 новый onScrollVisibilityChange(threshold:_:), казалось бы, должен справиться с этой задачей, но в iOS 18 Beta 6 мне не удалось добиться надежного срабатывания этого API при прокрутке содержимого на экране и за его пределами. Кроме того, этот API предоставляет только булевый триггер, что ограничивает нашу возможность управлять непрозрачностью заголовка в прямой зависимости от позиции прокрутки.

Изменение прозрачности заголовка навигации

В 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)
  }
}

Источник

Exit mobile version