有人能告诉我如何在iOS 10的新苹果地图应用程序中模仿底部表格吗?

在Android中,你可以使用一个BottomSheet来模仿这种行为,但我在iOS中找不到任何类似的东西。

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

我对iOS编程相当陌生,所以如果有人能帮助我创建这个布局,那将是非常感谢的。

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


当前回答

你可以试试我的答案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之类的位置。

其他回答

iOS 15终于添加了一个本地UISheetPresentationController!

官方文档 https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller

试试轮:

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

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

**为iOS 15本机支持提供此**

@IBAction func openSheet() { 
        let secondVC = self.storyboard?.instantiateViewController(withIdentifier: "SecondViewController")
        // Create the view controller.
        if #available(iOS 15.0, *) {
            let formNC = UINavigationController(rootViewController: secondVC!)
            formNC.modalPresentationStyle = UIModalPresentationStyle.pageSheet
            guard let sheetPresentationController = formNC.presentationController as? UISheetPresentationController else {
                return
            }
            sheetPresentationController.detents = [.medium(), .large()]
            sheetPresentationController.prefersGrabberVisible = true
            present(formNC, animated: true, completion: nil)
        } else {
            // Fallback on earlier versions
        }
   }

更新iOS 15

在iOS 15中,你现在可以使用原生的UISheetPresentationController。

if let sheet = viewControllerToPresent.sheetPresentationController {
    sheet.detents = [.medium(), .large()]
    // your sheet setup
}
present(viewControllerToPresent, animated: true, completion: nil)

注意,你甚至可以使用overcurrentcontext表示模式重现它的导航堆栈:

let nextViewControllerToPresent: UIViewController = ...
nextViewControllerToPresent.modalPresentationStyle = .overCurrentContext
viewControllerToPresent.present(nextViewControllerToPresent, animated: true, completion: nil)

遗产

我根据下面的答案发布了一个库。

它模仿快捷方式应用程序覆盖。详情请参阅本文。

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

SwiftUI (12/29/20)

该库的SwiftUI版本现已可用。

Color.red.dynamicOverlay(Color.green)

以前的回答

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

在Maps中,你可能注意到了,当tableView达到contenttoffset。Y == 0,底部薄片要么向上滑动要么向下滑动。

这一点很棘手,因为当平移手势开始转换时,我们不能简单地启用/禁用滚动。它会停止滚动,直到新的触摸开始。本文提出的大多数解决方案都是如此。

这是我实现这个动作的尝试。

起点:地图应用

为了开始我们的调查,让我们可视化地图的视图层次结构(在模拟器上启动地图,并选择调试>通过PID附加到进程或在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,并在转换发生时尝试重置表视图的contenttoffset:

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

But it does not work. The tableView updates its contentOffset when its own pan gesture recognizer action triggers or when its displayLink callback is called. There is no chance that our recognizer triggers right after those to successfully override the contentOffset. Our only chance is either to take part of the layout phase (by overriding layoutSubviews of the scroll view calls at each frame of the scroll view) or to respond to the didScroll method of the delegate called each time the contentOffset is modified. Let's try this one.

翻译实现

我们在OverlayVC中添加了一个委托,将滚动视图的事件分派给我们的翻译处理程序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的平移手势的翻译(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)
}

我们的覆盖的委托实现看起来就像这样:

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

我们可以用 willEndScrollingWithVelocity,而不是在用户结束拖动时启用/禁用滚动:

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

2021年的iOS 15添加了UISheetPresentationController,这是苹果首次公开发布苹果地图风格的“底稿”:

UISheetPresentationController UISheetPresentationController lets you present your view controller as a sheet. Before you present your view controller, configure its sheet presentation controller with the behavior and appearance you want for your sheet. Sheet presentation controllers specify a sheet's size based on a detent, a height where a sheet naturally rests. Detents allow a sheet to resize from one edge of its fully expanded frame while the other three edges remain fixed. You specify the detents that a sheet supports using detents, and monitor its most recently selected detent using selectedDetentIdentifier. https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller

在WWDC Session 10063:在UIKit中自定义和调整表大小中探索了这个新的底部表控件


不幸的是……

在iOS 15中,UISheetPresentationController已经启动,只有中型和大型止动。

值得注意的是,iOS 15 API中没有一个小的缺陷,它将被要求显示像Apple Maps那样始终呈现的“折叠”底部表:

在UISheetPresentationController中自定义更小的止损?

释放了介质止动,以处理共享表或邮件中的“•••More”菜单等用例:按钮触发的半页。

在iOS 15中,苹果地图现在使用这种UIKit表格表示,而不是自定义实现,这是朝着正确方向迈出的一大步。iOS 15中的苹果地图继续显示“小”条,以及1/3高的条。但是这些视图大小并不是开发人员可用的公共API。

WWDC 2021实验室的UIKit工程师似乎知道,一个小的detent将是一个非常受欢迎的UIKit组件。我希望明年能看到iOS 16的API扩展。