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.
- Table of Contents
- Features
- Requirements
- Documentation
- Installation
- Getting Started with SwiftUI
- Getting Started with UIKit
- Maintainer
- License
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:
- Maps like Apple Maps.app.
- Maps-SwiftUI like Apple Maps.app using SwiftUI.
- Stocks like Apple Stocks.app.
- Samples
- SamplesObjC
- SamplesSwiftUI
Requirements
FloatingPanel is written in Swift 5.0+ and compatible with iOS 15.0+.
Documentation
- API reference on Swift Package Index
- FloatingPanel SwiftUI API Guide
- FloatingPanel API Guide
- FloatingPanel 2.0 Migration Guide
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:
.package(url: "https://github.com/scenee/FloatingPanel", from: "3.2.4"),Add Numerics as a dependency for your target:
.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:
pod 'FloatingPanel'Getting Started with SwiftUI
Adding a floating panel within a view
@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.
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.
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
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
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
FloatingPanelLayoutis 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 asscrollViewDidScroll(_:)inUIScrollViewDelegate. 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
FloatingPanelPositionis nowFloatingPanelState.FloatingPanelPositionin v2 is used to specify a panel position(top, left, bottom and right) in a screen.
FloatingPanelSurfaceViewisSurfaceViewonly in Swift.FloatingPanelBackdropViewisBackdropViewonly in Swift.FloatingPanelGrabberHandleViewisGrabberViewonly 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`
layoutandbehaviorproperties can be changed directly without using the delegate methods.
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.
- 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) {surfaceOriginYis nowsurfaceLocation.updateLayoutis nowinvalidateLayout.- 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.
- Now
`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.
func floatingPanel(_ fpc: FloatingPanelController, layoutFor size: CGSize) -> FloatingPanelLayout {
if aCondition(for: size) {
return SearchPanelLayout()
}
return SearchPanel2Layout()
}- The
targetStateargument type offloatingPanelWillEndDragging(_:withVelocity:targetState:)is changed fromFloatingPanelStatetoUnsafeMutablePointer<FloatingPanelState>to modify a target state on demand.
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.behaviordirectly.
- Please update
floatingPanel(_:shouldRecognizeSimultaneouslyWith:)- Please use
FloatingPanelController.panGestureRecognizer.delegateProxy.
- Please use
`FloatingPanelLayout`
positionproperty is added to determine a panel position.initialPositionis nowinitialState.supportedPositionsandinsetFor(position:)are replaced withanchorsproperty.backdropAlphaFor(position:)is nowbackdropAlpha(for:).
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.
FloatingPanelFullScreenLayoutis replaced with anchors using.superviewreference guide.FloatingPanelLayoutAnchor(fractionalInset:edge:referenceGuide:)lets you lay out a panel at a relative position in a reference rectangle area.
// 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.
// 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
.topInteractionBufferand.bottomInteractionBuffer.- Please control the max/min range of the motion in
floatingPanelDidMove(_:)delegate method as below.
- Please control the max/min range of the motion in
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`
.springDecelerationRateand.springResponseTimeproperties are added to control the new spring effect of Numeric springing.
Deprecated APIs
addAnimator(_:to:),removeAnimator(_:from:)- They are moved into
floatingPanel(_:animatorForPresentingTo:)andfloatingPanel(_:animatorForDismissingWith:)ofFloatingPanelControllerDelegatebecause they are used for view transitions.
- They are moved into
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:)ofFloatingPanelControllerDelegate
- They are replaced with
removalInteractionAnimator(_:with:)- It is integrated with
floatingPanel(_:animatorForDismissingWith:)ofFloatingPanelControllerDelegate.
- It is integrated with
`SurfaceView`
SurfaceAppearanceclass andSurfaceView.appearanceproperty are added to specify the rounding corners, shadows and background color.SurfaceView.appearanceproperty avoidsAmbiguous use of 'cornerRadius'error, for instance.SurfaceAppearanceenables to apply layered box shadows into a surface to materialize it.
// 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.
grabberTopPaddingis nowgrabberHandlePadding.topGrabberBarHeightis nowgrabberAreaOffset.grabberHandleWidthandgrabberHandleHeightare replaced withgrabberHandleSize.
`BackdropView`
- The dismissal action of the backdrop is disabled by default.
- You can enable it to set
BackdropView.dismissalTapGestureRecognizer.isEnabledtotrue.
- You can enable it to set
`FloatingPanelPanGestureRecognizer`
delegateProxyproperty is added to intercept the gesture recognizer delegate.
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
- Table of Contents
- View hierarchy
- Usage
- Show/Hiding a floating panel in a view with your view hierarchy
- Scaling the content view when the surface position changes
- Customizing the layout with
FloatingPanelLayoutprotocol - Updating your panel layout
- Customizing the behavior with
FloatingPanelBehaviorprotocol - Specifying the panel move's boundary
- Customizing the surface design
- Customizing gestures
- Creating an additional floating panel for a detail
- Moving a position with an animation
- Working your contents together with a floating panel behavior
- Enabling the tap-to-dismiss action of the backdrop view
- Allowing to scroll content of the tracking scroll view in addition to the most expanded state
- Notes
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/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:
- Add it to the hierarchy once and then call
showandhidemethods to make it appear/disappear. - 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 = 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.
// 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
fpc.contentMode = .fitToBoundsOtherwise, FloatingPanelController fixes the content by the height of the top most position.
[!NOTE]
In.fitToBoundsmode, 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
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.
- Manually set
FloatingPanelController.layoutto the new layout object directly.
fpc.layout = MyPanelLayout()
fpc.invalidateLayout() // If neededNote: 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.
- Returns an appropriate layout object in one of 2
floatingPanel(_:layoutFor:)delegates.
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
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
- 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.
- Specify layout anchors using
FloatingPanelIntrinsicLayoutAnchor.
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]
FloatingPanelIntrinsicLayoutis deprecated on v1.
Specifying an anchor for each state by an inset of the `FloatingPanelController.view` frame
Use .superview reference guide in your anchors.
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]
FloatingPanelFullScreenLayoutis 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.
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.
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
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
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.
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.
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}InteractionBufferproperty is removed fromFloatingPanelLayoutsince v2.
Customizing the surface design
Modifying your surface appearance
// 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 = appearanceUse a custom grabber handle
let myGrabberHandleView = MyGrabberHandleView()
fpc.surfaceView.grabberHandle.isHidden = true
fpc.surfaceView.addSubview(myGrabberHandleView)Customizing layout of the grabber handle
fpc.surfaceView.grabberHandlePadding = 10.0
fpc.surfaceView.grabberHandleSize = .init(width: 44.0, height: 12.0)[!NOTE]
grabberHandleSizewidth and height are reversed in the left/right position.
Customizing content padding from surface edges
fpc.surfaceView.contentPadding = .init(top: 20, left: 20, bottom: 20, right: 20)Customizing 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.
Customizing gestures
Suppressing the panel interaction
You can disable the pan gesture recognizer directly
fpc.panGestureRecognizer.isEnabled = falseOr use this FloatingPanelControllerDelegate method.
func floatingPanelShouldBeginDragging(_ vc: FloatingPanelController) -> Bool {
return aCondition ? false : true
}Adding tap gestures to the surface view
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.
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
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.
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)
}Working 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 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 = trueAllowing 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.
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.
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
- Table of Contents
- Requirements
- Goals
- Non-Goals
- Design Principles
- Development and compatibility
- API implementation considerations
- Supplemental view approach rather than modal presentation
- Leveraging the view modifiers
FloatingPanelCoordinator: The key component
Requirements
- iOS 15 or later
- Xcode 16 or later
Goals
- Build SwiftUI APIs on top of our battle-tested UIKit implementation
- Enable smooth interoperability between UIKit and SwiftUI components
- Provide an idiomatic SwiftUI developer experience
Non-Goals
- Complete reimplementation of FloatingPanel solely using SwiftUI APIs
- 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
@Entrydue 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:
- Creation and Configuration: Initializing and configuring the underlying
FloatingPanelControllerinstance - View Hierarchy Management: Setting up the relationship between the main SwiftUI view and the panel content view
- State Management: Handling panel state transitions and position changes
- Event Handling: Capturing panel events and dispatching them back to SwiftUI
- 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:
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:
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:
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:
lazy var delegate: FloatingPanelControllerDelegate? = selfHandle 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:
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 ---
- Files Extracted: 5
- Estimated Token Budget: ~13551 tokens
- Canonical Reference: https://codewiki.google/github.com/scenee/FloatingPanel