it-roy-ru.com

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

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

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

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

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

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

 screenshot of the collapsed bottom sheet in Maps

 screenshot of the expanded bottom sheet in Maps

127
qwertz

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

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

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 в качестве параметра анимации, как в примере проекта, вам нужно включить прокрутку при закрытии завершения анимации, если пользователь прокручивает вверх.

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

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

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

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

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

 enter image description here

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

 enter image description here

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

 enter image description here

221
Ahmad Elassuty

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

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

Он имитирует оверлей приложения Shortcuts, который включает в себя:

  • Плавные переходы между свитком и переводом
  • Эффект резинкой
  • Пользовательское перетаскиваемое содержимое
  • Неограниченные метки

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

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

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, когда срабатывает его собственное действие распознавателя жестов панорамирования или когда вызывается его обратный вызов 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)
}
32
GaétanZ

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

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

Pulley Preview

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

13
Rajee Jones

Вы можете попробовать мой ответ 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

6
SCENEE

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

1
day

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

https://github.com/OfTheWolf/UBottomSheet

 ubottom sheet

1
ugur