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

202

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

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

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

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

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

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

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

QWERTZ
источник
1
Боюсь, ты должен сам это построить. Это вид с размытым фоновым изображением и некоторыми распознавателями жестов.
Кхан Нгуен
@KhanhNguyen да, я знаю, что должен создать его сам, но мне интересно, какие представления используются для архивирования этого эффекта и как они упорядочены и т. Д.
qwertz
Это визуальный эффект с эффектом размытия. Посмотрите этот ТАК вопрос
Кхан Нгуен
1
Я думаю, что вы можете добавить распознаватель жестов панорамирования в вид снизу и соответственно переместить вид (наблюдая за изменениями, сохраняя координаты y между событиями распознавателя жестов и вычисляя разницу).
Кхан Нгуен
2
Мне нравятся твои вопросы. Я планирую реализовать что-то вроде этого. Если бы кто-то мог написать какой-то пример, было бы неплохо.
wviana

Ответы:

330

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

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

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

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

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

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

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

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

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

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

Ахмад Элассути
источник
3
На самом деле это хороший учебник для дочерних и дочерних представлений. Спасибо :)
Эдисон
@AhmedElassuty Как я могу добавить просмотр таблицы в XIB и загрузить в качестве нижнего листа? Если бы вы могли помочь, было бы здорово Спасибо заранее!
nikhil nangia
3
@nikhilnangia Я только что обновил репо с помощью другого viewController «ScrollableBottomSheetViewController.swift», который содержит tableView. Я обновлю ответ как можно скорее. Проверьте это тоже github.com/AhmedElassuty/IOS-BottomSheet/issues/1
Ахмад Элассути
12
Я заметил, что нижний лист Apple Maps обрабатывает переход от жеста перетаскивания листа к жесту прокрутки вида прокрутки одним плавным движением, то есть пользователю не нужно останавливать один жест и запускать новый, чтобы начать прокрутку вида. Ваш пример не делает этого. Есть ли у вас мысли о том, как это может быть достигнуто? Спасибо и огромное спасибо за публикацию примера в репо!
Stoph
2
@ RafaelaLourenço Я очень рад, что вы нашли мой ответ полезным! Спасибо!
Ахмад Элассути
56

Я выпустил библиотеку на основе моего ответа ниже.

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

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

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

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

window?.rootViewController = containerController

Реализуйте, 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
    }
}

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

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

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

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

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

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

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

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

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

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

Наш вид контроллеров стеков

Давайте создадим базовую версию архитектуры 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чем enabling/ disablingсвиткой , когда концы пользователя перетаскивания:

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)
}
GaétanZ
источник
Этот подход не интерактивен на этапах анимации. Например, в Картах я могу поймать лист пальцем по мере его расширения. Затем я могу очистить анимацию, перемещаясь вверх или вниз. Он вернется к тому месту, которое ближе всего. Я считаю, что это может быть решено с помощью UIViewPropertyAnimator(который имеет возможность приостановки), а затем использовать fractionCompleteсвойство для выполнения очистки.
Aust
1
Ты прав @aust. Я забыл подтолкнуть мои предыдущие изменения. Это должно быть хорошо сейчас. Спасибо.
GaétanZ
Это отличная идея, однако я сразу вижу одну проблему. В этом translateView(following:)методе вы вычисляете высоту на основе перевода, но она может начинаться с значения, отличного от 0,0 (например, представление таблицы немного прокручивается, а наложение увеличивается при начале перетаскивания) . Это приведет к начальному прыжку. Вы можете решить эту проблему с помощью scrollViewWillBeginDraggingобратного вызова, в котором вы запомните инициал contentOffsetтабличного представления и будете использовать его в translateView(following:)вычислениях.
Якуб Трухларж
1
@ GaétanZ Мне было интересно присоединить отладчик к Maps.app на Simulator, но я получил ошибку «Не удалось подключиться к pid:« 9276 »» с последующим «Убедитесь, что« Карты »еще не запущены, и <имя пользователя> имеет разрешение на отлаживать ". - Как вы обманули Xcode, чтобы разрешить подключение к Maps.app?
MATM
1
@matm @ GaétanZ работал для меня на Xcode 10.1 / Mojave -> просто нужно отключить, чтобы отключить защиту целостности системы (SIP). Вам необходимо: 1) Перезагрузить Mac; 2) Удерживайте cmd + R; 3) В меню выберите Утилиты, затем Терминал; 4) В окне терминала типа: csrutil disable && reboot. После этого вы получите все полномочия, которые обычно дает root, в том числе возможность подключения к процессу Apple.
Валдыр
18

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

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

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

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

Раджи Джонс
источник
1
Поддерживает ли он прокрутку в базовом списке?
Ричард Линдхаут
@RichardLindhout - после запуска демонстрации похоже, что она поддерживает прокрутку, но не плавный переход от перемещения ящика к прокрутке списка.
Стоф
Пример появляется, чтобы скрыть юридическую ссылку Apple в левом нижнем углу.
Герд Кастан,
1
Супер простой вопрос, как вы получили индикатор в верхней части нижнего листа?
Исрафил
12

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

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

SCENEE
источник
fpc.set (contentViewController: newsVC), метод отсутствует в библиотеке
Фарис Мухаммед
1
Супер простой вопрос, как вы получили индикатор в верхней части нижнего листа?
Исрафил
1
@AIsrafil Я думаю, что это просто UIView
ShadeToD
Я думаю, что это действительно хорошее решение, но в iOS 13 есть проблема с представлением модального контроллера представления с этой плавающей панелью.
ShadeToD
12

Я написал свою собственную библиотеку для достижения желаемого поведения в приложении ios Maps. Это протоколно-ориентированное решение. Поэтому вам не нужно наследовать какой-либо базовый класс, вместо этого создайте контроллер листа и настройте его по своему усмотрению. Он также поддерживает внутреннюю навигацию / презентацию с или без UINavigationController.

Смотрите ссылку ниже для более подробной информации.

https://github.com/OfTheWolf/UBottomSheet

нижний лист

Ugur
источник
ЭТО должен быть выбранный ответ, так как вы все равно можете нажать на ВК за всплывающим окном.
Джей
Супер простой вопрос, как вы получили индикатор в верхней части нижнего листа?
Исрафил
1

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

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

подтверждение концепции:

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

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

Лука Иако
источник
0

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

день
источник
6
Хотя эта ссылка может ответить на вопрос, лучше включить сюда основные части ответа и предоставить ссылку для справки. Ответы, содержащие только ссылки, могут стать недействительными, если связанная страница изменится. Хотя эта ссылка может ответить на вопрос, лучше включить сюда основные части ответа и предоставить ссылку для справки. Ответы, содержащие только ссылки, могут стать недействительными, если связанная страница изменится.
Pirho