ios - 如何模拟地图应用中的底部工作表?




swift uiview (4)

任何人都可以告诉我如何在iOS 10中的新地图应用程序中模仿底页?

在Android中,您可以使用模仿此行为的 BottomSheet ,但我找不到类似iOS的内容。

这是一个带有内容插入的简单滚动视图,以便搜索栏位于底部吗?

我对iOS编程很新,所以如果有人可以帮助我创建这个布局,那将非常感激。

这就是我所说的“底页”:


2018年12月4日更新

我发布了一个基于此解决方案的库 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 以指定希望的槽口数量:

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
    }
}

以前的答案

我认为在建议的解决方案中没有对待一个重要的观点:滚动和翻译之间的过渡。

在地图中,您可能已经注意到,当tableView到达 contentOffset.y == 0 ,底部工作表会向上滑动或向下滑动。

这一点很棘手,因为当我们的平移手势开始翻译时,我们不能简单地启用/禁用滚动。 它将停止滚动,直到新的触摸开始。 这是大多数提出的解决方案中的情况。

这是我尝试实施这一动议。

起点:地​​图应用

为了开始我们的调查,让我们可视化地图的视图层次结构(在模拟器上启动地图,然后选择 Debug > Attach to process by PID or Name > Xcode 9中的 Maps )。

它没有说明动作是如何工作的,但它帮助我理解了它的逻辑。 您可以使用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来嵌入all:

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 包含我们需要的所有事件!

一个简单的实现将使用第二个pan Gesture并尝试在发生转换时重置表视图的 contentOffset

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

但它不起作用。 tableView在其自己的平移手势识别器操作触发或调用其displayLink回调时更新其 contentOffset 。 在成功覆盖 contentOffset 之后,我们的识别器无法触发。 我们唯一的机会是参与布局阶段(通过覆盖滚动视图每帧的滚动视图调用的 layoutSubviews )或者每次修改 contentOffset 时响应委托的 didScroll 方法。 我们来试试吧。

翻译实施

我们向 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
    }
}

第二个执行翻译。 它使用scrollView的平移手势的 translation(in:) 方法。

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)
}

我们的overlay的委托实现看起来像:

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()
}

结果

结果如下:

你可以在 here 找到代码。

如果您发现任何错误,请告诉我们! 请注意,您的实现当然可以使用第二个平移手势,特别是如果您在叠加层中添加标题。

更新于08/08/18

我们可以用 scrollViewDidEndDragging 替换 scrollViewDidEndDragging ,而不是在用户结束拖动时 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)
}

也许你可以尝试我的答案 https://github.com/AnYuan/AYPannel ,灵感来自Pulley。 从移动抽屉到滚动列表的平滑过渡。 我在容器滚动视图上添加了一个平移手势,并设置了shouldRecognizeSimultaneouslyWithGestureRecognizer以返回YES。 我上面的github链接中的更多细节。 希望能提供帮助。


尝试 Pulley

Pulley是一个易于使用的抽屉库,用于模仿iOS 10的地图应用程序中的抽屉。 它公开了一个简单的API,允许您使用任何UIViewController子类作为抽屉内容或主要内容。

Pulley


您可以尝试我的答案 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)    
    }
    ...
}

Here 是另一个显示底部工作表以搜索Apple Maps等位置的示例代码。





mapkit