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.cornerRadius
isn'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.