我如何模仿地图应用程序的底部表格?

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

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

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

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

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

 Maps中折叠的底部表单截图 地图中扩展的底面图截图

136574 次浏览

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

我想你知道如何:

1-通过故事板或使用xib文件创建视图控制器。

2-使用谷歌地图或苹果的MapKit。

例子

创建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)在视图中添加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到bottom sheet视图。

实现UIGestureRecognizerDelegate并将panGesture委托设置为控制器。

3-在两种情况下实现shouldRecognizeSimultaneouslyWith委托函数和禁用 scrollView isScrollEnabled属性:

  • 视图部分可见。
  • 视图是完全可见的,scrollView 内容偏移属性为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设置为动画选项,就像在示例项目中一样,如果用户向上滚动,你需要在动画完成闭包上启用滚动。 . c

示例项目

我在 repo上创建了一个带有更多选项的示例项目,这可能会让你更好地了解如何自定义流程。

在演示中,addBottomSheetView()函数控制哪个视图应该被用作底层表

项目示例截图

—局部视图

enter image description here

——FullView

enter image description here

—可滚动视图

enter image description here

试试滑轮:

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

滑轮预览

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

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

更新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到达contentOffset.y == 0时,底层表要么向上滑动,要么向下滑动。

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

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

起点:地图应用

为了开始我们的研究,让我们可视化Maps的视图层次结构(在模拟器上启动Maps并选择Debug >Attach to process by PID or Name祝辞Maps在Xcode 9)。

Maps debug view hierarchy .

它并没有告诉我们运动是如何进行的,但它帮助我理解了它的逻辑。你可以使用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具有我们需要的所有事件!< / p >

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

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

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

翻译实现

我们向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的平移手势的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)
}

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

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而不是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)
}

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

更多细节请参见下面的链接。

https://github.com/OfTheWolf/UBottomSheet

ubottom sheet

你可以试试我的答案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样例应用程序

我最近创建了一个名为SwipeableView的组件,作为UIView的子类,用Swift 5.1编写。它支持所有4个方向,有几个自定义选项,可以动画和插值不同的属性和项目(如布局约束,背景/色调颜色,仿射变换,alpha通道和视图中心,所有这些都演示了各自的展示案例)。它还支持滑动协调与内部滚动视图,如果设置或自动检测。应该是相当容易和直接使用(我希望🙂)

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

概念证明:

enter image description here

希望能有所帮助

如果你正在寻找一个使用View Struct的SwiftUI 2.0解决方案,这里是:

https://github.com/kenfai/KavSoft-Tutorials-iOS/tree/main/MapsBottomSheet

enter image description here

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

UISheetPresentationController

UISheetPresentationController让你把你的视图控制器呈现为一个表格。在呈现你的视图控制器之前,用你想要的工作表的行为和外观配置它的工作表呈现控制器。

表显示控制器根据表的止动(表自然停留的高度)指定表的大小。止动器允许薄片从其完全展开的框架的一个边缘调整大小,而其他三个边缘保持固定。使用detents指定表支持的止动,并使用selectedDetentIdentifier监视其最近选择的止动。

https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller

这个新的底页控件在WWDC Session 10063: 在UIKit中定制和调整表的大小中进行了探讨

UISheetPresentationController


不幸的是……

在iOS 15中,UISheetPresentationController只带有mediumlarge止动。

值得注意的是,iOS 15 API中没有small止回器,它将被要求显示始终呈现的“;折叠”。像苹果地图的底页:

在UISheetPresentationController中自定义较小的Detents ?< / >

medium止动被释放,以处理诸如Share Sheet或“;•••more”等用例;邮件菜单:一个按钮触发的半页。

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

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

iOS 15终于添加了原生UISheetPresentationController!

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

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

enter image description here

我们刚刚发布了一个支持iOS 11.4+的纯Swift包,它为你提供了一个可以自定义的主题和行为选项的BottomSheet。该组件使用方便,灵活。你可以在这里找到它:https://github.com/LunabeeStudio/LBBottomSheet。 在这个存储库中也有一个演示项目

例如,它支持不同的方式来管理所需的高度,还添加到它后面的控制器的能力,以检测高度变化和适应其底部内容嵌入。

你可以在GitHub存储库和文档中找到更多信息:https://lbbottomsheet.lunabee.studio

我觉得它能帮你做你想做的事。如果你有意见或问题,请不要犹豫,告诉我:)

在这里你可以看到所有可能的BottomSheet配置之一:

BottomSheet