Как можно имитировать нижний лист из приложения "Карты"?

Может кто-нибудь сказать мне, как я могу имитировать нижний лист в новом приложении Карт в iOS 10?

В Android вы можете использовать BottomSheet который имитирует это поведение, но я не мог найти ничего подобного для iOS.

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

Я довольно новичок в программировании на iOS, поэтому, если кто-то может помочь мне создать этот макет, это будет высоко оценено.

Вот что я имею в виду под "нижним листом":

снимок экрана свернутого нижнего листа в Картах

снимок экрана расширенного нижнего листа в Картах

12 ответов

Решение

Я не знаю, как именно нижний лист нового приложения Карт реагирует на взаимодействие с пользователем. Но вы можете создать собственный вид, похожий на тот, что на скриншотах, и добавить его в основной вид.

Я полагаю, вы знаете, как:

1- создавать контроллеры представления либо раскадровками, либо с использованием файлов XIB.

2- используйте GoogleMaps или Apple MapKit.

пример

1- Создайте 2 контроллера вида, например, MapViewController и BottomSheetViewController. Первый контроллер будет размещать карту, а второй - сам нижний лист.

Настроить MapViewController

Создайте метод для добавления вида нижнего листа.

func addBottomSheetView() {
    // 1- Init bottomSheetVC
    let bottomSheetVC = BottomSheetViewController()

    // 2- Add bottomSheetVC as a child view 
    self.addChildViewController(bottomSheetVC)
    self.view.addSubview(bottomSheetVC.view)
    bottomSheetVC.didMoveToParentViewController(self)

    // 3- Adjust bottomSheet frame and initial position.
    let height = view.frame.height
    let width  = view.frame.width
    bottomSheetVC.view.frame = CGRectMake(0, self.view.frame.maxY, width, height)
}

И вызвать его в методе viewDidAppear:

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
    addBottomSheetView()
}

Настроить BottomSheetViewController

1) Подготовить фон

Создайте метод для добавления эффектов размытия и яркости

func prepareBackgroundView(){
    let blurEffect = UIBlurEffect.init(style: .Dark)
    let visualEffect = UIVisualEffectView.init(effect: blurEffect)
    let bluredView = UIVisualEffectView.init(effect: blurEffect)
    bluredView.contentView.addSubview(visualEffect)

    visualEffect.frame = UIScreen.mainScreen().bounds
    bluredView.frame = UIScreen.mainScreen().bounds

    view.insertSubview(bluredView, atIndex: 0)
}

вызовите этот метод в вашем viewWillAppear

override func viewWillAppear(animated: Bool) {
   super.viewWillAppear(animated)
   prepareBackgroundView()
}

Убедитесь, что цвет фона вашего контроллера - clearColor.

2) Анимированный вид нижнего листа

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)

    UIView.animateWithDuration(0.3) { [weak self] in
        let frame = self?.view.frame
        let yComponent = UIScreen.mainScreen().bounds.height - 200
        self?.view.frame = CGRectMake(0, yComponent, frame!.width, frame!.height)
    }
}

3) Измените ваш XIB, как вы хотите.

4) Добавьте Pan Gesture Recognizer к вашему виду.

В вашем методе viewDidLoad добавьте UIPanGestureRecognizer.

override func viewDidLoad() {
    super.viewDidLoad()

    let gesture = UIPanGestureRecognizer.init(target: self, action: #selector(BottomSheetViewController.panGesture))
    view.addGestureRecognizer(gesture)

}

И реализуйте свое поведение жеста:

func panGesture(recognizer: UIPanGestureRecognizer) {
    let translation = recognizer.translationInView(self.view)
    let y = self.view.frame.minY
    self.view.frame = CGRectMake(0, y + translation.y, view.frame.width, view.frame.height)
     recognizer.setTranslation(CGPointZero, inView: self.view)
}

Прокручиваемый нижний лист:

Если ваш пользовательский вид представляет собой вид с прокруткой или любой другой вид, который наследуется, то у вас есть два варианта:

Первый:

Создайте представление с представлением заголовка и добавьте panGesture в заголовок. (плохой пользовательский опыт).

Во-вторых:

1- Добавьте panGesture к нижнему виду листа.

2 - Реализуйте UIGestureRecognizerDelegate и установите делегат panGesture на контроллер.

3- Реализовать функцию shouldRecognizeSim одновременно с делегатом и отключить свойство scrollView isScrollEnabled в двух случаях:

  • Вид частично виден.
  • Вид полностью виден, свойство scrollView contentOffset равно 0, а пользователь перетаскивает вид вниз.

В противном случае включите прокрутку.

  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
      let gesture = (gestureRecognizer as! UIPanGestureRecognizer)
      let direction = gesture.velocity(in: view).y

      let y = view.frame.minY
      if (y == fullView && tableView.contentOffset.y == 0 && direction > 0) || (y == partialView) {
          tableView.isScrollEnabled = false
      } else {
        tableView.isScrollEnabled = true
      }

      return false
  }

НОТА

В случае, если вы установите .allowUserInteraction в качестве параметра анимации, как в примере проекта, вам нужно включить прокрутку при закрытии завершения анимации, если пользователь прокручивает вверх.

Пример проекта

Я создал пример проекта с большим количеством опций в этом репо, который может дать вам лучшее представление о том, как настроить поток.

В демонстрационной версии функция addBottomSheetView() контролирует, какой вид следует использовать в качестве нижнего листа.

Пример проекта Скриншоты

- Частичный вид

введите описание изображения здесь

- Полный обзор

введите описание изображения здесь

- прокручиваемый вид

введите описание изображения здесь

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

Карты перехода между прокруткой и переводом

В Картах, как вы могли заметить, когда tableView достигает contentOffset.y == 0нижний лист сдвигается вверх или опускается.

Суть хитрая, потому что мы не можем просто включить / отключить прокрутку, когда наш жест панорамирования начинает перевод. Это остановит прокрутку, пока не начнется новое касание. Это относится к большинству предлагаемых решений здесь.

Вот моя попытка реализовать это движение.

Начальная точка: приложение "Карты"

Чтобы начать наше исследование, давайте визуализируем иерархию представлений Карт (запустите Карты на симуляторе и выберите Debug > Attach to process by PID or Name > Maps в Xcode).

Иерархия представления отладки карт

Это не говорит, как работает движение, но это помогло мне понять логику этого. Вы можете поиграть с lldb и отладчиком иерархии представлений.

Наши стеки ViewController

Давайте создадим базовую версию архитектуры Maps ViewController.

Начнем с BackgroundViewController (наша карта):

class BackgroundViewController: UIViewController {
    override func loadView() {
        view = MKMapView()
    }
}

Мы помещаем tableView в выделенный UIViewController:

class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    lazy var tableView = UITableView()

    override func loadView() {
        view = tableView
        tableView.dataSource = self
        tableView.delegate = self
    }

    [...]
}

Теперь нам нужен VC, чтобы встроить оверлей и управлять его переводом. Чтобы упростить задачу, мы считаем, что он может перевести оверлей из одной статической точки OverlayPosition.maximum другому OverlayPosition.minimum,

На данный момент у него есть только один публичный метод для анимации изменения позиции и прозрачный вид:

enum OverlayPosition {
    case maximum, minimum
}

class OverlayContainerViewController: UIViewController {

    let overlayViewController: OverlayViewController
    var translatedViewHeightContraint = ...

    override func loadView() {
        view = UIView()
    }

    func moveOverlay(to position: OverlayPosition) {
        [...]
    }
}

Наконец, нам нужен ViewController для встраивания всего:

class StackViewController: UIViewController {

    private var viewControllers: [UIViewController]

    override func viewDidLoad() {
        super.viewDidLoad()
        viewControllers.forEach { gz_addChild($0, in: view) }
    }
}

В нашем AppDelegate наша последовательность запуска выглядит следующим образом:

let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])

Сложность перевода наложения

Теперь, как перевести наш оверлей?

В большинстве предлагаемых решений используется специальный распознаватель жестов панорамирования, но у нас уже есть один: жест панорамирования представления таблицы. Кроме того, нам нужно синхронизировать прокрутку и перевод, а также UIScrollViewDelegate есть все события, которые нам нужны!

Наивная реализация будет использовать второй панорамирование Gesture и попытаться сбросить contentOffset представления таблицы, когда происходит перевод:

func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
    if isTranslating {
        tableView.contentOffset = .zero
    }
}

Но это не работает. TableView обновляет свой contentOffset когда срабатывает собственное действие распознавателя жестов панорамирования или когда вызывается его обратный вызов displayLink. Нет никаких шансов, что наш распознаватель сработает сразу после тех, чтобы успешно переопределить contentOffset, Наш единственный шанс - либо принять участие в фазе макета (переопределив layoutSubviews вызовов прокрутки в каждом кадре прокрутки) или для ответа на didScroll метод делегата вызывается каждый раз contentOffset модифицируется. Давайте попробуем это.

Реализация перевода

Мы добавляем делегата к нашему OverlayVC отправлять события scrollview в наш обработчик перевода, OverlayContainerViewController:

protocol OverlayViewControllerDelegate: class {
    func scrollViewDidScroll(_ scrollView: UIScrollView)
    func scrollViewDidStopScrolling(_ scrollView: UIScrollView)
}

class OverlayViewController: UIViewController {

    [...]

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        delegate?.scrollViewDidScroll(scrollView)
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        delegate?.scrollViewDidStopScrolling(scrollView)
    }
}

В нашем контейнере мы отслеживаем перевод, используя перечисление:

enum OverlayInFlightPosition {
    case minimum
    case maximum
    case progressing
}

Расчет текущей позиции выглядит так:

private var overlayInFlightPosition: OverlayInFlightPosition {
    let height = translatedViewHeightContraint.constant
    if height == maximumHeight {
        return .maximum
    } else if height == minimumHeight {
        return .minimum
    } else {
        return .progressing
    }
}

Нам нужно 3 метода для обработки перевода:

Первый говорит нам, если нам нужно начать перевод.

private func shouldTranslateView(following scrollView: UIScrollView) -> Bool {
    guard scrollView.isTracking else { return false }
    let offset = scrollView.contentOffset.y
    switch overlayInFlightPosition {
    case .maximum:
        return offset < 0
    case .minimum:
        return offset > 0
    case .progressing:
        return true
    }
}

Второй выполняет перевод. Он использует translation(in:) метод панорамирования жеста scrollView.

private func translateView(following scrollView: UIScrollView) {
    scrollView.contentOffset = .zero
    let translation = translatedViewTargetHeight - scrollView.panGestureRecognizer.translation(in: view).y
    translatedViewHeightContraint.constant = max(
        Constant.minimumHeight,
        min(translation, Constant.maximumHeight)
    )
}

Третий анимирует конец перевода, когда пользователь отпускает палец. Мы рассчитываем позицию, используя скорость и текущую позицию вида.

private func animateTranslationEnd() {
    let position: OverlayPosition =  // ... calculation based on the current overlay position & velocity
    moveOverlay(to: position)
}

Реализация делегата нашего оверлея выглядит просто так:

class OverlayContainerViewController: UIViewController {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard shouldTranslateView(following: scrollView) else { return }
        translateView(following: scrollView)
    }

    func scrollViewDidStopScrolling(_ scrollView: UIScrollView) {
        // prevent scroll animation when the translation animation ends
        scrollView.isEnabled = false
        scrollView.isEnabled = true
        animateTranslationEnd()
    }
}

Последняя проблема: отправка штрихов оверлейного контейнера

Перевод теперь довольно эффективен. Но есть еще одна проблема: штрихи не передаются в фоновом режиме. Все они перехвачены представлением оверлейного контейнера. Мы не можем установить isUserInteractionEnabled в false потому что это также отключило бы взаимодействие в нашем табличном представлении. Решение, которое широко используется в приложении "Карты", PassThroughView:

class PassThroughView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let view = super.hitTest(point, with: event)
        if view == self {
            return nil
        }
        return view
    }
}

Он удаляет себя из цепочки респондента.

В OverlayContainerViewController:

override func loadView() {
    view = PassThroughView()
}

Результат

Вот результат:

Результат

Вы можете найти код здесь.

Пожалуйста, если вы видите какие-либо ошибки, дайте мне знать! Обратите внимание, что ваша реализация может, конечно, использовать второй жест панорамирования, особенно если вы добавляете заголовок в оверлей.

Обновление 23/08/18

Мы можем заменить scrollViewDidEndDragging с willEndScrollingWithVelocity скорее, чем enable/disable прокрутка, когда пользователь заканчивает перетаскивание:

func scrollView(_ scrollView: UIScrollView,
                willEndScrollingWithVelocity velocity: CGPoint,
                targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    switch overlayInFlightPosition {
    case .maximum:
        break
    case .minimum, .progressing:
        targetContentOffset.pointee = .zero
    }
    animateTranslationEnd(following: scrollView)
}

Мы можем использовать пружинную анимацию и разрешить взаимодействие с пользователем во время анимации, чтобы улучшить движение:

func moveOverlay(to position: OverlayPosition,
                 duration: TimeInterval,
                 velocity: CGPoint) {
    overlayPosition = position
    translatedViewHeightContraint.constant = translatedViewTargetHeight
    UIView.animate(
        withDuration: duration,
        delay: 0,
        usingSpringWithDamping: velocity.y == 0 ? 1 : 0.6,
        initialSpringVelocity: abs(velocity.y),
        options: [.allowUserInteraction],
        animations: {
            self.view.layoutIfNeeded()
    }, completion: nil)
}

Попробуйте шкив:

Pulley - это простая в использовании библиотека ящиков, предназначенная для имитации ящика в приложении Maps для iOS 10. Он предоставляет простой API, который позволяет вам использовать любой подкласс UIViewController в качестве содержимого ящика или основного содержимого.

Предварительный просмотр шкива

https://github.com/52inc/Pulley

Ничто из вышеперечисленного не работает для меня, потому что панорамирование как табличного, так и внешнего вида не работает гладко. Поэтому я написал свой собственный код для достижения желаемого поведения в приложении ios Maps.

https://github.com/OfTheWolf/UBottomSheet

нижний лист

Вы можете попробовать мой ответ https://github.com/SCENEE/FloatingPanel. Он предоставляет контроллер представления контейнера для отображения интерфейса "нижнего листа".

Он прост в использовании, и вы не возражаете против любой обработки распознавания жестов! Кроме того, вы можете отслеживать вид прокрутки (или родственный вид) на нижнем листе, если это необходимо.

Это простой пример. Обратите внимание, что вам необходимо подготовить контроллер представления для отображения вашего контента на нижнем листе.

import UIKit
import FloatingPanel

class ViewController: UIViewController {
    var fpc: FloatingPanelController!

    override func viewDidLoad() {
        super.viewDidLoad()

        fpc = FloatingPanelController()

        // Add "bottom sheet" in self.view.
        fpc.add(toParent: self)

        // Add a view controller to display your contents in "bottom sheet".
        let contentVC = ContentViewController()
        fpc.show(contentVC, sender: nil)

        // Track a scroll view in "bottom sheet" content if needed.
        fpc.track(scrollView: contentVC.tableView)    
    }
    ...
}

Вот еще один пример кода для отображения нижнего листа для поиска местоположения, такого как Apple Maps.

Пример приложения, например Apple Maps

iOS 15 осенью 2021 года добавит UISheetPresentationController, который является первым публичным выпуском Apple «нижнего листа» в стиле Apple Maps:

UISheetPresentationController позволяет представить контроллер представления в виде листа. Перед тем, как представить свой контроллер представления, настройте его контроллер представления листа в соответствии с поведением и внешним видом, которые вы хотите для своего листа.

Контроллеры представления листов определяют размер листа на основе фиксатора - высоты, на которой лист естественным образом лежит. Детекторы позволяют листу изменять размер от одного края его полностью развернутой рамки, в то время как другие три края остаются фиксированными. Вы указываете фиксаторы, которые поддерживает лист, используя detents, и контролировать его последний выбранный фиксатор с помощью selectedDetentIdentifier.

UISheetPresentationControllerhttps://developer.apple.com/documentation/uikit/uisheetpresentationcontroller

Этот новый элемент управления нижнего листа исследуется в сеансе WWDC 10063: Настройка и изменение размеров листов в UIKit.

** для iOS 15 для этого доступна встроенная поддержка **

      @IBAction func openSheet() { 
        let secondVC = self.storyboard?.instantiateViewController(withIdentifier: "SecondViewController")
        // Create the view controller.
        if #available(iOS 15.0, *) {
            let formNC = UINavigationController(rootViewController: secondVC!)
            formNC.modalPresentationStyle = UIModalPresentationStyle.pageSheet
            guard let sheetPresentationController = formNC.presentationController as? UISheetPresentationController else {
                return
            }
            sheetPresentationController.detents = [.medium(), .large()]
            sheetPresentationController.prefersGrabberVisible = true
            present(formNC, animated: true, completion: nil)
        } else {
            // Fallback on earlier versions
        }
   }

Мы только что выпустили чистый пакет Swift, поддерживающий iOS 11.4+, который предоставляет вам BottomSheet с темой и параметрами поведения, которые вы можете настроить. Этот компонент прост в использовании и гибок. Вы можете найти его здесь: https://github.com/LunabeeStudio/LBBottomSheet . Демо-проект также доступен в этом репозитории.

Например, он поддерживает различные способы управления необходимой высотой, а также добавляет к контроллеру, находящемуся за ним, возможность обнаруживать изменения высоты и адаптировать его нижнюю вставку содержимого.

Дополнительную информацию можно найти в репозитории GitHub и в документации: https://lbbottomsheet.lunabee.studio .

Я думаю, это может помочь вам сделать то, что вы ищете. Не стесняйтесь сообщать мне, если у вас есть комментарии / вопросы :)

Здесь вы можете увидеть одну из всех возможных конфигураций BottomSheet:

iOS 15 наконец-то добавляет родной UISheetPresentationController!

Официальная документация https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller

Может быть, вы можете попробовать мой ответ https://github.com/AnYuan/AYPannel, вдохновленный Pulley. Плавный переход от перемещения ящика к прокрутке списка. Я добавил жест панорамирования в представлении прокрутки контейнера и установил shouldRecognizeSim одновременно uallyWithGestureRecognizer, чтобы вернуть YES. Более подробно в моей ссылке выше. Желание помочь.

Недавно я создал компонент под названием SwipeableView как подкласс UIView, написанный на Swift 5.1 . Он поддерживает все 4 направления, имеет несколько параметров настройки и может анимировать и интерполировать различные атрибуты и элементы (такие как ограничения макета, цвет фона / оттенка, аффинное преобразование, альфа-канал и центр просмотра, все они продемонстрированы в соответствующей витрине). Он также поддерживает координацию прокрутки с представлением внутренней прокрутки, если установлено или обнаружено автоматически. Должно быть довольно легко и просто использовать (я надеюсь)

Ссылка на https://github.com/LucaIaco/SwipeableView

доказательство концепции:

Надеюсь, это поможет

Если вы ищете решение SwiftUI 2.0, которое использует View Struct, вот он:

https://github.com/kenfai/KavSoft-Tutorials-iOS/tree/main/MapsBottomSheet

Другие вопросы по тегам