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

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

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

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

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

Это то, что я подразумеваю под "нижним листом":

screenshot of the collapsed bottom sheet in Maps

screenshot of the expanded bottom sheet in Maps

Ответ 1

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

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

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- Реализовать функцию shouldRecognizeSimralleluallyWith делегата и отключить свойство 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() контролирует, какой вид следует использовать в качестве нижнего листа.

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

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

enter image description here

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

enter image description here

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

enter image description here

Ответ 2

Обновление 4 декабря 2018

Я выпустил библиотеку на основе этого решения https://github.com/applidium/ADOverlayContainer.

Он имитирует наложение приложения "Ярлыки". Смотрите эту статью для деталей.

Основным компонентом библиотеки является OverlayContainerViewController. Он определяет область, где контроллер представления можно перетаскивать вверх и вниз, скрывая или раскрывая содержимое под ним.

let contentController = MapsViewController()
let overlayController = SearchViewController()

let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
    contentController,
    overlayController
]

window?.rootViewController = containerController

OverlayContainerViewControllerDelegate чтобы указать OverlayContainerViewControllerDelegate количество OverlayContainerViewControllerDelegate:

enum OverlayNotch: Int, CaseIterable {
    case minimum, medium, maximum
}

func numberOfNotches(in containerViewController: OverlayContainerViewController) -> Int {
    return OverlayNotch.allCases.count
}

func overlayContainerViewController(_ containerViewController: OverlayContainerViewController,
                                    heightForNotchAt index: Int,
                                    availableSpace: CGFloat) -> CGFloat {
    switch OverlayNotch.allCases[index] {
        case .maximum:
            return availableSpace * 3 / 4
        case .medium:
            return availableSpace / 2
        case .minimum:
            return availableSpace * 1 / 4
    }
}

Предыдущий ответ

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

Maps transition between the scroll and the translation

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

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

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

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

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

Maps debug view hierarchy

Это не говорит, как работает движение, но оно помогло мне понять логику этого. Вы можете играть с 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 когда 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()
}

Результат

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

Result

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

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

Обновление 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)
}

Ответ 3

Попробуйте Pulley:

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

Pulley Preview

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

Ответ 4

Вы можете попробовать мой ответ 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.set(contentViewController: contentVC)

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

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

Sample App like Apple Maps

Ответ 5

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

https://github.com/OfTheWolf/UBottomSheet

ubottom sheet

Ответ 6

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