Skip to content

Repository files navigation

Swift 5PlatformVersionGitHub Workflow Status (with branch)Carthage compatible

FloatingPanel

FloatingPanel is a simple and easy-to-use UI component designed for a user interface featured in Apple Maps, Shortcuts and Stocks app. The user interface displays related content and utilities alongside the main content.

Please see also the API reference for more details.

MapsStocks

Maps(Landscape)

Features

  • Simple container view controller
  • Fluid behavior using numeric springing
  • Scroll view tracking
  • Removal interaction
  • Multi panel support
  • Modal presentation
  • Support for 4 positions (top, left, bottom, right)
  • 1 or more magnetic anchors(full, half, tip and more)
  • Layout support for all trait environments(i.e. Landscape orientation)
  • Common UI elements: surface, backdrop and grabber handle
  • Free from common Auto Layout and gesture handling issues
  • Compatible with Objective-C

Examples can be found here:

Requirements

FloatingPanel is written in Swift 5.0+ and compatible with iOS 11.0+.

✏️ If you'd like to use Swift 4.0, please use FloatingPanel v1.

Installation

CocoaPods

FloatingPanel is available through CocoaPods. To install it, simply add the following line to your Podfile:

pod'FloatingPanel'

✏️ FloatingPanel v1.7.0 or later requires CocoaPods v1.7.0+ for swift_versions support.

Carthage

For Carthage, add the following to your Cartfile:

github "scenee/FloatingPanel"

Swift Package Manager

Follow this doc.

Getting Started

Add a floating panel as a child view controller

import UIKit
import FloatingPanel
classViewController:UIViewController,FloatingPanelControllerDelegate{varfpc:FloatingPanelController!overridefunc 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.
letcontentVC=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)}}

Present a floating panel as a modality

letfpc=FloatingPanelController()letcontentVC=...
fpc.set(contentViewController: contentVC)
fpc.isRemovalInteractionEnabled =true // Optional: Let it removable by a swipe-down
self.present(fpc, animated:true, completion:nil)

You can show a floating panel over UINavigationController from the container view controllers as a modality of .overCurrentContext style.

✏️ FloatingPanelController has the custom presentation controller. If you would like to customize the presentation/dismissal, please see Transitioning.

View hierarchy

FloatingPanelController manages the views as the following view hierarchy.

FloatingPanelController.view (FloatingPanelPassThroughView)
├─ .backdropView (FloatingPanelBackdropView)
└─ .surfaceView (FloatingPanelSurfaceView)
├─ .containerView (UIView)
│ └─ .contentView (FloatingPanelController.contentViewController.view)
└─ .grabber (FloatingPanelGrabberView)

Usage

Show/Hide a floating panel in a view with your view hierarchy

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:

  1. Add it to the hierarchy once and then call show and hide methods to make it appear/disappear.
  2. Add it to the hierarchy when needed and remove afterwards.

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 =falseNSLayoutConstraint.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()}

Scale the content view when the surface position changes

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.

✏️ In .fitToBounds mode, 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.

Customize the layout with FloatingPanelLayout protocol

Change the initial layout

class ViewController:UIViewController,FloatingPanelControllerDelegate{...{
fpc = FloatingPanelController(delegate:self)
fpc.layout =MyFloatingPanelLayout()}}classMyFloatingPanelLayout:FloatingPanelLayout{letposition:FloatingPanelPosition=.bottom
letinitialState:FloatingPanelState=.tip
letanchors:[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),]}

Update your panel layout

There are 2 ways to update the panel layout.

  1. Manually set 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.

  1. Returns an appropriate layout object in one of 2 floatingPanel(_:layoutFor:) delegates.
classViewController:UIViewController,FloatingPanelControllerDelegate{...func floatingPanel(_ vc:FloatingPanelController, layoutFor newCollection:UITraitCollection)->FloatingPanelLayout{returnMyFloatingPanelLayout()}
// OR
func floatingPanel(_ vc:FloatingPanelController, layoutFor size:CGSize)->FloatingPanelLayout{returnMyFloatingPanelLayout()}}

Support your landscape layout

classViewController:UIViewController,FloatingPanelControllerDelegate{...func floatingPanel(_ vc:FloatingPanelController, layoutFor newCollection:UITraitCollection)->FloatingPanelLayout{return(newCollection.verticalSizeClass ==.compact)?LandscapePanelLayout():FloatingPanelBottomLayout()}}classLandscapePanelLayout:FloatingPanelLayout{letposition:FloatingPanelPosition=.bottom
letinitialState:FloatingPanelState=.tip
letanchors:[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),]}}

Use the intrinsic size of a content in your panel layout

  1. Lay out your content View with the intrinsic height size. For example, see "Detail View Controller scene"/"Intrinsic View Controller scene" of Main.storyboard. The 'Stack View.bottom' constraint determines the intrinsic height.
  2. Specify layout anchors using FloatingPanelIntrinsicLayoutAnchor.
classIntrinsicPanelLayout:FloatingPanelLayout{letposition:FloatingPanelPosition=.bottom
letinitialState:FloatingPanelState=.full
letanchors:[FloatingPanelState:FloatingPanelLayoutAnchoring]=[.full:FloatingPanelIntrinsicLayoutAnchor(absoluteOffset:0, referenceGuide:.safeArea),.half:FloatingPanelIntrinsicLayoutAnchor(fractionalOffset:0.5, referenceGuide:.safeArea),]...}

✏️ FloatingPanelIntrinsicLayout is deprecated on v1.

Specify an anchor for each state by an inset of the FloatingPanelController.view frame

Use .superview reference guide in your anchors.

classMyFullScreenLayout:FloatingPanelLayout{...letanchors:[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 is deprecated on v1.

Change the backdrop alpha

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.

classMyPanelLayout:FloatingPanelLayout{func backdropAlpha(for state:FloatingPanelState)->CGFloat{switch state {case.full,.half:return0.3default:return0.0}}}

Using custome panel states

You're able to define custom panel states and use them as the following example.

extensionFloatingPanelState{staticletlastQuart:FloatingPanelState=FloatingPanelState(rawValue:"lastQuart", order:750)staticletfirstQuart:FloatingPanelState=FloatingPanelState(rawValue:"firstQuart", order:250)}classFloatingPanelLayoutWithCustomState:FloatingPanelBottomLayout{overridevaranchors:[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),]}}

Customize the behavior with FloatingPanelBehavior protocol

Modify your floating panel's interaction

classViewController:UIViewController,FloatingPanelControllerDelegate{...func viewDidLoad(){...
fpc.behavior =CustomPanelBehavior()}}classCustomPanelBehavior:FloatingPanelBehavior{letspringDecelerationRate=UIScrollView.DecelerationRate.fast.rawValue +0.02letspringResponseTime=0.4func shouldProjectMomentum(_ fpc:FloatingPanelController, to proposedState:FloatingPanelState)->Bool{returntrue}}

✏️ floatingPanel(_ vc:behaviorFor:) is deprecated on v1.

Activate the rubber-band effect on panel edges

classMyPanelBehavior:FloatingPanelBehavior{...func allowsRubberBanding(for edge:UIRectEdge)->Bool{returntrue}}

Manage the projection of a pan gesture momentum

This allows full projectional panel behavior. For example, a user can swipe up a panel from tip to full nearby the tip position.

classMyPanelBehavior:FloatingPanelBehavior{...func shouldProjectMomentum(_ fpc:FloatingPanelController, to proposedState:FloatingPanelPosition)->Bool{returntrue}}

Specify the panel move's boundary

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{letloc= vc.surfaceLocation
letminY= vc.surfaceLocation(for:.full).y -6.0letmaxY= vc.surfaceLocation(for:.tip).y +6.0
vc.surfaceLocation =CGPoint(x: loc.x, y:min(max(loc.y, minY), maxY))}}

✏️ {top,bottom}InteractionBuffer property is removed from FloatingPanelLayout since v2.

Customize the surface design

Modify your surface appearance

// Create a new appearance.
letappearance=SurfaceAppearance()
// Define shadows
letshadow=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

Use a custom grabber handle

letmyGrabberHandleView=MyGrabberHandleView()
fpc.surfaceView.grabberHandle.isHidden =true
fpc.surfaceView.addSubview(myGrabberHandleView)

Customize layout of the grabber handle

fpc.surfaceView.grabberHandlePadding =10.0
fpc.surfaceView.grabberHandleSize =.init(width:44.0, height:12.0)

✏️ Note that grabberHandleSize width and height are reversed in the left/right position.

Customize content padding from surface edges

fpc.surfaceView.contentPadding =.init(top:20, left:20, bottom:20, right:20)

Customize margins of the surface edges

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

  • Facebook/Slack-like panel whose surface top edge is separated from the grabber handle.
  • iOS native panel to display AirPods information, for example.

Customize gestures

Suppress the panel interaction

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}

Add tap gestures to the surface view

overridefunc viewDidLoad(){...letsurfaceTapGesture=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)}

Interrupt the delegate methods of FloatingPanelController.panGestureRecognizer

If you are set FloatingPanelController.panGestureRecognizer.delegateProxy to an object adopting UIGestureRecognizerDelegate, it overrides delegate methods of the pan gesture recognizer.

classMyGestureRecognizerDelegate:UIGestureRecognizerDelegate{func gestureRecognizer(_ gestureRecognizer:UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer:UIGestureRecognizer)->Bool{returnfalse}}classViewController:UIViewController{letmyGestureDelegate=MyGestureRecognizerDelegate()func setUpFpc(){....
fpc.panGestureRecognizer.delegateProxy = myGestureDelegate
}

Create an additional floating panel for a detail

overridefunc viewDidLoad(){
// Setup Search panel
self.searchPanelVC =FloatingPanelController()letsearchVC=SearchViewController()self.searchPanelVC.set(contentViewController: searchVC)self.searchPanelVC.track(scrollView: contentVC.tableView)self.searchPanelVC.addPanel(toParent:self)
// Setup Detail panel
self.detailPanelVC =FloatingPanelController()letcontentVC=ContentViewController()self.detailPanelVC.set(contentViewController: contentVC)self.detailPanelVC.track(scrollView: contentVC.scrollView)self.detailPanelVC.addPanel(toParent:self)}

Move a position with an animation

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)}

Work your contents together with a floating panel behavior

classViewController: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()}}}

Enabling the tap-to-dismiss action of the backdrop view

The tap-to-dismiss action is disabled by default. So it needs to be enabled as below.

fpc.backdropView.dismissalTapGestureRecognizer.isEnabled =true

Notes

'Show' or 'Show Detail' Segues from 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.

classViewController:UIViewController{varfpc:FloatingPanelController!varsecondFpc:FloatingPanelController!...overridefunc 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 issue

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.

Maintainer

Shin Yamamoto shin@scenee.com | @scenee

License

FloatingPanel is available under the MIT license. See the LICENSE file for more info.

About

A clean and easy-to-use floating panel UI component for iOS

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages