FloatingPanel is a simple and easy-to-use UI component for a new interface introduced in Apple Maps, Shortcuts and Stocks app. The new interface displays the related contents and utilities in parallel as a user wants.
- Features
- Requirements
- Installation
- Getting Started
- Usage
- Notes
- Author
- License
- Simple container view controller
- Fluid animation and gesture handling
- Scroll view tracking
- Common UI elements: Grabber handle, Backdrop and Surface rounding corners
- 2 or 3 anchor positions(full, half, tip)
- Layout customization for all trait environments(i.e. Landscape orientation support)
- Behavior customization
- Free from common issues of Auto Layout and gesture handling
Examples are here.
- Examples/Maps like Apple Maps.app.
- Examples/Stocks like Apple Stocks.app.
FloatingPanel is written in Swift 4.2. Compatible with iOS 10.0+
FloatingPanel is available through CocoaPods. To install it, simply add the following line to your Podfile:
pod 'FloatingPanel'For Carthage, add the following to your Cartfile:
github "scenee/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
// Add a content view controller.
let contentVC = ContentViewController()
fpc.show(contentVC, sender: nil)
// Track a scroll view(or the siblings) in the content view controller.
fpc.track(scrollView: contentVC.tableView)
// Add the views managed by the `FloatingPanelController` object to self.view.
fpc.addPanel(toParent: self)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Remove the views managed by the `FloatingPanelController` object from self.view.
fpc.removePanelFromParent()
}
...
}class ViewController: UIViewController, FloatingPanelControllerDelegate {
...
func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout? {
return MyFloatingPanelLayout()
}
...
}
class MyFloatingPanelLayout: FloatingPanelLayout {
public var initialPosition: FloatingPanelPosition {
return .tip
}
public var supportedPositions: [FloatingPanelPosition] {
return [.full, .half, .tip]
}
public func insetFor(position: FloatingPanelPosition) -> CGFloat? {
switch position {
case .full: return 16.0 # A top inset from safe area
case .half: return 216.0 # A bottom inset from the safe area
case .tip: return 44.0 # A bottom inset from the safe area
}
}
func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] {
return [
surfaceView.leftAnchor.constraint(equalTo: view.sideLayoutGuide.leftAnchor, constant: 0.0),
surfaceView.rightAnchor.constraint(equalTo: view.sideLayoutGuide.rightAnchor, constant: 0.0),
]
}
}class ViewController: UIViewController, FloatingPanelControllerDelegate {
...
func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout? {
return (newCollection.verticalSizeClass == .compact) ? FloatingPanelLandscapeLayout() : nil // Returing nil indicates to use the default layout
}
...
}
class FloatingPanelLandscapeLayout: FloatingPanelLayout {
public var initialPosition: FloatingPanelPosition {
return .tip
}
public var supportedPositions: [FloatingPanelPosition] {
return [.full, .tip]
}
public func insetFor(position: FloatingPanelPosition) -> CGFloat? {
switch position {
case .full: return 16.0
case .tip: return 69.0
default: return nil
}
}
public func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] {
return [
surfaceView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuid.leftAnchor, constant: 8.0),
surfaceView.widthAnchor.constraint(equalToConstant: 291),
]
}
}class ViewController: UIViewController, FloatingPanelControllerDelegate {
...
func floatingPanel(_ vc: FloatingPanelController, behaviorFor newCollection: UITraitCollection) -> FloatingPanelBehavior? {
return FloatingPanelStocksBehavior()
}
...
}
...
class FloatingPanelStocksBehavior: FloatingPanelBehavior {
var velocityThreshold: CGFloat {
return 15.0
}
func interactionAnimator(_ fpc: FloatingPanelController, to targetPosition: FloatingPanelPosition, with velocity: CGVector) -> UIViewPropertyAnimator {
let damping = self.damping(with: velocity)
let springTiming = UISpringTimingParameters(dampingRatio: damping, initialVelocity: velocity)
return UIViewPropertyAnimator(duration: 0.5, timingParameters: springTiming)
}
...
}class ViewController: UIViewController, FloatingPanelControllerDelegate {
var searchPanelVC: FloatingPanelController!
var detailPanelVC: FloatingPanelController!
override func viewDidLoad() {
// Setup Search panel
self.searchPanelVC = FloatingPanelController()
let searchVC = SearchViewController()
self.searchPanelVC.show(searchVC, sender: nil)
self.searchPanelVC.track(scrollView: contentVC.tableView)
self.searchPanelVC.addPanel(toParent: self)
// Setup Detail panel
self.detailPanelVC = FloatingPanelController()
let contentVC = ContentViewController()
self.detailPanelVC.show(contentVC, sender: nil)
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 closeing 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)
}class ViewController: UIViewController, FloatingPanelControllerDelegate {
...
func floatingPanelWillBeginDragging(_ vc: FloatingPanelController) {
if vc.position == .full {
searchVC.searchBar.showsCancelButton = false
searchVC.searchBar.resignFirstResponder()
}
}
func floatingPanelDidEndDragging(_ vc: FloatingPanelController, withVelocity velocity: CGPoint, targetPosition: FloatingPanelPosition) {
if targetPosition != .full {
searchVC.hideHeader()
}
}
...
}- On iOS 10,
FloatingPanelSurfaceView.cornerRadiusisn't not automatically masked with the top rounded corners because of UIVisualEffectView issue. See https://forums.developer.apple.com/thread/50854. So you need to draw top rounding corners of your content. Here is an example in Examples/Maps.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if #available(iOS 10, *) {
visualEffectView.layer.cornerRadius = 9.0
visualEffectView.clipsToBounds = true
}
}- If you sets clear color to
FloatingPanelSurfaceView.backgrounColor, please note the bottom overflow of your content on bouncing at full position. To prevent it, you need to expand your content. For example, See Example/Maps's Auto Layout settings of UIVisualEffectView in Main.storyborad.
Shin Yamamoto shin@scenee.com
FloatingPanel is available under the MIT license. See the LICENSE file for more info.


