FloatingPanel Documentation

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

5,822 stars Swift Swift 5 files ยท ~13,634 tokens
RAW Doc

Technical Documentation: scenee/FloatingPanel

โ„น๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference

Overview (`README.md`)

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.

Table of Contents

Click here.

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
  • SwiftUI API support

Examples can be found here:

Requirements

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

Documentation

Installation

Swift Package Manager

Using Xcode

Just follow this documentation.

Using Package.swift

In your Package.swift Swift Package Manager manifest, add the following dependency to your dependencies argument:

swift
.package(url: "https://github.com/scenee/FloatingPanel", from: "3.2.4"),

Add Numerics as a dependency for your target:

swift
.target(name: "MyTarget", dependencies: [
  .product(name: "FloatingPanel", package: "FloatingPanel"),
  "AnotherModule"
]),

And then add import FloatingPanel in your source code.

CocoaPods

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

ruby
pod 'FloatingPanel'

Getting Started with SwiftUI

Adding a floating panel within a view

swift
@State private var layout = MyFloatingPanelLayout()
@State private var state: FloatingPanelState?

var view: some View {
  MainView()
      .floatingPanel { proxy in
          ScrollView {
              VStack(spacing: 20) {
                  ForEach(items) { item in
                      ItemRow(item)
                  }
              }
              .padding()
          }
          .floatingPanelScrollTracking(proxy: proxy)
      }
      .floatingPanelState($state)
      .floatingPanelLayout(layout)
      .floatingPanelBehavior(MyCustomBehavior())
      .floatingPanelSurfaceAppearance(.transparent)
}

Presenting a floating panel modally within a view

Please define a custom coordinator object to present a floating panel as a modality.

swift
struct HomeView: View {
  var view: some View {
    MainView()
        .floatingPanel(
          coordinator: MyPanelCoordinator.self
        ) { proxy in
          ...
        }
  }
}

class MyPanelCoordinator: FloatingPanelCoordinator {
    ...
    func setupFloatingPanel<Main, Content>(
        mainHostingController: UIHostingController<Main>,
        contentHostingController: UIHostingController<Content>
    ) where Main: View, Content: View {
        // Set the delegate object
        controller.delegate = delegate

        // Set up the content
        contentHostingController.view.backgroundColor = .clear
        controller.set(contentViewController: contentHostingController)

        /* =============== HERE ==================== */
        // NOTE: 
        // Present the floating panel on the next run loop cycle
        // to ensure proper view hierarchy setup.
        Task { @MainActor in
            mainHostingController.present(controller, animated: false)
        }
    }
    ...
}

Displaying multiple panels

Multiple floating panels can be displayed in the same view hierarchy. To customize the layout and behavior for each Floating Panel individually, place modifiers directly below each panel.

swift
Color.orange
    .ignoresSafeArea()
    .floatingPanel(
        coordinator: MyPanelCoordinator.self
    ) { proxy in
        ContentView(proxy: proxy)
    }
    .floatingPanelSurfaceAppearance(.transparent())
    .floatingPanel(
        coordinator: MyPanelCoordinator.self
    ) { proxy in
        ContentView(proxy: proxy)
    }
    .floatingPanelSurfaceAppearance(.transparent(cornerRadius: 24))

Next step

For more details, see the FloatingPanel SwiftUI API Guide

Getting Started with UIKit

Adding a floating panel as a child view controller

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

Presenting a floating panel modally

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

[!NOTE]
FloatingPanelController has the custom presentation controller. If you would like to customize the presentation/dismissal, please see Transitioning.

Next step

For more details, see the FloatingPanel API Guide.

Maintainer

Shin Yamamoto [email protected] | @scenee

License

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


Overview (`Examples/SamplesSwiftUI/README.md`)

SamplesSwiftUI

Requirements

  • iOS 15 or later
  • Xcode 16 or later

Documentation FloatingPanel 2.0 Migration Guide (`Documentation/FloatingPanel 2.0 Migration Guide.md`)

FloatingPanel 2.0 Migration Guide

FloatingPanel 2.0 is the latest major release of FloatingPanel. As a major release, following Semantic Versioning conventions, 2.0 introduces API-breaking changes.

This guide is provided in order to ease the transition of existing applications using FloatingPanel 1.x to the latest APIs, as well as explain the design and structure of new and updated functionality.

Updated Minimum Requirements

  • Swift 5.0
  • iOS 11 (iOS 10 is still the deployment target, but not tested well)
  • Xcode 11.0

Benefits of Upgrading

  • Top, left and right positioned panel
    • FloatingPanel is not just a library for a bottom positioned panel, but also top, left and right positioned ones.
  • Objective-C compatibility
    • The entire APIs are exposed in Objective-C. So you can use them in Objective-C directly.
  • Flexible and explicit layout customization
    • FloatingPanelLayout is redesigned. There is no implicit rules to lay out a panel anymore.
  • New spring animation without UIViewPropertyAnimator
    • The new spring animation uses Numeric springing which is a very powerful tool for procedural animation. Therefore a library consumer is easy to modify a panel behavior by 2 parameters of the deceleration rate and response time.
  • Handle the panel position anytime
    • floatingPanelDidMove(_:) delegate method is also called while a panel is moving. The method behavior becomes same as scrollViewDidScroll(_:) in UIScrollViewDelegate. And in the method a library consumer is able to change a panel location.
  • Update the removal interaction's invocation
    • Now you can invoke the removal interaction at any time where you want. There is no restrictions in the library.
  • Fix many issues depending on API design
    • See the following sections for details.

API Name Changes

  • FloatingPanelPosition is now FloatingPanelState.
    • FloatingPanelPosition in v2 is used to specify a panel position(top, left, bottom and right) in a screen.
  • FloatingPanelSurfaceView is SurfaceView only in Swift.
  • FloatingPanelBackdropView is BackdropView only in Swift.
  • FloatingPanelGrabberHandleView is GrabberView only in Swift.
  • "decelerate" term is replaced with "attract" because the panel's behavior is not unidirectional, but going back and forth so that it is settled to a location.

`FloatingPanelController`

  • layout and behavior properties can be changed directly without using the delegate methods.
swift
fpc.behavior = SearchPaneliPadBehavior()
fpc.layout = SearchPaneliPadLayout()
fpc.invalidateLayout() // If needed
  • The second argument of addPanel(toParent:) changes to specify an index of subviews of a view in which a panel is added.
diff
- public func addPanel(toParent parent: UIViewController, belowView: UIView? = nil, animated: Bool = false) {
+ public func addPanel(toParent parent: UIViewController, at viewIndex: Int = -1, animated: Bool = false) {
  • surfaceOriginY is now surfaceLocation.
  • updateLayout is now invalidateLayout.
  • The scroll tracking API is changed a bit to support multiple scroll view tracking in the future.
    • Now untrack(scrollView:) is used to disable the scroll tracking.

`FloatingPanelControllerDelegate`

  • floatingPanelDidEndDragging(_ vc:willAttract:) is added to check whether a panel will continue to move after dragging.
  • floatingPanelDidMove(_:) behavior changes. The method is also called in the spring animation.
  • The removal interaction delegate is updated.
    • floatingPanel(_:shouldRemoveAt:with:) is added to determine whether it invokes the removal interaction in any state.
    • floatingPanelWillRemove(_:) is added.
  • floatingPanel(_: FloatingPanelController, layoutFor size: CGSize) is added to respond to a layout change in regular size classes on iPad.
swift
func floatingPanel(_ fpc: FloatingPanelController, layoutFor size: CGSize) -> FloatingPanelLayout {
    if aCondition(for: size) {
        return SearchPanelLayout()
    }
    return SearchPanel2Layout()
}
  • The targetState argument type of floatingPanelWillEndDragging(_:withVelocity:targetState:) is changed from FloatingPanelState to UnsafeMutablePointer<FloatingPanelState> to modify a target state on demand.
swift
func floatingPanelWillEndDragging(_ vc: FloatingPanelController, withVelocity velocity: CGPoint, targetState: UnsafeMutablePointer<FloatingPanelState>) {
    switch targetState.pointee {
        case .full:
          // do something...
        case .half:
            if aCondition {
                targetState.pointee = .tip
            }
        default:
            break
    }
}

Deprecated APIs

  • floatingPanel(_:behaviorFor:)
    • Please update FloatingPanelController.behavior directly.
  • floatingPanel(_:shouldRecognizeSimultaneouslyWith:)
    • Please use FloatingPanelController.panGestureRecognizer.delegateProxy.

`FloatingPanelLayout`

  • position property is added to determine a panel position.
  • initialPosition is now initialState.
  • supportedPositions and insetFor(position:) are replaced with anchors property.
  • backdropAlphaFor(position:) is now backdropAlpha(for:).
swift
class SearchPanelPadLayout: FloatingPanelLayout {
    let position: FloatingPanelPosition  = .top
    let initialState: FloatingPanelState = .tip
    var anchors: [FloatingPanelState : FloatingPanelLayoutAnchoring] {
        return [
            ...
        ]
    }
    func backdropAlpha(for state: FloatingPanelState) -> CGFloat {
        return 0.3
    }
    ...
}

New `FloatingPanelLayoutAnchoring` classes

The following objects adopting FloatingPanelLayoutAnchoring protocol are added to configure the flexible and explicit layout.

`FloatingPanelLayoutAnchor`

This class is used to specify a panel layout using insets from a rectangle area of the superview or safe area.

  • FloatingPanelFullScreenLayout is replaced with anchors using .superview reference guide.
  • FloatingPanelLayoutAnchor(fractionalInset:edge:referenceGuide:) lets you lay out a panel at a relative position in a reference rectangle area.
swift
// Before:
class MyPanelLayout: FloatingPanelLayout {
    var initialPosition: FloatingPanelPosition {
        return .half
    }

    func insetFor(position: FloatingPanelPosition) -> CGFloat? {
        switch position {
        case .full: return 16.0
        case .half: return 262.0
        case .tip: return 44.0
        case .hidden: return nil
        }
    }
}

// After:
class MyPanelLayout: FloatingPanelLayout {
    var position: FloatingPanelPosition = .bottom
    var initialState: FloatingPanelState { .half }
    var anchors: [FloatingPanelState : FloatingPanelLayoutAnchoring] {
        return [
            .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea),
            .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .superview),
            .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .safeArea)
        ]
    }
}

`FloatingPanelIntrinsicLayoutAnchor`

This class is used to specify a panel layout using offsets from the intrinsic size layout.

  • This replaces FloatingPanelIntrinsicLayout.
  • This is also able to configure a fractional layout in the intrinsic size.
swift
// Before:
class MyPanelIntrinsicLayout: FloatingPanelIntrinsicLayout {
    var initialPosition: FloatingPanelPosition {
        return .half
    }
    func insetFor(position: FloatingPanelPosition) -> CGFloat? {
        switch position {
        case .full: return 16.0
        case .half: return 262.0
        case .tip: return 44.0
        case .hidden: return nil
        }
    }
}

// After:
class MyPanelIntrinsicLayout: FloatingPanelLayout {
    var position: FloatingPanelPosition = .bottom
    var initialState: FloatingPanelState { .full }
    var anchors: [FloatingPanelState : FloatingPanelLayoutAnchoring] {
        return [
            .full: FloatingPanelIntrinsicLayoutAnchor(absoluteOffset: 16.0, referenceGuide: .safeArea),
            .half: FloatingPanelIntrinsicLayoutAnchor(fractionalOffset: 0.5, referenceGuide: .safeArea),
            .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .safeArea)
        ]
    }
}

Deprecated APIs

  • .topInteractionBuffer and .bottomInteractionBuffer.
    • Please control the max/min range of the motion in floatingPanelDidMove(_:) delegate method as below.
swift
func floatingPanelDidMove(_ fpc: FloatingPanelController) {
    if fpc.isAttracting == false {
        let loc = fpc.surfaceLocation
        let minY = fpc.surfaceLocation(for: .full).y - 6.0
        let maxY = fpc.surfaceLocation(for: .tip).y + 6.0
        fpc.surfaceLocation = CGPoint(x: loc.x, y: min(max(loc.y, minY), maxY))
    }
}

`FloatingPanelBehavior`

  • .springDecelerationRate and .springResponseTime properties are added to control the new spring effect of Numeric springing.

Deprecated APIs

  • addAnimator(_:to:), removeAnimator(_:from:)
    • They are moved into floatingPanel(_:animatorForPresentingTo:) and floatingPanel(_:animatorForDismissingWith:) of FloatingPanelControllerDelegate because they are used for view transitions.
  • interactionAnimator(_:to:with:), moveAnimator(_:from:to:)
    • They are removed because the animators are replaced with the new spring effect.
  • removalVelocity, removalProgress
    • They are replaced with floatingPanel(_:shouldRemoveAt:with:) of FloatingPanelControllerDelegate
  • removalInteractionAnimator(_:with:)
    • It is integrated with floatingPanel(_:animatorForDismissingWith:) of FloatingPanelControllerDelegate.

`SurfaceView`

  • SurfaceAppearance class and SurfaceView.appearance property are added to specify the rounding corners, shadows and background color.
    • SurfaceView.appearance property avoids Ambiguous use of 'cornerRadius' error, for instance.
    • SurfaceAppearance enables to apply layered box shadows into a surface to materialize it.
swift
// Before:
fpc.surfaceView.cornerRadius = 6.0
fpc.surfaceView.backgroundColor = .clear
fpc.surfaceView.shadowHidden = false
fpc.surfaceView.shadowColor = .black
fpc.surfaceView.shadowOffset = CGSize(width: 0, height: 16)
fpc.surfaceView.shadowRadius = 16.0

// After:
let appearance = SurfaceAppearance()
appearance.cornerRadius = 8.0
appearance.backgroundColor = .clear

let shadow = SurfaceAppearance.Shadow()
shadow.color = .black
shadow.offset = CGSize(width: 0, height: 16)
shadow.radius = 16
shadow.spread = 8
appearance.shadows = [shadow]

fpc.surfaceView.appearance = appearance
  • These properties are changed for the top, left and right positioned panel.
    • grabberTopPadding is now grabberHandlePadding.
    • topGrabberBarHeight is now grabberAreaOffset.
    • grabberHandleWidth and grabberHandleHeight are replaced with grabberHandleSize.

`BackdropView`

  • The dismissal action of the backdrop is disabled by default.
    • You can enable it to set BackdropView.dismissalTapGestureRecognizer.isEnabled to true.

`FloatingPanelPanGestureRecognizer`

  • delegateProxy property is added to intercept the gesture recognizer delegate.
swift
func layoutPanelForPad() {
    fpc.behavior = SearchPaneliPadBehavior()
    fpc.panGestureRecognizer.delegateProxy = self
}

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
    return false
}

Miscellaneous

  • UISpringTimingParameters(decelerationRate:frequencyResponse:initialVelocity:) initializer is added.
  • The directory structure and file names in the Xcode project changes.

Documentation FloatingPanel API Guide (`Documentation/FloatingPanel API Guide.md`)

Floating Panel API Guide

Table of Contents

Click here

View hierarchy

FloatingPanelController manages the views as the following view hierarchy.

text
FloatingPanelController.view (FloatingPanelPassThroughView)
 โ”œโ”€ .backdropView (FloatingPanelBackdropView)
 โ””โ”€ .surfaceView (FloatingPanelSurfaceView)
    โ”œโ”€ .containerView (UIView)
    โ”‚  โ””โ”€ .contentView (FloatingPanelController.contentViewController.view)
    โ””โ”€ .grabber (FloatingPanelGrabberView)

Usage

Show/Hiding 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.

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

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

Scaling 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

swift
fpc.contentMode = .fitToBounds

Otherwise, FloatingPanelController fixes the content by the height of the top most position.

[!NOTE]
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.

Customizing the layout with `FloatingPanelLayout` protocol

Changing the initial layout

swift
class 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),
    ]
}

Updating your panel layout

There are 2 ways to update the panel layout.

  1. Manually set FloatingPanelController.layout to the new layout object directly.
swift
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.
swift
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()
    } 
}

Supporting your landscape layout

swift
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),
        ]
    }
}

Using 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.
swift
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]
FloatingPanelIntrinsicLayout is deprecated on v1.

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

Use .superview reference guide in your anchors.

swift
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]
FloatingPanelFullScreenLayout is deprecated on v1.

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

swift
class MyPanelLayout: FloatingPanelLayout {
    func backdropAlpha(for state: FloatingPanelState) -> CGFloat {
        switch state {
        case .full, .half: return 0.3
        default: return 0.0
        }
    }
}

Using custom panel states

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

swift
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),
        ]
    }
}

Customizing the behavior with `FloatingPanelBehavior` protocol

Modifying your floating panel's interaction

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

Activating the rubber-band effect on panel edges

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

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

swift
class MyPanelBehavior: FloatingPanelBehavior {
    ...
    func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedState: FloatingPanelState) -> Bool {
        return true
    }
}

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

swift
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}InteractionBuffer property is removed from FloatingPanelLayout since v2.

Customizing the surface design

Modifying your surface appearance

swift
// 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

Use a custom grabber handle

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

Customizing layout of the grabber handle

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

[!NOTE]
grabberHandleSize width and height are reversed in the left/right position.

Customizing content padding from surface edges

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

Customizing margins of the surface edges

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

Customizing gestures

Suppressing the panel interaction

You can disable the pan gesture recognizer directly

swift
fpc.panGestureRecognizer.isEnabled = false

Or use this FloatingPanelControllerDelegate method.

swift
func floatingPanelShouldBeginDragging(_ vc: FloatingPanelController) -> Bool {
    return aCondition ?  false : true
}

Adding tap gestures to the surface view

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

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

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

Creating an additional floating panel for a detail

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

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

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

swift
UIView.animate(withDuration: 0.25) {
    self.fpc.move(to: .half, animated: false)
}

Working your contents together with a floating panel behavior

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

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.

swift
fpc.backdropView.dismissalTapGestureRecognizer.isEnabled = true

Allowing to scroll content of the tracking scroll view in addition to the most expanded state

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.

swift
class MyViewController: FloatingPanelControllerDelegate {
    ... 

    func floatingPanel(
        _ fpc: FloatingPanelController,
        shouldAllowToScroll trackingScrollView: UIScrollView,
        in state: FloatingPanelState
    ) -> Bool {
        return state == .full || state == .half
    }
}

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.

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


Documentation FloatingPanel SwiftUI API Guide (`Documentation/FloatingPanel SwiftUI API Guide.md`)

FloatingPanel SwiftUI API Guide

Table of Contents

Click here

Requirements

  • iOS 15 or later
  • Xcode 16 or later

Goals

  1. Build SwiftUI APIs on top of our battle-tested UIKit implementation
  2. Enable smooth interoperability between UIKit and SwiftUI components
  3. Provide an idiomatic SwiftUI developer experience

Non-Goals

  1. Complete reimplementation of FloatingPanel solely using SwiftUI APIs
  2. Cover all UIKit-specific customization options through SwiftUI APIs

Design Principles

  • APIs designed to maximize user control and flexibility
  • FloatingPanel designed as a supplemental view rather than a modal presentation as the same as UIKit implementation.
  • Declarative modifiers that follow SwiftUI conventions
  • Environment-based configuration patterns
  • Seamless integration with SwiftUI's view hierarchy
  • Implementation of essential APIs only, with plans to expand based on user requests -- we welcome your feedback!

Development and compatibility

  • Built targeting Xcode 16 as the primary development environment
    • Maintains backward compatibility with Xcode 14/15 for UIKit support but gradually migrates to full Xcode 16+ features
  • iOS version compatibility:
    • Our SwiftUI integration requires iOS 15 or later, the same as the library's minimum deployment target

API implementation considerations

  • We've determined that providing an integration API is the optimal approach with enhanced UIKit integration (as of iOS 18)
  • Currently not using @Entry due to compatibility constraints

Supplemental view approach rather than modal presentation

FloatingPanel has been designed as a supplemental view rather than a modal presentation since its first release in the UIKit implementation. The SwiftUI APIs follow this same principle, allowing users to leverage this library for use cases not covered by Apple's built-in APIs.

For instance, the SwiftUI API deliberately doesn't provide an isPresented binding argument in the floatingPanel(coordinator:onEvent:content:) modifier. If you want to hide a floating panel, use the .hidden anchor state instead, which enables you to hide a floating panel while keeping the content pre-rendered outside the visible screen area.

Leveraging the view modifiers

The SwiftUI APIs provide a variety of view modifiers. Consider using these modifiers before implementing custom logic in your FloatingPanelCoordinator object.

`FloatingPanelCoordinator`: The key component

The FloatingPanelCoordinator protocol is a key component in the FloatingPanel's SwiftUI integration, serving as the bridge between SwiftUI's declarative UI framework and FloatingPanel's UIKit-based implementation.

It manages the connection between the SwiftUI view hierarchy and the underlying FloatingPanelController, handling setup, configuration, and event dispatching for floating panels within SwiftUI applications.

This secion explains the FloatingPanelCoordinator protocol in detail, including its purpose, implementation patterns, and common use cases to help you effectively integrate floating panels into your SwiftUI applications.

Core Responsibilities

A FloatingPanelCoordinator implementation handles the following key responsibilities:

  1. Creation and Configuration: Initializing and configuring the underlying FloatingPanelController instance
  2. View Hierarchy Management: Setting up the relationship between the main SwiftUI view and the panel content view
  3. State Management: Handling panel state transitions and position changes
  4. Event Handling: Capturing panel events and dispatching them back to SwiftUI
  5. Environment Changes: Responding to SwiftUI environment changes and updating the panel accordingly

Default Implementation

The library provides FloatingPanelDefaultCoordinator as a standard implementation for basic panel integration.

FloatingPanelCoordinator intentionally does not provide default implementations for its required methods. This design choice avoids implicit behavior when handling the FloatingPanelController. When implementing a custom coordinator instead of using FloatingPanelDefaultCoordinator, users can clearly understand the requirements of the FloatingPanelCoordinator protocol and how the FloatingPanelController should be managed in their custom implementation.

Use Cases

1. Custom Event Handling

Define a custom coordinator to handle panel events:

swift
struct ContentView: View {
    var body: some View {
        Color.blue
            .ignoresSafeArea()
            .floatingPanel(
                coordinator: MyPanelCoordinator.self,
                onEvent: handlePanelEvent
            ) { proxy in
                PanelContent(proxy: proxy)
            }
    }
    
    func handlePanelEvent(_ event: MyPanelCoordinator.Event) {
        switch event {
        case .willChangeState(let state):
            print("Panel will change to \(state)")
        case .didChangeState(let state):
            print("Panel changed to \(state)")
        }
    }
}

class MyPanelCoordinator: FloatingPanelCoordinator {
    enum Event {
        case willChangeState(FloatingPanelState)
        case didChangeState(FloatingPanelState)
    }
    
    let action: (Event) -> Void
    lazy var delegate: FloatingPanelControllerDelegate? = self
    let proxy: FloatingPanelProxy
    
    ...
}

extension MyPanelCoordinator: FloatingPanelControllerDelegate {
    func floatingPanelWillBeginDragging(_ fpc: FloatingPanelController) {
        action(.willChangeState(fpc.state))
    }
    
    func floatingPanelDidChangeState(_ fpc: FloatingPanelController) {
        action(.didChangeState(fpc.state))
    }
}

2. Responding to Environment Changes

Create a coordinator that responds to SwiftUI environment changes:

swift
class EnvironmentAwarePanelCoordinator: FloatingPanelCoordinator {
    ...
    func onUpdate<Representable>(
        context: UIViewControllerRepresentableContext<Representable>
    ) where Representable: UIViewControllerRepresentable {
        // Access environment values and update the panel
        let shouldMoveToFullState = context.environment.someCustomValue
        if shouldMoveToFullState {
            proxy.move(to: .full, animated: true)
        }
    }
}

3. Modal Presentation

Implement a coordinator that presents the panel modally:

swift
class ModalPanelCoordinator: FloatingPanelCoordinator {
    enum Event {
        case dismissed
    }
    
    let action: (Event) -> Void
    let proxy: FloatingPanelProxy
    lazy var delegate: FloatingPanelControllerDelegate? = nil
    
    ...
    func setupFloatingPanel<Main, Content>(
        mainHostingController: UIHostingController<Main>,
        contentHostingController: UIHostingController<Content>
    ) where Main: View, Content: View {
        // Set up the content
        contentHostingController.view.backgroundColor = .clear
        controller.set(contentViewController: contentHostingController)
        
        // Present the panel modally
        Task { @MainActor in
            controller.isRemovalInteractionEnabled = true
            controller.delegate = self
            mainHostingController.present(controller, animated: true)
        }
    }
    
    ...
}

extension ModalPanelCoordinator: FloatingPanelControllerDelegate {
    func floatingPanelDidEndRemove(_ fpc: FloatingPanelController) {
        action(.dismissed)
    }
}

Best Practices

Define Meaningful Events

Design your Event type to capture meaningful panel interactions that SwiftUI views need to respond to, but avoid over-engineering with too many event types.

Use Lazy Delegate Initialization

Initialize delegate lazily when you want your coordinator to implement FloatingPanelControllerDelegate:

swift
lazy var delegate: FloatingPanelControllerDelegate? = self

Handle Environment Changes Efficiently

In your onUpdate method, compare environment values with current panel state before making changes to avoid unnecessary updates.

Coordinate with SwiftUI Animations

Respect SwiftUI's animation context when moving the panel on iOS 18 or later:

swift
func onUpdate<Representable>(
    context: UIViewControllerRepresentableContext<Representable>
) where Representable: UIViewControllerRepresentable {
    if #available(iOS 18.0, *) {
        let animation = context.transaction.animation ?? .spring(response: 0.25, dampingFraction: 0.9)
        UIView.animate(animation) {
            proxy.move(to: .full, animated: false)
        }
    } else {
        ...
    }
}

--- METRICS ---