Avatar ·

Как реализовать выезжающую снизу панель (bottom sheet) как в приложении Карты на iOS?

Всем привет! Я новичок в iOS разработке на Swift и пытаюсь сделать интерфейс похожий на приложение "Карты" от Apple. Мне нужно, чтобы снизу экрана выезжала панель с информацией о месте, которую можно перетаскивать вверх/вниз, и она фиксируется в двух положениях: частично открыта (показывает только заголовок) и полностью открыта (показывает весь контент). Я пробовал использовать UIScrollView с делегатом и анимациями, но получается коряво: панель дергается, не плавно останавливается, а если внутри UIScrollView есть UITableView, то жесты перетаскивания конфликтуют. Вот что я набросал: ```swift class BottomSheetViewController: UIViewController { @IBOutlet weak var bottomSheetView: UIView! @IBOutlet weak var bottomConstraint: NSLayoutConstraint! override func viewDidLoad() { super.viewDidLoad() let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) bottomSheetView.addGestureRecognizer(panGesture) } @objc func handlePan(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: view) // здесь пытаюсь обновлять bottomConstraint // но не понимаю как правильно зафиксировать позицию } } ``` Подскажите, как правильно сделать такую панель? Может есть готовые решения или библиотеки? Важно, чтобы она работала плавно и не конфликтовала с UIScrollView внутри. Буду благодарен за пример кода на Swift. <<>>

Решение с использованием 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
  • Несколько фиксированных позиций
  • Затемнение фона

Это хороший вариант, если вы не хотите писать кастомное решение с нуля и предпочитаете проверенный код.