如何模仿“地图”应用中的底部表格?


201

谁能告诉我如何模仿iOS 10中新Maps应用程序的底部?

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

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

我是iOS编程的新手,所以如果有人可以帮助我创建此布局,将不胜感激。

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

地图中折叠的底部工作表的屏幕截图

地图中展开的底部工作表的屏幕截图


1
恐怕您必须自己构建它。这是具有模糊背景视图和某些手势识别器的视图。
Khanh Nguyen

@KhanhNguyen是的,我知道我必须自己构建它,但是我想知道的是,哪些视图用于归档此效果以及如何排序等等
qwertz

它是带有模糊效果的视觉效果视图。看到这样的问题
Khanh Nguyen

1
我认为您可以将平移手势识别器添加到底视图中并相应地移动视图(通过在手势识别器的事件之间存储y坐标来观察更改,并计算差值)。
Khanh Nguyen

2
我喜欢你的问题。我正计划实施这样的事情。如果有人可以写一些例子会很好。
wviana

Answers:


328

我不知道新的Google Maps应用程序的底部如何准确响应用户交互。但是您可以创建一个自定义视图,看起来像屏幕截图中的视图,并将其添加到主视图中。

我假设您知道如何:

1-通过情节提要或使用xib文件创建视图控制器。

2-使用googleMaps或Apple的MapKit。

1-创建2个视图控制器,例如MapViewControllerBottomSheetViewController。第一个控制器将托管地图,第二个控制器将托管底部表单。

配置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)动画bottomSheet外观

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)将“平移手势识别器”添加到视图中。

在您的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- 在两种情况下,实现shouldRecognizeSimultaneouslyWith委托函数并禁用 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
这实际上对于childViews和subViews是一个很好的教程。谢谢您:)
爱迪生(Edison)

@AhmedElassuty我如何在xib中添加tableview并加载为底页?如果您能提供帮助,那将非常感谢!
尼克希尔·南贾

3
@nikhilnangia我刚刚使用另一个包含tableView的viewController“ ScrollableBottomSheetViewController.swift”更新了存储库。我将尽快更新答案。检查这也github.com/AhmedElassuty/IOS-BottomSheet/issues/1
艾哈迈德Elassuty

12
我注意到Apple Maps底部工作表以一种平滑的动作处理了从工作表拖动手势到滚动视图滚动手势的过渡,即用户不需要停止一个手势就可以开始滚动视图而开始一个新手势。您的示例不执行此操作。您对如何实现有任何想法?谢谢,也非常感谢您将示例发布到仓库中!
Stoph

2
@RafaelaLourenço我很高兴您发现我的回答很有帮助!谢谢!
艾哈迈德·埃拉苏蒂

55

我根据以下答案发布了一个

它模仿“快捷方式”应用程序覆盖。有关详细信息,请参见本文

该库的主要组件是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
    }
}

先前的答案

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

滚动和平移之间的地图过渡

您可能已经注意到,在Maps中,当tableView到达时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拥有我们需要的所有事件!

天真的实现将使用第二个pan 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因为它还会禁用表视图中的交互。该解决方案是在Maps应用中大量使用的解决方案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)
}

我们可以使用spring动画,并在进行动画时允许用户交互,以使运动流程更好:

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

在动画阶段,这种方法不是交互式的。例如,在“地图”中,展开时可以用手指抓住它。然后,我可以通过向上或向下平移来清理动画。它将返回到最接近的位置。我相信可以使用UIViewPropertyAnimator(可以暂停)解决该问题,然后使用该fractionComplete属性执行清理。
AUST

1
你说得对@aust。我忘了推我以前的改变。现在应该很好。谢谢。
GaétanZ

这是一个好主意,但是我马上就能看到一个问题。在该translateView(following:)方法中,您将基于平移计算高度,但是高度可以从不同于0.0的值开始(例如,表格视图稍微滚动一点,并且在开始拖动时叠加层会最大化)。那将导致最初的跳跃。您可以通过scrollViewWillBeginDragging回调解决此问题,在该回调中您将记住contentOffset表视图的初始值并将其用于translateView(following:)的计算中。
雅各布·特鲁拉斯(JakubTruhlář)

1
@GaétanZ我也有兴趣在Simulator上将调试器附加到Maps.app,但出现错误“无法附加到pid:“ 9276””,然后显示“确保“ Maps”尚未运行,并且<username>有权调试它。” -如何欺骗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进程的权力。
valdyr

18

尝试滑轮

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

皮带轮预览

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


1
是否支持在基础列表中滚动?
理查德·林德豪特

@RichardLindhout-运行演示后,它似乎支持滚动,但不支持从移动抽屉到滚动列表的平滑过渡。
斯托克

该示例似乎在左下方隐藏了Apples法律链接。
Gerd Castan

1
超级简单的问题,如何将指标显示在底部页面的顶部?
Israfil

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的位置。

苹果地图等示例应用


fpc.set(contentViewController:newsVC),库中缺少方法
Faris Muhammed

1
超级简单的问题,如何将指标显示在底部页面的顶部?
Israfil

1
@AIsrafil我认为它只是一个UIView
ShadeToD

我认为这是一个非常不错的解决方案,但是在iOS 13中,使用此浮动面板显示模式视图控制器存在问题。
ShadeToD

11

我编写了自己的库来实现ios Maps应用程序中的预期行为。这是一个面向协议的解决方案。因此,您无需继承任何基类,而是创建工作表控制器并根据需要进行配置。它还支持带有或不带有UINavigationController的内部导航/演示。

有关更多详细信息,请参见下面的链接。

https://github.com/OfTheWolf/UBottomSheet

底纸


这应该是选定的答案,因为您仍然可以单击弹出窗口后面的VC。
杰伊,

超级简单的问题,如何将指标显示在底部页面的顶部?
Israfil

1

我最近创建了一个称为SwipeableView的子类的组件UIView Swift的。它支持所有4个方向,具有多​​个自定义选项,并且可以对不同的属性和项进行动画处理和插值(例如布局约束,背景/色调颜色,仿射变换,alpha通道和视图中心,所有这些都通过各自的展示案例进行了演示)。如果已设置或自动检测到,它还支持与内部滚动视图的滑动协调。应该非常简单易用(我希望I)

链接在https://github.com/LucaIaco/SwipeableView

概念证明:

在此处输入图片说明

希望能帮助到你


0

也许您可以尝试我的答案https://github.com/AnYuan/AYPannel,它受Pulley启发。从移动抽屉到滚动列表的平滑过渡。我在容器滚动视图上添加了平移手势,并设置了ShouldRecognizeSimultaneouslyWithGestureRecognizer以返回YES。我上面的github链接中有更多细节。希望能对您有所帮助。


6
尽管此链接可以回答问题,但最好在此处包括答案的基本部分,并提供链接以供参考。如果链接页面发生更改,仅链接的答案可能会无效。尽管此链接可以回答问题,但最好在此处包括答案的基本部分,并提供链接以供参考。如果链接页面发生更改,仅链接的答案可能会无效。
pirho
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.