FloatingPanel 是一个简单易用的 UI 组件,专为 Apple Maps、Shortcuts 和 Stocks 应用中采用的用户界面而设计。该用户界面在主内容旁边显示相关的内容和实用工具。
请参阅 API 参考@SPI 以了解更多详情。
示例可以在这里找到
FloatingPanel 使用 Swift 5.0+ 编写,并兼容 iOS 11.0+。
FloatingPanel 可通过 CocoaPods 获得。要安装它,只需将以下行添加到您的 Podfile
pod 'FloatingPanel'
请遵循 此文档。
import UIKit
import FloatingPanel
class ViewController: UIViewController, FloatingPanelControllerDelegate {
var fpc: FloatingPanelController!
override func viewDidLoad() {
super.viewDidLoad()
// Initialize a `FloatingPanelController` object.
fpc = FloatingPanelController()
// Assign self as the delegate of the controller.
fpc.delegate = self // Optional
// Set a content view controller.
let contentVC = ContentViewController()
fpc.set(contentViewController: contentVC)
// Track a scroll view(or the siblings) in the content view controller.
fpc.track(scrollView: contentVC.tableView)
// Add and show the views managed by the `FloatingPanelController` object to self.view.
fpc.addPanel(toParent: self)
}
}
let fpc = FloatingPanelController()
let contentVC = ...
fpc.set(contentViewController: contentVC)
fpc.isRemovalInteractionEnabled = true // Optional: Let it removable by a swipe-down
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
之前,您永远不会将同一个 FloatingPanelController
添加到层级结构中。
注意:不需要,也不建议使用 self.
前缀。这里使用它是为了更清楚地说明所使用的函数来自哪里。self
是您代码中自定义 UIViewController 的实例。
// Add the floating panel view to the controller's view on top of other views.
self.view.addSubview(fpc.view)
// REQUIRED. It makes the floating panel view have the same size as the controller's view.
fpc.view.frame = self.view.bounds
// In addition, Auto Layout constraints are highly recommended.
// Constraint the fpc.view to all four edges of your controller's view.
// It makes the layout more robust on trait collection change.
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),
])
// Add the floating panel controller to the controller hierarchy.
self.addChild(fpc)
// Show the floating panel at the initial position defined in your `FloatingPanelLayout` object.
fpc.show(animated: true) {
// Inform the floating panel controller that the transition to the controller hierarchy has completed.
fpc.didMove(toParent: self)
}
在您如上所示添加 FloatingPanelController
之后,您可以调用 fpc.show(animated: true) { }
来显示面板,以及 fpc.hide(animated: true) { }
来隐藏它。
要从层级结构中移除 FloatingPanelController
,请遵循以下示例。
// Inform the panel controller that it will be removed from the hierarchy.
fpc.willMove(toParent: nil)
// Hide the floating panel.
fpc.hide(animated: true) {
// Remove the floating panel view from your controller's view.
fpc.view.removeFromSuperview()
// Remove the floating panel controller from the controller hierarchy.
fpc.removeFromParent()
}
如果当表面位置改变时,表面高度适合 FloatingPanelController.view
的边界,则将 contentMode
指定为 .fitToBounds
fpc.contentMode = .fitToBounds
否则,FloatingPanelController
会通过最顶部位置的高度固定内容。
注意
在 .fitToBounds
模式下,表面高度会随着用户交互而变化,因此您有责任配置自动布局约束,以避免弹性表面高度破坏内容视图的布局。
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() // If needed
注意:如果您已经设置了 FloatingPanelController
实例的 delegate
属性,则 invalidateLayout()
会使用代理对象返回的布局对象覆盖 FloatingPanelController
的布局对象。
floatingPanel(_:layoutFor:)
委托之一中返回适当的布局对象。class ViewController: UIViewController, FloatingPanelControllerDelegate {
...
func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout {
return MyFloatingPanelLayout()
}
// OR
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),
]
}
}
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 版本中已弃用。
在您的锚点中使用 .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 版本中已弃用。
您可以为每种状态(.full
、.half
和 .tip
)通过 FloatingPanelLayout.backdropAlpha(for:)
更改背景幕 alpha 值。
例如,如果面板在 .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),
]
}
}
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
}
}
floatingPanelDidMove(_:)
代理方法中的 FloatingPanelController.surfaceLocation
的行为类似于 scrollViewDidScroll(_:)
中的 UIScrollView.contentOffset
。因此,您可以指定面板移动的边界,如下所示。
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))
}
}
警告
{top,bottom}InteractionBuffer
属性已从 v2 版本以来的 FloatingPanelLayout
中移除。
// Create a new appearance.
let appearance = SurfaceAppearance()
// Define shadows
let shadow = SurfaceAppearance.Shadow()
shadow.color = UIColor.black
shadow.offset = CGSize(width: 0, height: 16)
shadow.radius = 16
shadow.spread = 8
appearance.shadows = [shadow]
// Define corner radius and background color
appearance.cornerRadius = 8.0
appearance.backgroundColor = .clear
// Set the new appearance
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)
此功能可用于以下 2 种面板
您可以直接禁用平移手势识别器
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)
}
// Enable `surfaceTapGesture` only at `tip` state
func floatingPanelDidChangeState(_ vc: FloatingPanelController) {
surfaceTapGesture.isEnabled = (vc.position == .tip)
}
如果您将 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() {
// Setup Search panel
self.searchPanelVC = FloatingPanelController()
let searchVC = SearchViewController()
self.searchPanelVC.set(contentViewController: searchVC)
self.searchPanelVC.track(scrollView: contentVC.tableView)
self.searchPanelVC.addPanel(toParent: self)
// Setup Detail panel
self.detailPanelVC = FloatingPanelController()
let contentVC = ContentViewController()
self.detailPanelVC.set(contentViewController: contentVC)
self.detailPanelVC.track(scrollView: contentVC.scrollView)
self.detailPanelVC.addPanel(toParent: self)
}
在以下示例中,我在像 Apple Maps 一样打开或关闭搜索栏时,将浮动面板移动到全屏或半屏位置。
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
}
}
来自内容视图控制器的“Show”或“Show Detail” segue 将由添加浮动面板的视图控制器(以下称为“主 VC”)管理。因为浮动面板只是主 VC 的子视图(模态情况除外)。
FloatingPanelController
无法像 UINavigationController
那样管理视图控制器堆栈。如果是这样,它会变得非常复杂,并且界面将变成 UINavigationController
。此组件不应承担管理堆栈的责任。
顺便说一句,内容视图控制器可以使用 present(_:animated:completion:)
或“Present Modally”segue 以模态方式呈现视图控制器。
但是,有时您想使用另一个浮动面板显示“Show”或“Show Detail” segue 的目标视图控制器。可以重写主 VC 的 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)
的操作代理给主 VC。这就是为什么主 VC 可以处理“Show”或“Show Detail” segue 的目标视图控制器,并且您可以挂钩 show(_:sender)
以显示辅助浮动面板,并将目标视图控制器设置为内容。
这是解耦浮动面板和内容 VC 的好方法。
由于系统设计,UISearchController
无法与 FloatingPanelController
一起使用。
因为当用户与搜索栏交互时,UISearchController
会自动以模态方式呈现自身,然后在显示时将其搜索栏的父视图交换到由自身管理的视图。因此,当 UISearchController
处于活动状态时,FloatingPanelController
无法控制搜索栏,正如您从 屏幕截图 中看到的那样。
Shin Yamamoto shin@scenee.com | @scenee
FloatingPanel 在 MIT 许可证下可用。有关更多信息,请参阅 LICENSE 文件。