Как реализовать выезжающую снизу панель (bottom sheet) как в приложении Карты на iOS?
Решение с использованием UIScrollView и UIPanGestureRecognizer
Правильный подход — создать кастомный контейнер, который управляет положением панели с помощью UIScrollView, а не простой панели с жестом. Вот проверенное решение, которое работает как в Apple Maps:
import UIKit
class BottomSheetViewController: UIViewController {
// MARK: - Properties
private let sheetView = UIView()
private let contentView = UIView()
private var scrollView: UIScrollView!
private let fullHeight: CGFloat = UIScreen.main.bounds.height * 0.85
private let partialHeight: CGFloat = 120
private var currentHeight: CGFloat = 120
private var panGestureRecognizer: UIPanGestureRecognizer!
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupGestures()
}
private func setupUI() {
view.backgroundColor = UIColor.black.withAlphaComponent(0.3)
// Настройка sheetView
sheetView.backgroundColor = .white
sheetView.layer.cornerRadius = 16
sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
sheetView.clipsToBounds = true
view.addSubview(sheetView)
// Настройка contentView (внутренняя прокрутка)
contentView.backgroundColor = .clear
sheetView.addSubview(contentView)
// Создаем UIScrollView для контента
scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.delegate = self
contentView.addSubview(scrollView)
// Добавляем какой-нибудь контент в scrollView
let label = UILabel()
label.text = "Это контент панели. Прокрутите вниз для деталей."
label.numberOfLines = 0
scrollView.addSubview(label)
// Layout (упрощенно, используйте Auto Layout в реальном проекте)
sheetView.frame = CGRect(x: 0, y: view.bounds.height - partialHeight,
width: view.bounds.width, height: fullHeight)
contentView.frame = sheetView.bounds
scrollView.frame = contentView.bounds
label.frame = CGRect(x: 16, y: 16, width: scrollView.bounds.width - 32, height: 600)
scrollView.contentSize = CGSize(width: scrollView.bounds.width, height: 632)
}
private func setupGestures() {
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panGestureRecognizer.delegate = self
sheetView.addGestureRecognizer(panGestureRecognizer)
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view)
let velocity = gesture.velocity(in: view)
switch gesture.state {
case .changed:
// Обновляем положение панели
var newY = sheetView.frame.origin.y + translation.y
newY = max(view.bounds.height - fullHeight, min(newY, view.bounds.height - partialHeight))
sheetView.frame.origin.y = newY
gesture.setTranslation(.zero, in: view)
// Обновляем прозрачность затемнения
let progress = (sheetView.frame.origin.y - (view.bounds.height - fullHeight)) / (view.bounds.height - fullHeight - (view.bounds.height - partialHeight))
view.backgroundColor = UIColor.black.withAlphaComponent(0.3 * (1 - progress))
case .ended:
// Определяем, куда "прилипнуть"
let targetY: CGFloat
let currentY = sheetView.frame.origin.y
let midPoint = (view.bounds.height - fullHeight + view.bounds.height - partialHeight) / 2
if velocity.y > 500 {
targetY = view.bounds.height - partialHeight // закрыть
} else if velocity.y < -500 {
targetY = view.bounds.height - fullHeight // открыть
} else if currentY < midPoint {
targetY = view.bounds.height - fullHeight
} else {
targetY = view.bounds.height - partialHeight
}
// Анимируем фиксацию
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: .curveEaseOut) {
self.sheetView.frame.origin.y = targetY
let progress = (targetY - (self.view.bounds.height - self.fullHeight)) / (self.view.bounds.height - self.fullHeight - (self.view.bounds.height - self.partialHeight))
self.view.backgroundColor = UIColor.black.withAlphaComponent(0.3 * (1 - progress))
}
default:
break
}
}
}
// MARK: - UIGestureRecognizerDelegate
extension BottomSheetViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// Разрешаем одновременное распознавание с UIScrollView
return true
}
}
// MARK: - UIScrollViewDelegate
extension BottomSheetViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Если scrollView прокручен вверх — блокируем жест панели
if scrollView.contentOffset.y > 0 {
panGestureRecognizer.isEnabled = false
} else {
panGestureRecognizer.isEnabled = true
}
}
}
Ключевые моменты:
- UIScrollView как основа — панель сама является контейнером с прокруткой, а не просто UIView
- UIPanGestureRecognizer — для перетаскивания панели, с делегатом для совместной работы с UIScrollView
- Фиксация позиций — анимация с пружинным эффектом (springWithDamping) для плавного прилипания
- Затемнение фона — плавно меняется в зависимости от положения панели
- Блокировка жестов — когда контент прокручен, жест панели отключается
Этот код можно улучшить, добавив поддержку индикатора (полоски сверху), анимацию появления/исчезания и работу с Auto Layout.
<<Альтернатива: Использование UIViewControllerAnimatedTransitioning
Если вам нужна более готовая и тестированная реализация, рекомендую использовать библиотеку Pulley (или ее форки). Она специально создана для имитации bottom sheet из Maps и решает все проблемы с жестами и UIScrollView.
Установка через CocoaPods:
pod 'Pulley'
Пример использования:
import Pulley
class MainViewController: PulleyViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Устанавливаем primary и drawer контроллеры
primaryContentVC = MapViewController()
drawerContentVC = DrawerViewController()
// Настройка позиций
drawerTopInset = 100 // верхний отступ
drawerBottomInset = 50 // нижний отступ
drawerCornerRadius = 16
}
}
Библиотека автоматически обрабатывает:
- Плавные анимации открытия/закрытия
- Конфликты жестов с UIScrollView и UITableView
- Несколько фиксированных позиций
- Затемнение фона
Это хороший вариант, если вы не хотите писать кастомное решение с нуля и предпочитаете проверенный код.
Ответов пока нет.
Войдите чтобы оставить ответ