谁能告诉我如何在 iOS 10 的新 Apple Maps 应用程序中模仿底部表格?
在 Android 中,您可以使用模仿这种行为的 BottomSheet
,但我在 iOS 中找不到类似的东西。
这是一个带有内容插入的简单滚动视图,因此搜索栏位于底部?
我对 iOS 编程相当陌生,所以如果有人可以帮助我创建这个布局,那将不胜感激。
这就是我所说的“底页”:
https://i.stack.imgur.com/doVzSm.jpg
我不知道新地图应用程序的底页究竟如何响应用户交互。但是您可以创建一个类似于屏幕截图中的自定义视图并将其添加到主视图中。
我假设你知道如何:
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)动画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 设置为动画选项,例如在示例项目中,so you need to enable scrolling on the animation completion closure if the user is scrolling up.
示例项目
我在 this 存储库上创建了一个包含更多选项的示例项目,它可以让您更好地了解如何自定义流程。
In the demo, addBottomSheetView() function controls which view should be used as a bottom sheet.
示例项目截图
局部视图
https://i.stack.imgur.com/AQ7fW.png
全视图
https://i.stack.imgur.com/YVOPS.png
可滚动视图
https://i.stack.imgur.com/GoN5K.png
更新 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)
遗产
我根据下面的回答发布了 library。
它模仿快捷方式应用程序覆盖。有关详细信息,请参阅 this article。
库的主要组件是 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 version 现在可用。
Color.red.dynamicOverlay(Color.green)
上一个答案
我认为建议的解决方案中没有处理一个重要的点:滚动和翻译之间的过渡。
https://i.stack.imgur.com/BqlYi.gif
在 Maps 中,您可能已经注意到,当 tableView 到达 contentOffset.y == 0
时,底部工作表要么向上滑动,要么向下滑动。
这一点很棘手,因为当我们的平移手势开始翻译时,我们不能简单地启用/禁用滚动。它会停止滚动,直到新的触摸开始。此处提出的大多数解决方案都是这种情况。
这是我实施这一议案的尝试。
起点:地图应用
为了开始我们的研究,让我们可视化 Maps 的视图层次结构(在模拟器上启动 Maps 并在 Xcode 9 中选择 Debug
> Attach to process by PID or Name
> Maps
)。
https://i.stack.imgur.com/0qlJx.png
它没有说明动作是如何工作的,但它帮助我理解了它的逻辑。您可以使用 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
包含我们需要的所有事件!
一个简单的实现会使用第二个平移手势并在转换发生时尝试重置表格视图的 contentOffset
:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
但它不起作用。当它自己的平移手势识别器动作触发或调用它的 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()
}
结果
结果如下:
https://i.stack.imgur.com/fihVW.gif
您可以找到代码 here。
如果您发现任何错误,请告诉我!请注意,您的实现当然可以使用第二个平移手势,特别是如果您在叠加层中添加标题。
更新 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)
}
UIViewPropertyAnimator
(具有暂停功能)来解决,然后使用 fractionComplete
属性执行清理。
translateView(following:)
方法中,您根据平移计算高度,但它可以从不同于 0.0 的值开始 (例如,表格视图会滚动一点,当您开始拖动时,覆盖会最大化)我>。这将导致最初的跳跃。您可以通过 scrollViewWillBeginDragging
回调解决此问题,您可以在其中记住表格视图的初始 contentOffset
并在 translateView(following:)
的计算中使用它。
csrutil disable && reboot
。然后,您将拥有 root 通常应该赋予您的所有权力,包括能够附加到 Apple 进程。
试试 Pulley:
Pulley 是一个易于使用的抽屉库,旨在模仿 iOS 10 的地图应用程序中的抽屉。它公开了一个简单的 API,允许您使用任何 UIViewController 子类作为抽屉内容或主要内容。
https://i.imgur.com/bmEWqy7.gif
https://github.com/52inc/Pulley
我编写了自己的库来实现 ios Maps 应用程序中的预期行为。这是一个面向协议的解决方案。因此,您不需要继承任何基类,而是创建一个工作表控制器并根据需要进行配置。它还支持带有或不带有 UINavigationController 的内部导航/演示。
有关更多详细信息,请参见下面的链接。
https://github.com/OfTheWolf/UBottomSheet
https://github.com/OfTheWolf/UBottomSheet/raw/master/records/record1.gif
你可以试试我的回答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 地图等位置。
https://i.stack.imgur.com/a2sEB.gif
vc
和 fps
,两者都应该是 floatingPanel
),地图示例显示面板跳转方式向下拖动时短暂向上,示例代码很混乱。
2021 年的 iOS 15 增加了 UISheetPresentationController
,这是 Apple 首次公开发布 Apple 地图样式的“底页”:
UISheetPresentationController UISheetPresentationController 允许您将视图控制器呈现为工作表。在展示您的视图控制器之前,请使用您想要的工作表的行为和外观配置其工作表展示控制器。工作表显示控制器根据卡位指定工作表的大小,即工作表自然放置的高度。定位器允许纸张从其完全展开的框架的一个边缘调整大小,而其他三个边缘保持固定。您可以使用 detents 指定工作表支持的 detent,并使用 selectedDetentIdentifier 监视其最近选择的 detent。 https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller
在 WWDC Session 10063 中探索了这个新的底部表单控件:Customize and Resize Sheets in UIKit
https://i.stack.imgur.com/oh9Qi.jpg
很遗憾....
在 iOS 15 中,UISheetPresentationController
仅使用 medium
和 large
定位器启动。
iOS 15 API 中明显没有 small
定位,这将需要显示始终呈现的“折叠”底页,如 Apple 地图:
在 UISheetPresentationController 中自定义较小的 Detents?
medium
定位器已发布以处理诸如共享表或邮件中的“••• 更多”菜单之类的用例:按钮触发的半页。
在 iOS 15 中,Apple Maps 现在正在使用这个 UIKit 表单演示而不是自定义实现,这是朝着正确方向迈出的一大步。 iOS 15 中的 Apple 地图继续显示“小”条以及 1/3 高度条。但是这些视图大小不是开发人员可用的公共 API。
WWDC 2021 实验室的 UIKit 工程师似乎知道 small
detent 将是一个非常受欢迎的 UIKit 组件。我预计明年会看到 iOS 16 的 API 扩展。
.custom(identifier: .small) { context in 0.3 * context.maximumDetentValue }
**适用于此的 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
}
}
https://i.stack.imgur.com/j7INF.gif
我们刚刚发布了一个支持 iOS 11.4+ 的纯 Swift 包,它为您提供了一个带有可以自定义的主题和行为选项的 BottomSheet。该组件易于使用且灵活。您可以在此处找到它:https://github.com/LunabeeStudio/LBBottomSheet。此存储库中也提供了一个演示项目。
例如,它支持不同的方式来管理所需的高度,并且还为它背后的控制器增加了检测高度变化和适应其底部内容插图的能力。
您可以在 GitHub 存储库和文档中找到更多信息:https://lbbottomsheet.lunabee.studio。
我认为它可以帮助你做你正在寻找的东西。如果您有意见/问题,请随时告诉我 :)
在这里,您可以看到所有可能的 BottomSheet 配置之一:
https://i.stack.imgur.com/sJufJ.png
iOS 15 终于添加了原生 UISheetPresentationController
!
官方文档https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller
我最近创建了一个名为 SwipeableView
的组件作为 UIView
的子类,用 Swift 5.1 编写。它支持所有 4 个方向,具有多个自定义选项,并且可以动画和插入不同的属性和项目(例如布局约束、背景/色调颜色、仿射变换、Alpha 通道和视图中心,所有这些都在各自的展示案例中进行了演示)。如果设置或自动检测到,它还支持与内部滚动视图的滑动协调。使用起来应该非常简单直接(我希望🙂)
https://github.com/LucaIaco/SwipeableView 处的链接
概念证明:
https://i.stack.imgur.com/BDiCX.gif
希望能帮助到你
也许你可以试试我的答案 https://github.com/AnYuan/AYPannel,灵感来自 Pulley。从移动抽屉到滚动列表的平滑过渡。我在容器滚动视图上添加了一个平移手势,并将 shouldRecognizeSimultaneouslyWithGestureRecognizer 设置为返回 YES。更多细节在我上面的 github 链接中。希望提供帮助。
如果您正在寻找使用 View
Struct 的 SwiftUI 2.0 解决方案,这里是:
https://github.com/kenfai/KavSoft-Tutorials-iOS/tree/main/MapsBottomSheet
https://i.stack.imgur.com/5xNeL.gif
不定期副业成功案例分享