Sources/FloatingPanel.docc/FloatingPanel API Guide.md
FloatingPanelLayout protocol
FloatingPanelBehavior protocol
FloatingPanelController manages the views as the following view hierarchy.
FloatingPanelController.view (FloatingPanelPassThroughView)
├─ .backdropView (FloatingPanelBackdropView)
└─ .surfaceView (FloatingPanelSurfaceView)
├─ .containerView (UIView)
│ └─ .contentView (FloatingPanelController.contentViewController.view)
└─ .grabber (FloatingPanelGrabberView)
If you need more control over showing and hiding the floating panel, you can forgo the addPanel and removePanelFromParent methods. These methods are a convenience wrapper for FloatingPanel's show and hide methods along with some required setup.
There are two ways to work with the FloatingPanelController:
show and hide methods to make it appear/disappear.The following example shows how to add the controller to your UIViewController and how to remove it. Make sure that you never add the same FloatingPanelController to the hierarchy before removing it.
NOTE: self. prefix is not required, nor recommended. It's used here to make it clearer where do the functions used come from. self is an instance of a custom UIViewController in your code.
// 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)
}
After you add the FloatingPanelController as seen above, you can call fpc.show(animated: true) { } to show the panel and fpc.hide(animated: true) { } to hide it.
To remove the FloatingPanelController from the hierarchy, follow the example below.
// 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()
}
Specify the contentMode to .fitToBounds if the surface height fits the bounds of FloatingPanelController.view when the surface position changes
fpc.contentMode = .fitToBounds
Otherwise, FloatingPanelController fixes the content by the height of the top most position.
[!NOTE] In
.fitToBoundsmode, the surface height changes as following a user interaction so that you have a responsibility to configure Auto Layout constrains not to break the layout of a content view by the elastic surface height.
FloatingPanelLayout protocolclass 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),
]
}
There are 2 ways to update the panel layout.
FloatingPanelController.layout to the new layout object directly.fpc.layout = MyPanelLayout()
fpc.invalidateLayout() // If needed
Note: If you already set the delegate property of your FloatingPanelController instance, invalidateLayout() overrides the layout object of FloatingPanelController with one returned by the delegate object.
floatingPanel(_:layoutFor:) delegates.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),
]
...
}
[!WARNING]
FloatingPanelIntrinsicLayoutis deprecated on v1.
FloatingPanelController.view frameUse .superview reference guide in your anchors.
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),
]
}
[!WARNING]
FloatingPanelFullScreenLayoutis deprecated on v1.
You can change the backdrop alpha by FloatingPanelLayout.backdropAlpha(for:) for each state(.full, .half and .tip).
For instance, if a panel seems like the backdrop view isn't there on .half state, it's time to implement the backdropAlpha API and return a value for the state as below.
class MyPanelLayout: FloatingPanelLayout {
func backdropAlpha(for state: FloatingPanelState) -> CGFloat {
switch state {
case .full, .half: return 0.3
default: return 0.0
}
}
}
You're able to define custom panel states and use them as the following example.
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 protocolclass 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
}
}
[!WARNING]
floatingPanel(_ vc:behaviorFor:)is deprecated on v1.
class MyPanelBehavior: FloatingPanelBehavior {
...
func allowsRubberBanding(for edge: UIRectEdge) -> Bool {
return true
}
}
This allows full projectional panel behavior. For example, a user can swipe up a panel from tip to full nearby the tip position.
class MyPanelBehavior: FloatingPanelBehavior {
...
func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedState: FloatingPanelState) -> Bool {
return true
}
}
FloatingPanelController.surfaceLocation in floatingPanelDidMove(_:) delegate method behaves like UIScrollView.contentOffset in scrollViewDidScroll(_:).
As a result, you can specify the boundary of a panel move as below.
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))
}
}
[!WARNING]
{top,bottom}InteractionBufferproperty is removed fromFloatingPanelLayoutsince v2.
// 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)
[!NOTE]
grabberHandleSizewidth and height are reversed in the left/right position.
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)
The feature can be used for these 2 kind panels
You can disable the pan gesture recognizer directly
fpc.panGestureRecognizer.isEnabled = false
Or use this FloatingPanelControllerDelegate method.
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.panGestureRecognizerIf you are set FloatingPanelController.panGestureRecognizer.delegateProxy to an object adopting UIGestureRecognizerDelegate, it overrides delegate methods of the pan gesture recognizer.
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)
}
In the following example, I move a floating panel to full or half position while opening or closing a search bar like Apple Maps.
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
...
fpc.move(to: .half, animated: true)
}
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
...
fpc.move(to: .full, animated: true)
}
You can also use a view animation to move a panel.
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()
}
}
}
The tap-to-dismiss action is disabled by default. So it needs to be enabled as below.
fpc.backdropView.dismissalTapGestureRecognizer.isEnabled = true
Just define conditions to allow content scrolling in floatingPanel(:_:shouldAllowToScroll:in) delegate method. If the returned value is true, the scroll content scrolls when its scroll position is not at the top of the content.
class MyViewController: FloatingPanelControllerDelegate {
...
func floatingPanel(
_ fpc: FloatingPanelController,
shouldAllowToScroll trackingScrollView: UIScrollView,
in state: FloatingPanelState
) -> Bool {
return state == .full || state == .half
}
}
FloatingPanelController's content view controller'Show' or 'Show Detail' segues from a content view controller will be managed by a view controller(hereinafter called 'master VC') adding a floating panel. Because a floating panel is just a subview of the master VC(except for modality).
FloatingPanelController has no way to manage a stack of view controllers like UINavigationController. If so, it would be so complicated and the interface will become UINavigationController. This component should not have the responsibility to manage the stack.
By the way, a content view controller can present a view controller modally with present(_:animated:completion:) or 'Present Modally' segue.
However, sometimes you want to show a destination view controller of 'Show' or 'Show Detail' segue with another floating panel. It's possible to override show(_:sender) of the master VC!
Here is an example.
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)
}
}
A FloatingPanelController object proxies an action for show(_:sender) to the master VC. That's why the master VC can handle a destination view controller of a 'Show' or 'Show Detail' segue and you can hook show(_:sender) to show a secondary floating panel set the destination view controller to the content.
It's a great way to decouple between a floating panel and the content VC.
UISearchController isn't able to be used with FloatingPanelController by the system design.
Because UISearchController automatically presents itself modally when a user interacts with the search bar, and then it swaps the superview of the search bar to the view managed by itself while it displays. As a result, FloatingPanelController can't control the search bar when it's active, as you can see from the screen shot.