Разработка
Исследуем интерактивный Bottom Sheet в SwiftUI
Узнайте, как эффективно использовать интерактивные настраиваемые экраны, как в приложениях Apple Maps, Find My и Stocks.
В таких приложениях, как Maps, Find My и Stocks, есть различные нижние шторки, выдвижные панели, которые остаются видимыми все время, предоставляя пользователям мгновенный доступ к определенным функциям, не заслоняя фоновый вид и поддерживая взаимодействие.
В этой статье мы рассмотрим, как эффективно использовать presentationDetents
и связанные с ними модификаторы для создания интерактивных настраиваемых представлений, как в упомянутых приложениях.
Панель и презентационные фиксаторы
Листы в SwiftUI — это способ представления модального содержимого поверх текущего представления. Традиционно листы занимают весь экран при отображении.
В Human Interface Guidelines в разделе «Платформа» для iOS и iPadOS также упоминаются листы с изменяемыми размерами.
Лист с изменяемыми размерами расширяется, когда люди прокручивают его содержимое или перетаскивают граббер — небольшой горизонтальный индикатор, который может появляться у верхнего края листа. Листы изменяют размер в соответствии с их фиксаторами, которые представляют собой определенную высоту, на которой лист органично располагается. В iPhone фиксаторы задают определенную высоту, на которой лист располагается естественным образом.
Фиксасторы были представлены на WWDC 2021 в UIKit как способ создания многоуровневого и настраиваемого опыта работы с листами, позволяющий разработчикам указывать заданную высоту листов. Презентационные фиксаторы — это предопределенные точки остановки для отображения листов в SwiftUI. Они позволяют указать несколько вариантов высоты листа, предоставляя пользователям возможность изменять размер листа в нескольких вариантах. В iOS 16 в SwiftUI появились отступы и модификаторы presentationDetents(_:)
, позволяющие создавать персонализированные листы без необходимости создавать представление UISheetPresentationController
.
struct ContentView: View { @State private var isSheetPresented = false var body: some View { Button("Show Sheet") { isSheetPresented.toggle() } .sheet(isPresented: $isSheetPresented) { Text("Create with Swift") .presentationDetents([.medium, .fraction(0.7), .large]) } } }
Встроенные размеры
В SwiftUI предусмотрено два стандартных размера фиксатора:
medium
: занимает примерно половину высоты экранаlarge
: занимает всю высоту экрана (поведение по умолчанию)
struct ContentView: View { @State private var isSheetPresented = false var body: some View { Button("Show Sheet") { isSheetPresented.toggle() } .sheet(isPresented: $isSheetPresented) { Text("Create with Swift") .presentationDetents([.medium, .large]) } } }
Средний фиксатор автоматически отключается в альбомной ориентации, чтобы сохранить полезное пространство. Все листы будут использовать большие фиксаторы в альбомной ориентации независимо от того, что указано в модификаторе.
Кастомные размеры
Для более точного контроля SwiftUI предлагает несколько вариантов пользовательских фиксаторов:
fraction(_ fraction: CGFloat)
— указывает процент от высоты экрана (например: .fraction(0.30) для высоты 30%)height(_ height: CGFloat)
— задание точной высоты в точкахcustom(_:)
— определенный вами кастомный фиксатор с рассчитанной высотой
struct ContentView: View { @State private var isSheetPresented = false var body: some View { Button("Show Sheet") { isSheetPresented.toggle() } .sheet(isPresented: $isSheetPresented) { Text("Create with Swift") .presentationDetents([.fraction(0.7), .height(100)]) } } }
Вы можете создать пользовательский фиксатор, следуя протоколу CustomPresentationDetent
и указав его высоту.
private struct SmallDetent: CustomPresentationDetent { static func height(in context: Context) -> CGFloat? { return 100 } } private struct CompactDetent: CustomPresentationDetent { static func height(in context: Context) -> CGFloat? { max(50, min(context.maxDetentValue * 0.3, 200)) } }
Первый фиксатор — это тот, который мы использовали ранее, он возвращает задержку на определенной высоте. Второй вычисляет высоту между 50 и 200 пунктами, но не более 30% от доступной высоты.
struct ContentView: View { @State private var isSheetPresented = false var body: some View { Button("Show Sheet") { isSheetPresented.toggle() } .sheet(isPresented: $isSheetPresented) { Text("Create with Swift") .presentationDetents([.custom(SmallDetent.self), .custom(CompactDetent.self)]) } } } private struct SmallDetent: CustomPresentationDetent { static func height(in context: Context) -> CGFloat? { return 100 } } private struct CompactDetent: CustomPresentationDetent { static func height(in context: Context) -> CGFloat? { max(50, min(context.maxDetentValue * 0.3, 200)) } }
Чтобы упростить использование в вашем приложении, расширьте PresentationDetent
:
struct ContentView: View { @State private var isSheetPresented = false var body: some View { Button("Show Sheet") { isSheetPresented.toggle() } .sheet(isPresented: $isSheetPresented) { Text("Create with Swift") .presentationDetents([.small, .compact]) } } } private struct SmallDetent: CustomPresentationDetent { static func height(in context: Context) -> CGFloat? { return 100 } } private struct CompactDetent: CustomPresentationDetent { static func height(in context: Context) -> CGFloat? { max(50, min(context.maxDetentValue * 0.3, 200)) } } extension PresentationDetent { static let small = PresentationDetent.custom(SmallDetent.self) static let compact = PresentationDetent.custom(CompactDetent.self) }
Поведение индикатора перетаскивания
Если доступен только один фиксатор, лист не будет отображать индикатор перетаскивания.
Видимость индикатора перетаскивания регулируется автоматически:
- Один фиксатор: индикатор перетаскивания скрыт
- Несколько фиксаторов: индикатор перетаскивания виден
Вы можете переопределить это поведение с помощью модификатора presentationDragIndicator(_:)
. Установите видимость на visible
, чтобы индикатор отображался, даже если определен только один фиксатор.
struct ContentView: View { @State private var isSheetPresented = false var body: some View { Button("Show Sheet") { isSheetPresented.toggle() } .sheet(isPresented: $isSheetPresented) { Text("Create with Swift") .presentationDetents([.medium]) .presentationDragIndicator(.visible) } } }
Или вы можете установить видимость на hidden
, чтобы скрыть индикатор даже при наличии нескольких фиксаторов:
struct ContentView: View { @State private var isSheetPresented = false var body: some View { Button("Show Sheet") { isSheetPresented.toggle() } .sheet(isPresented: $isSheetPresented) { Text("Create with Swift") .presentationDetents([.medium, .large]) .presentationDragIndicator(.hidden) } } }
Динамическая проверка или управление высотой
Вы можете захотеть изменить содержимое боттом шит в зависимости от доступного пространства. Для этого можно динамически проверять или контролировать высоту листа с помощью биндинга:
struct ContentView: View { @State private var selectedDetent: PresentationDetent = .medium @State private var isSheetPresented = false var body: some View { Button("Show Sheet") { isSheetPresented.toggle() } .sheet(isPresented: $isSheetPresented) { sheetContent .presentationDetents([.height(100), .medium, .fraction(0.7), .large], selection: $selectedDetent) } } @ViewBuilder private var sheetContent: some View { switch selectedDetent { case .medium: Text("Medium Detent") case .fraction(0.7): Text("Fraction Detent") case .large: Text("Large Detent") case .height(100): Text("Height Detent") default: Text("Unknown Detent") } } }
Предотвращение удаления листа
В приведенных выше примерах пользователь может удалить лист. Чтобы воспроизвести интерфейсы типа Maps, вы можете предотвратить интерактивное удаление листа пользователем с помощью модификатора interactiveDismissDisabled(_:)
и заставить его всегда быть представленным, установив постоянное значение для параметра isPresented
с помощью метода Binding.constant(_:)
.
struct ContentView: View { @State private var selectedDetent: PresentationDetent = .height(100) var body: some View { Text("Create with Swift") .sheet(isPresented: .constant(true)) { VStack { Image(systemName: "swift") .resizable() .scaledToFit() .padding() .foregroundStyle(Color.accentColor) if (selectedDetent != .height(100)) { Text("We can’t wait to see what you will Create with Swift!") } } .presentationDetents([.height(100), .medium, .large], selection: $selectedDetent) .interactiveDismissDisabled() } } }
Лист все еще можно отключить программно или указать, при каком условии лист может быть свернут, с помощью параметра isDisabled
.
Взаимодействие с фоном
Во всех предыдущих реализациях листа фоновое представление оставалось неинтерактивным. В конфигурациях по умолчанию фоновое представление обычно неинтерактивно, пока боттом шит активен, что предотвращает случайные касания. Если вы хотите позволить пользователю взаимодействовать с фоновым представлением во время отображения листа, используйте модификатор presentationBackgroundInteraction(_:)
.
struct ContentView: View { @State private var selectedDetent: PresentationDetent = .height(100) @State private var selectedColor: Color = Color.accentColor var body: some View { VStack { Button("Change Color", action: { selectedColor = selectedColor == .accentColor ? .white : .accentColor }) .padding(.top) Spacer() } .sheet(isPresented: .constant(true)) { VStack { Image(systemName: "swift") .resizable() .scaledToFit() .padding() .foregroundStyle(selectedColor) if (selectedDetent != .height(100)) { Text("We can’t wait to see what you will Create with Swift!") } } .presentationDetents([.height(100), .medium, .large], selection: $selectedDetent) .interactiveDismissDisabled() .presentationBackgroundInteraction(.enabled) } } }
Условное взаимодействие с фоном
С помощью параметра upThrough
можно указать, на каком зафиксированном уровне разрешено взаимодействие с фоном. Например, .enabled(upThrough: .medium)
позволяет взаимодействовать с фоновым представлением только тогда, когда лист зафиксирован на среднем уровне или ниже, в то время как на большем уровне фон остается неинтерактивным.
struct ContentView: View { @State private var selectedDetent: PresentationDetent = .height(100) @State private var selectedColor: Color = Color.accentColor var body: some View { VStack { Button("Change Color", action: { selectedColor = selectedColor == .accentColor ? .white : .accentColor }) .padding(.top) Spacer() } .sheet(isPresented: .constant(true)) { VStack { Image(systemName: "swift") .resizable() .scaledToFit() .padding() .foregroundStyle(selectedColor) if (selectedDetent != .height(100)) { Text("We can’t wait to see what you will Create with Swift!") } } .presentationDetents([.height(100), .medium, .large], selection: $selectedDetent) .interactiveDismissDisabled() .presentationBackgroundInteraction(.enabled(upThrough: .height(100))) } } }
Мы рассмотрели, как с помощью модификаторов фиксации и фонового взаимодействия в SwiftUI можно легко реализовать сложные интерфейсы. Благодаря сочетанию настраиваемых фиксаторов, индикаторов перетаскивания и взаимодействия с фоном разработчики теперь могут создавать листы, которые улучшают, а не прерывают пользовательский опыт. Эти возможности позволяют создавать целостные интерактивные интерфейсы, которые сохраняют контекст, предоставляя при этом дополнительную функциональность.