浮动面板
浮动面板是一个简单易用的UI组件,设计灵感来自苹果地图、快捷指令和股市应用中的用户界面特性。 该用户界面在主要内容旁边显示相关内容和实用工具。 更多详细信息请参阅API参考@SPI。
- 特性
- 要求
- 安装
- CocoaPods
- Swift包管理器
- 入门
- 将浮动面板添加为子视图控制器
- 以模态方式呈现浮动面板
- 视图层次结构
- 使用方法
- 在您的视图层次结构中显示/隐藏浮动面板
- 当表面位置改变时缩放内容视图
- 使用
FloatingPanelLayout
协议自定义布局 - 更改初始布局
- 更新面板布局
- 支持横屏布局
- 在面板布局中使用内容的固有大小
- 通过
FloatingPanelController.view
框架的内边距为每个状态指定锚点 - 更改背景遮罩透明度
- 使用自定义面板状态
- 使用
FloatingPanelBehavior
协议自定义行为 - 修改浮动面板的交互
- 激活面板边缘的橡皮筋效果
- 管理平移手势动量的投射
- 指定面板移动的边界
- 自定义表面设计
- 修改表面外观
- 使用自定义抓取手柄
- 自定义抓取手柄的布局
- 自定义内容与表面边缘的填充
- 自定义表面边缘的边距
- 自定义手势
- 抑制面板交互
- 向表面视图添加点击手势
- 中断
FloatingPanelController.panGestureRecognizer
的委托方法 - 为详情创建额外的浮动面板
- 使用动画移动位置
- 使内容与浮动面板行为协同工作
- 启用背景视图的点击关闭操作
- 允许在最展开状态下滚动跟踪滚动视图的内容
- 注意事项
- 从
FloatingPanelController
的内容视图控制器进行'Show'或'Show Detail'跳转 - UISearchController问题
- 维护者
- 许可证
特性
- 简单的容器视图控制器
- 使用数值弹簧的流畅行为
- 滚动视图跟踪
- 移除交互
- 多面板支持
- 模态呈现
- 支持4个位置(上、左、下、右)
- 1个或多个磁性锚点(全屏、半屏、提示等)
- 支持所有特征环境的布局(例如横向模式)
- 常见UI元素:表面、背景和抓取手柄
- 避免常见的自动布局和手势处理问题
- 兼容Objective-C
示例可以在以下位置找到:
- Examples/Maps 类似苹果地图应用
- Examples/Stocks 类似苹果股票应用
- Examples/Samples
- Examples/SamplesObjC
系统要求
FloatingPanel 使用 Swift 5.0+ 编写,兼容 iOS 11.0+ 系统。
安装
CocoaPods
FloatingPanel 可通过 CocoaPods 安装。要安装它,只需在你的 Podfile 中添加以下行:
pod 'FloatingPanel'
Swift Package Manager
请按照此文档的说明进行操作。
开始使用
将浮动面板作为子视图控制器添加
import UIKit
import FloatingPanel
class ViewController: UIViewController, FloatingPanelControllerDelegate {
var fpc: FloatingPanelController!
override func viewDidLoad() {
super.viewDidLoad()
// 初始化一个 `FloatingPanelController` 对象
fpc = FloatingPanelController()
// 将自身指定为控制器的代理
fpc.delegate = self // 可选
// 设置内容视图控制器
let contentVC = ContentViewController()
fpc.set(contentViewController: contentVC)
// 追踪内容视图控制器中的滚动视图(或其兄弟视图)
fpc.track(scrollView: contentVC.tableView)
// 将 `FloatingPanelController` 对象管理的视图添加并显示到 self.view 中
fpc.addPanel(toParent: self)
}
}
以模态方式呈现浮动面板
let fpc = FloatingPanelController()
let contentVC = ...
fpc.set(contentViewController: contentVC)
fpc.isRemovalInteractionEnabled = true // 可选:允许通过向下滑动移除
self.present(fpc, animated: true, completion: nil)
你可以从容器视图控制器以 .overCurrentContext
样式的模态方式在 UINavigationController 上显示浮动面板。
[!注意] FloatingPanelController 有自定义的呈现控制器。如果你想自定义呈现/退出过程,请参阅 Transitioning。
视图层级
FloatingPanelController
按以下视图层级管理视图:
FloatingPanelController.view (FloatingPanelPassThroughView)
├─ .backdropView (FloatingPanelBackdropView)
└─ .surfaceView (FloatingPanelSurfaceView)
├─ .containerView (UIView)
│ └─ .contentView (FloatingPanelController.contentViewController.view)
└─ .grabber (FloatingPanelGrabberView)
使用方法
在视图层次结构中显示/隐藏浮动面板
如果你需要更多控制来显示和隐藏浮动面板,你可以放弃使用addPanel
和removePanelFromParent
方法。这些方法是FloatingPanel的show
和hide
方法的便捷封装,同时包含一些必要的设置。
使用FloatingPanelController
有两种方式:
- 将其一次性添加到层次结构中,然后调用
show
和hide
方法使其出现/消失。 - 在需要时将其添加到层次结构中,使用后再移除。
以下示例展示了如何将控制器添加到你的UIViewController
中,以及如何移除它。确保在移除之前不要重复添加相同的FloatingPanelController
到层次结构中。
注意:不需要也不推荐使用self.
前缀。这里使用它是为了更清楚地表明所使用的函数来自哪里。在你的代码中,self
是自定义UIViewController的实例。
// 将浮动面板视图添加到控制器的视图上,置于其他视图之上。
self.view.addSubview(fpc.view)
// 必需。使浮动面板视图与控制器的视图大小相同。
fpc.view.frame = self.view.bounds
// 此外,强烈建议使用Auto Layout约束。
// 将fpc.view约束到你的控制器视图的四个边缘。
// 这使得布局在特征集合变化时更加稳健。
fpc.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
fpc.view.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0.0),
fpc.view.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: 0.0),
fpc.view.rightAnchor.constraint(equalTo: self.view.rightAnchor, constant: 0.0),
fpc.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0.0),
])
// 将浮动面板控制器添加到控制器层次结构中。
self.addChild(fpc)
// 在`FloatingPanelLayout`对象中定义的初始位置显示浮动面板。
fpc.show(animated: true) {
// 通知浮动面板控制器,向控制器层次结构的过渡已完成。
fpc.didMove(toParent: self)
}
在按照上述方式添加FloatingPanelController
后,你可以调用fpc.show(animated: true) { }
来显示面板,调用fpc.hide(animated: true) { }
来隐藏它。
要从层次结构中移除FloatingPanelController
,请参考以下示例。
// 通知面板控制器它将从层次结构中移除。
fpc.willMove(toParent: nil)
// 隐藏浮动面板。
fpc.hide(animated: true) {
// 从你的控制器视图中移除浮动面板视图。
fpc.view.removeFromSuperview()
// 从控制器层次结构中移除浮动面板控制器。
fpc.removeFromParent()
}
当表面位置变化时缩放内容视图
如果在表面位置改变时,表面高度适应FloatingPanelController.view
的边界,请将contentMode
指定为.fitToBounds
fpc.contentMode = .fitToBounds
否则,FloatingPanelController
会根据最顶部位置的高度固定内容。
[!注意] 在
.fitToBounds
模式下,表面高度会随用户交互而变化,因此你有责任配置自动布局约束,以防止弹性表面高度破坏内容视图的布局。
使用FloatingPanelLayout
协议自定义布局
更改初始布局
class ViewController: UIViewController, FloatingPanelControllerDelegate {
... {
fpc = FloatingPanelController(delegate: self)
fpc.layout = MyFloatingPanelLayout()
}
}
class MyFloatingPanelLayout: FloatingPanelLayout {
let position: FloatingPanelPosition = .bottom
let initialState: FloatingPanelState = .tip
let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [
.full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea),
.half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea),
.tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .safeArea),
]
}
更新面板布局
有两种方法可以更新面板布局。
- 直接手动将
FloatingPanelController.layout
设置为新的布局对象。
fpc.layout = MyPanelLayout()
fpc.invalidateLayout() // 如果需要
注意:如果你已经设置了FloatingPanelController
实例的delegate
属性,invalidateLayout()
会用委托对象返回的布局对象覆盖FloatingPanelController
的布局对象。
- 在两个
floatingPanel(_:layoutFor:)
委托方法之一中返回适当的布局对象。
class ViewController: UIViewController, FloatingPanelControllerDelegate {
...
func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout {
return MyFloatingPanelLayout()
}
// 或者
func floatingPanel(_ vc: FloatingPanelController, layoutFor size: CGSize) -> FloatingPanelLayout {
return MyFloatingPanelLayout()
}
}
支持横屏布局
class ViewController: UIViewController, FloatingPanelControllerDelegate {
...
func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout {
return (newCollection.verticalSizeClass == .compact) ? LandscapePanelLayout() : FloatingPanelBottomLayout()
}
}
class LandscapePanelLayout: FloatingPanelLayout {
let position: FloatingPanelPosition = .bottom
let initialState: FloatingPanelState = .tip
let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [
.full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea),
.tip: FloatingPanelLayoutAnchor(absoluteInset: 69.0, edge: .bottom, referenceGuide: .safeArea),
]
func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] {
return [
surfaceView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 8.0),
surfaceView.widthAnchor.constraint(equalToConstant: 291),
]
}
}
在面板布局中使用内容的固有大小
-
使用固有高度大小布局你的内容视图。例如,请参见Main.storyboard中的"Detail View Controller scene"/"Intrinsic View Controller scene"。'Stack View.bottom'约束决定了固有高度。
-
使用
FloatingPanelIntrinsicLayoutAnchor
指定布局锚点。
class IntrinsicPanelLayout: FloatingPanelLayout {
let position: FloatingPanelPosition = .bottom
let initialState: FloatingPanelState = .full
let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [
.full: FloatingPanelIntrinsicLayoutAnchor(absoluteOffset: 0, referenceGuide: .safeArea),
.half: FloatingPanelIntrinsicLayoutAnchor(fractionalOffset: 0.5, referenceGuide: .safeArea),
]
...
}
[!警告]
FloatingPanelIntrinsicLayout
在v1版本中已弃用。
通过FloatingPanelController.view
框架的插入值为每个状态指定锚点
在你的锚点中使用.superview
参考指南。
class MyFullScreenLayout: FloatingPanelLayout {
...
let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [
.full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .superview),
.half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .superview),
.tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .superview),
]
}
[!警告]
FloatingPanelFullScreenLayout
在v1版本中已弃用。
更改背景遮罩透明度
你可以通过FloatingPanelLayout.backdropAlpha(for:)
为每个状态(.full
、.half
和.tip
)更改背景遮罩透明度。
例如,如果面板在.half
状态下看起来像没有背景视图,那么是时候实现backdropAlpha API并为该状态返回一个值,如下所示:
class MyPanelLayout: FloatingPanelLayout {
func backdropAlpha(for state: FloatingPanelState) -> CGFloat {
switch state {
case .full, .half: return 0.3
default: return 0.0
}
}
}
使用自定义面板状态
你可以定义自定义面板状态并像以下示例一样使用它们。
extension FloatingPanelState {
static let lastQuart: FloatingPanelState = FloatingPanelState(rawValue: "lastQuart", order: 750)
static let firstQuart: FloatingPanelState = FloatingPanelState(rawValue: "firstQuart", order: 250)
}
class FloatingPanelLayoutWithCustomState: FloatingPanelBottomLayout {
override var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] {
return [
.full: FloatingPanelLayoutAnchor(absoluteInset: 0.0, edge: .top, referenceGuide: .safeArea),
.lastQuart: FloatingPanelLayoutAnchor(fractionalInset: 0.75, edge: .bottom, referenceGuide: .safeArea),
.half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea),
.firstQuart: FloatingPanelLayoutAnchor(fractionalInset: 0.25, edge: .bottom, referenceGuide: .safeArea),
.tip: FloatingPanelLayoutAnchor(absoluteInset: 20.0, edge: .bottom, referenceGuide: .safeArea),
]
}
}
使用 FloatingPanelBehavior
协议自定义行为
修改浮动面板的交互
class ViewController: UIViewController, FloatingPanelControllerDelegate {
...
func viewDidLoad() {
...
fpc.behavior = CustomPanelBehavior()
}
}
class CustomPanelBehavior: FloatingPanelBehavior {
let springDecelerationRate = UIScrollView.DecelerationRate.fast.rawValue + 0.02
let springResponseTime = 0.4
func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedState: FloatingPanelState) -> Bool {
return true
}
}
[!警告]
floatingPanel(_ vc:behaviorFor:)
在 v1 版本中已弃用。
在面板边缘激活橡皮筋效果
class MyPanelBehavior: FloatingPanelBehavior {
...
func allowsRubberBanding(for edge: UIRectEdge) -> Bool {
return true
}
}
管理平移手势动量的投射
这允许完全投射式的面板行为。例如,用户可以从提示位置附近向上滑动面板至全屏。
class MyPanelBehavior: FloatingPanelBehavior {
...
func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedState: FloatingPanelPosition) -> Bool {
return true
}
}
指定面板移动的边界
FloatingPanelController.surfaceLocation
在 floatingPanelDidMove(_:)
代理方法中的行为类似于 UIScrollView.contentOffset
在 scrollViewDidScroll(_:)
中的行为。
因此,你可以像下面这样指定面板移动的边界。
func floatingPanelDidMove(_ vc: FloatingPanelController) {
if vc.isAttracting == false {
let loc = vc.surfaceLocation
let minY = vc.surfaceLocation(for: .full).y - 6.0
let maxY = vc.surfaceLocation(for: .tip).y + 6.0
vc.surfaceLocation = CGPoint(x: loc.x, y: min(max(loc.y, minY), maxY))
}
}
[!警告] 自 v2 版本起,
{top,bottom}InteractionBuffer
属性已从FloatingPanelLayout
中移除。
自定义表面设计
修改表面外观
// 创建新的外观
let appearance = SurfaceAppearance()
// 定义阴影
let shadow = SurfaceAppearance.Shadow()
shadow.color = UIColor.black
shadow.offset = CGSize(width: 0, height: 16)
shadow.radius = 16
shadow.spread = 8
appearance.shadows = [shadow]
// 定义圆角半径和背景颜色
appearance.cornerRadius = 8.0
appearance.backgroundColor = .clear
// 设置新的外观
fpc.surfaceView.appearance = appearance
使用自定义抓取手柄
let myGrabberHandleView = MyGrabberHandleView()
fpc.surfaceView.grabberHandle.isHidden = true
fpc.surfaceView.addSubview(myGrabberHandleView)
自定义抓取手柄的布局
fpc.surfaceView.grabberHandlePadding = 10.0
fpc.surfaceView.grabberHandleSize = .init(width: 44.0, height: 12.0)
[!注意] 在左/右位置时,
grabberHandleSize
的宽度和高度会互换。
自定义内容与表面边缘的填充
fpc.surfaceView.contentPadding = .init(top: 20, left: 20, bottom: 20, right: 20)
自定义表面边缘的边距
fpc.surfaceView.containerMargins = .init(top: 20.0, left: 16.0, bottom: 16.0, right: 16.0)
此功能可用于以下两种面板:
- 类似Facebook/Slack的面板,其表面顶部边缘与抓取手柄分离。
- iOS原生面板,例如用于显示AirPods信息。
自定义手势
抑制面板交互
您可以直接禁用平移手势识别器
fpc.panGestureRecognizer.isEnabled = false
或使用这个 FloatingPanelControllerDelegate
方法。
func floatingPanelShouldBeginDragging(_ vc: FloatingPanelController) -> Bool {
return aCondition ? false : true
}
为表面视图添加点击手势
override func viewDidLoad() {
...
let surfaceTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleSurface(tapGesture:)))
fpc.surfaceView.addGestureRecognizer(surfaceTapGesture)
surfaceTapGesture.isEnabled = (fpc.position == .tip)
}
// 仅在 `tip` 状态下启用 `surfaceTapGesture`
func floatingPanelDidChangeState(_ vc: FloatingPanelController) {
surfaceTapGesture.isEnabled = (vc.position == .tip)
}
中断 FloatingPanelController.panGestureRecognizer
的代理方法
如果您将 FloatingPanelController.panGestureRecognizer.delegateProxy
设置为一个采用 UIGestureRecognizerDelegate
的对象,它将覆盖平移手势识别器的代理方法。
class MyGestureRecognizerDelegate: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return false
}
}
class ViewController: UIViewController {
let myGestureDelegate = MyGestureRecognizerDelegate()
func setUpFpc() {
....
fpc.panGestureRecognizer.delegateProxy = myGestureDelegate
}
}
为详细信息创建额外的浮动面板
override func viewDidLoad() {
// 设置搜索面板
self.searchPanelVC = FloatingPanelController()
let searchVC = SearchViewController()
self.searchPanelVC.set(contentViewController: searchVC)
self.searchPanelVC.track(scrollView: contentVC.tableView)
self.searchPanelVC.addPanel(toParent: self)
// 设置详细信息面板
self.detailPanelVC = FloatingPanelController()
let contentVC = ContentViewController()
self.detailPanelVC.set(contentViewController: contentVC)
self.detailPanelVC.track(scrollView: contentVC.scrollView)
self.detailPanelVC.addPanel(toParent: self)
}
通过动画移动位置
在下面的例子中,我在打开或关闭搜索栏时将浮动面板移动到全屏或半屏位置,类似于苹果地图的效果。
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
...
fpc.move(to: .half, animated: true)
}
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
...
fpc.move(to: .full, animated: true)
}
你也可以使用视图动画来移动面板。
UIView.animate(withDuration: 0.25) {
self.fpc.move(to: .half, animated: false)
}
使用浮动面板行为协调您的内容
class ViewController: UIViewController, FloatingPanelControllerDelegate {
...
func floatingPanelWillBeginDragging(_ vc: FloatingPanelController) {
if vc.position == .full {
searchVC.searchBar.showsCancelButton = false
searchVC.searchBar.resignFirstResponder()
}
}
func floatingPanelWillEndDragging(_ vc: FloatingPanelController, withVelocity velocity: CGPoint, targetState: UnsafeMutablePointer<FloatingPanelState>) {
if targetState.pointee != .full {
searchVC.hideHeader()
}
}
}
启用背景视图的点击关闭功能
默认情况下,点击关闭功能是禁用的。需要按以下方式启用:
fpc.backdropView.dismissalTapGestureRecognizer.isEnabled = true
允许在最大展开状态以外滚动跟踪滚动视图的内容
只需在 floatingPanel(:_:shouldAllowToScroll:in)
代理方法中定义允许内容滚动的条件。如果返回值为 true,当滚动位置不在内容顶部时,滚动内容将可以滚动。
class MyViewController: FloatingPanelControllerDelegate {
...
func floatingPanel(
_ fpc: FloatingPanelController,
shouldAllowToScroll trackingScrollView: UIScrollView,
in state: FloatingPanelState
) -> Bool {
return state == .full || state == .half
}
}
注释
从FloatingPanelController
的内容视图控制器进行'Show'或'Show Detail'转场
从内容视图控制器进行的'Show'或'Show Detail'转场将由添加浮动面板的视图控制器(以下称为"主视图控制器")管理。因为浮动面板只是主视图控制器的一个子视图(除了模态形式)。
FloatingPanelController
无法像UINavigationController
那样管理视图控制器堆栈。如果这样做,它会变得非常复杂,接口也会变成UINavigationController
。这个组件不应该承担管理堆栈的责任。
顺便说一下,内容视图控制器可以使用present(_:animated:completion:)
或'Present Modally'转场以模态方式呈现视图控制器。
然而,有时你可能想用另一个浮动面板显示'Show'或'Show Detail'转场的目标视图控制器。通过重写主视图控制器的show(_:sender)
方法可以实现这一点!
以下是一个例子:
class ViewController: UIViewController {
var fpc: FloatingPanelController!
var secondFpc: FloatingPanelController!
...
override func show(_ vc: UIViewController, sender: Any?) {
secondFpc = FloatingPanelController()
secondFpc.set(contentViewController: vc)
secondFpc.addPanel(toParent: self)
}
}
FloatingPanelController
对象将show(_:sender)
的动作代理给主视图控制器。这就是为什么主视图控制器可以处理'Show'或'Show Detail'转场的目标视图控制器,而你可以钩住show(_:sender)
来显示一个次要的浮动面板,并将目标视图控制器设置为其内容。
这是一种很好的方法来解耦浮动面板和内容视图控制器。
UISearchController问题
由于系统设计,UISearchController
无法与FloatingPanelController
一起使用。
这是因为当用户与搜索栏交互时,UISearchController
会自动以模态方式呈现自己,然后它会将搜索栏的父视图切换到由其自身管理的视图。结果,当搜索栏处于活动状态时,FloatingPanelController
无法控制搜索栏,正如你可以从这个截图中看到的那样。
维护者
山本 晋 shin@scenee.com | @scenee
许可证
FloatingPanel 基于 MIT 许可证发布。更多信息请查看 LICENSE 文件。