Swiftpack.co - Package - SCENEE/FloatingPanel

Build Status Version Carthage compatible Platform Swift 4.1 Swift 4.2 Swift 5.0 Swift 5.1

FloatingPanel

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.

Maps Stocks

Maps(Landscape)

Features

  • [x] Simple container view controller
  • [x] Fluid animation and gesture handling
  • [x] Scroll view tracking
  • [x] Common UI elements: Grabber handle, Backdrop and Surface rounding corners
  • [x] 1~3 anchor positions(full, half, tip)
  • [x] Layout customization for all trait environments(i.e. Landscape orientation support)
  • [x] Behavior customization
  • [x] Free from common issues of Auto Layout and gesture handling
  • [x] Modal presentation

Examples are here.

Requirements

FloatingPanel is written in Swift 4.0+. It can be built by Xcode 9.4.1 or later. Compatible with iOS 10.0+.

✏️ The default Swift version is 4.0 because it avoids build errors with Carthage on each Xcode version from the source compatibility between Swift 4.0, 4.2 and 5.0.

Installation

CocoaPods

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

pod 'FloatingPanel'

✏️ To suppress "Swift Conversion" warnings in Xcode, please set a Swift version to SWIFT_VERSION for the project in your Podfile. It will be resolved in CocoaPods v1.7.0.

Carthage

For Carthage, add the following to your Cartfile:

github "scenee/FloatingPanel"

Swift Package Manager with Xcode 11

Follow this doc.

Getting Started

Add a floating panel as a child view controller

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

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        // Remove the views managed by the `FloatingPanelController` object from self.view.
        fpc.removePanelFromParent()
    }
}

Present a floating panel as a modality

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)

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 FloatingPanelTransitioning.

View hierarchy

FloatingPanelController manages the views as the following view hierarchy.

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

Usage

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

// Add the controller and the managed views to a view controller.
// From the second time, just call `show(animated:completion)`.
view.addSubview(fpc.view)

fpc.view.frame = view.bounds // MUST
// In addition, Auto Layout constraints are highly recommended.
// Because it makes the layout more robust on trait collection change.
//
//     fpc.view.translatesAutoresizingMaskIntoConstraints = false
//     NSLayoutConstraint.activate([...])
// 

parent.addChild(fpc)

// Show a floating panel to the initial position defined in your `FloatingPanelLayout` object.
fpc.show(animated: true) {

    // Only for the first time
    self.didMove(toParent: self)
}

...

// Hide it
fpc.hide(animated: true) {

    // Remove it if needed
    self.willMove(toParent: nil)
    self.view.removeFromSuperview()
    self.removeFromParent()
}

NOTE: FloatingPanelController wraps show/hide with addPanel/removePanelFromParent for easy-to-use. But show/hide are more convenience for your app.

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.

Customize the layout with FloatingPanelLayout protocol

Change the initial position and height

class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ...
    func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout? {
        return MyFloatingPanelLayout()
    }
}

class MyFloatingPanelLayout: FloatingPanelLayout {
    public var initialPosition: FloatingPanelPosition {
        return .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
            default: return nil // Or `case .hidden: return nil`
        }
    }
}

Support your landscape layout

class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ...
    func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout? {
        return (newCollection.verticalSizeClass == .compact) ? FloatingPanelLandscapeLayout() : nil // Returning nil indicates to use the default layout
    }
}

class FloatingPanelLandscapeLayout: FloatingPanelLayout {
    public var initialPosition: FloatingPanelPosition {
        return .tip
    }
    public var supportedPositions: Set<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.safeAreaLayoutGuide.leftAnchor, constant: 8.0),
            surfaceView.widthAnchor.constraint(equalToConstant: 291),
        ]
    }
}

Use Intrinsic height 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. Create a layout that adopts and conforms to FloatingPanelIntrinsicLayout and use it.
class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ...
    func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout? {
        return RemovablePanelLayout()
    }
}

class RemovablePanelLayout: FloatingPanelIntrinsicLayout {
    var supportedPositions: Set<FloatingPanelPosition> {
        return [.full, .half]
    }
    func insetFor(position: FloatingPanelPosition) -> CGFloat? {
        switch position {
        case .half: return 130.0
        default: return nil  // Must return nil for .full
        }
    }
    ...
}

Specify position insets from the frame of FloatingPanelContrller.view, not the SafeArea

There are 2 ways. One is returning .fromSuperview for FloatingPanelLayout.positionReference in your layout.

class MyFullScreenLayout: FloatingPanelLayout {
    ...
    var positionReference: FloatingPanelLayoutReference {
        return .fromSuperview
    }
}

Another is using FloatingPanelFullScreenLayout protocol.

class MyFullScreenLayout: FloatingPanelFullScreenLayout {
    ...
}

Customize the behavior with FloatingPanelBehavior protocol

Modify your floating panel's interaction

class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ...
    func floatingPanel(_ vc: FloatingPanelController, behaviorFor newCollection: UITraitCollection) -> FloatingPanelBehavior? {
        return FloatingPanelStocksBehavior()
    }
}

class FloatingPanelStocksBehavior: FloatingPanelBehavior {
    ...
    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)
    }
}

Activate the rubberband effect on the top/bottom edges

class FloatingPanelBehavior: FloatingPanelBehavior {
    ...
    func allowsRubberBanding(for edge: UIRectEdge) -> Bool {
        return true
    }
}

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.

class FloatingPanelBehavior: FloatingPanelBehavior {
    ...
    func shouldProjectMomentum(_ fpc: FloatingPanelController, for proposedTargetPosition: FloatingPanelPosition) -> Bool
        return true
    }
}

Use a custom grabber handle

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

Add tap gestures to the surface or backdrop views

override func viewDidLoad() {
    ...
    surfaceTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleSurface(tapGesture:)))
    fpc.surfaceView.addGestureRecognizer(surfaceTapGesture)

    backdropTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleBackdrop(tapGesture:)))
    fpc.backdropView.addGestureRecognizer(backdropTapGesture)

    surfaceTapGesture.isEnabled = (fpc.position == .tip)
}

// Enable `surfaceTapGesture` only at `tip` position
func floatingPanelDidChangePosition(_ vc: FloatingPanelController) {
    surfaceTapGesture.isEnabled = (vc.position == .tip)
}

Create an additional floating panel for a detail

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

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

Work your contents together with a floating panel behavior

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

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.

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 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.

FloatingPanelSurfaceView's issue on iOS 10

  • 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.backgroundColor, 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 App's Auto Layout settings of UIVisualEffectView in Main.storyboard.

Author

Shin Yamamoto shin@scenee.com | @scenee

License

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

Github

link
Stars: 2834
Help us keep the lights on

Dependencies

Used By

Total: 0

Releases

v1.6.5 - Aug 31, 2019

Improvements

  • Return true for FloatingPanelSurfaceView.requiresConstraintBasedLayout
  • Update README for UISearchController issue #248
  • Add move-to-hidden tests
  • Don't unregister safeAreaInsetsObservation in hide()
  • Improve floatingPanelDidChangePosition and tigger it on removal by David Hart
  • Support bottom content inset for container view (#257) by @nderkach

Bugfixes

  • Samples App: Fix UISearchBar's _searchField access #243

Thank you to David Hart, @nderkach, @swiftymf and @roblabs for this release!

v1.6.4 - Aug 9, 2019

This release contains some bugfixes for the animation interruption.

Bugfixes

  • Fix not calling floatingPanelDidEndDecelerating delegate after interruption
  • Always call startInteraction before endInteraction to keep the delegate call convention.
  • Fix stopping a panel b/w anchors after an interruption

v1.6.3 - Jul 26, 2019

This has a bunch of bugfixes and improvements. It's more stable than the earlier versions.

The core logic was refactored along with many new unit tests and then the position handling, scroll lock and animation interruption become robust. I highly recommend to upgrade to the version.

This is a big milestone to go to the next step, which is the initial version supporting the elastic height and top-to-bottom layout.

Improvements

  • Add unit tests for the layout and the animation logic.
  • Refactor the core logic for the next features.
  • Revise the doc comments.

Bugfixes

  • Fix the .hidden position handling. Now you can use .hidden position as well as other positions.
  • Fix the scroll lock and unlock when an animation interrupted.
  • Fix a bug of the removal interaction.
  • Fix the behavior when the animation is interrupted over the top most position.
  • Fix an ambiguous layout error after swiping down from full to half position.
  • Stop the top edge bounce when a tracking scroll is decelerating using an interruptible animator.
  • Fix the SafeArea update
  • Fix scroll lock just before/after dragging down in the grabber area

v1.6.2 - Jul 25, 2019

Bugfixes

  • Fix the scroll indicator lock on a contentVC reset
  • Refactor FloatingPanel and FloatingPanelLayoutAdapter a bit by the new unit tests.
  • Prevent potential 'unexpectedly found nil' fatal error

v1.6.1 - Jun 29, 2019

Improvements

  • Add Swift Package Manager support by @robbiet480

Bugfixes

  • Fix the crash while closeing via dragging #216
  • Fix closing panel during internal scroll view bounce #218
  • Remove workaround for tableView(_:didSelectRowAt:) issue #225
  • Fix an unexpected layout update in the background on iOS13 #228

Thank you to @robbiet480 for your SwiftPM support! Thank you to @cozzin, @Heltisace and @Isuru-Nanayakkara for your issues and cooperations!