Files
Shin Yamamoto afff000d8c Allow slight deviation when checking for anchor position.
This change addresses the 2nd issue reported in #633. The previous attempt
in commit b0fd0d4 was intended to fix this, but it has a regression.
This change resolves the issue without introducing any regressions.
2025-04-21 20:18:02 +09:00

1457 lines
60 KiB
Swift

// Copyright 2018-Present Shin Yamamoto. All rights reserved. MIT license.
import UIKit
import os.log
///
/// The presentation model of FloatingPanel
///
class Core: NSObject, UIGestureRecognizerDelegate {
private weak var ownerVC: FloatingPanelController?
let surfaceView: SurfaceView
var backdropView: BackdropView {
didSet {
backdropView.dismissalTapGestureRecognizer
.addTarget(self, action: #selector(handleBackdrop(tapGesture:)))
}
}
let layoutAdapter: LayoutAdapter
let behaviorAdapter: BehaviorAdapter
weak var scrollView: UIScrollView? {
didSet {
oldValue?.panGestureRecognizer.removeTarget(self, action: nil)
scrollView?.panGestureRecognizer.addTarget(self, action: #selector(handle(panGesture:)))
if let cur = scrollView {
if oldValue == nil {
initialScrollOffset = cur.contentOffset
scrollBounce = cur.bounces
scrollIndictorVisible = cur.showsVerticalScrollIndicator
}
scrollLocked = false
} else {
if let pre = oldValue {
pre.isDirectionalLockEnabled = false
pre.bounces = scrollBounce
pre.showsVerticalScrollIndicator = scrollIndictorVisible
}
}
}
}
private(set) var state: FloatingPanelState = .hidden {
didSet {
os_log(msg, log: devLog, type: .debug, "state changed: \(oldValue) -> \(state)")
if let fpc = ownerVC {
fpc.delegate?.floatingPanelDidChangeState?(fpc)
}
}
}
let panGestureRecognizer: FloatingPanelPanGestureRecognizer
let panGestureDelegateRouter: FloatingPanelPanGestureRecognizer.DelegateRouter
var isRemovalInteractionEnabled: Bool = false
fileprivate var isSuspended: Bool = false // Prevent a memory leak in the modal transition
fileprivate var transitionAnimator: UIViewPropertyAnimator?
fileprivate var moveAnimator: NumericSpringAnimator?
private var initialSurfaceLocation: CGPoint = .zero
private var initialTranslation: CGPoint = .zero
private var initialLocation: CGPoint {
return panGestureRecognizer.initialLocation
}
var interactionInProgress: Bool = false
var isAttracting: Bool = false
// Removal interaction
var removalVector: CGVector = .zero
// Scroll handling
private var initialScrollOffset: CGPoint?
private var scrollBounce = false
private var scrollIndictorVisible = false
private var scrollBounceThreshold: CGFloat = -30.0
private var scrollLocked = false
// MARK: - Interface
init(_ vc: FloatingPanelController, layout: FloatingPanelLayout, behavior: FloatingPanelBehavior) {
ownerVC = vc
surfaceView = SurfaceView()
surfaceView.position = layout.position
surfaceView.backgroundColor = .white
backdropView = BackdropView()
backdropView.backgroundColor = .black
backdropView.alpha = 0.0
layoutAdapter = LayoutAdapter(vc: vc, layout: layout)
behaviorAdapter = BehaviorAdapter(vc: vc, behavior: behavior)
panGestureRecognizer = FloatingPanelPanGestureRecognizer()
panGestureDelegateRouter = FloatingPanelPanGestureRecognizer.DelegateRouter(panGestureRecognizer: panGestureRecognizer)
super.init()
panGestureRecognizer.set(floatingPanel: self)
surfaceView.addGestureRecognizer(panGestureRecognizer)
panGestureRecognizer.addTarget(self, action: #selector(handle(panGesture:)))
// Assign the delegate router to `FloatingPanelPanGestureRecognizer.delegate` only after setting
// `FloatingPanelPanGestureRecognizer.floatingPanel` property.
// This is because `delegateOrigin` is used at the time of assignment to its `delegate` property
// through the delegate router.
panGestureRecognizer.delegate = panGestureDelegateRouter
// Set the tap-to-dismiss action of the backdrop view.
// It's disabled by default. See also BackdropView.dismissalTapGestureRecognizer.
backdropView.dismissalTapGestureRecognizer.addTarget(self, action: #selector(handleBackdrop(tapGesture:)))
}
deinit {
// Release `NumericSpringAnimator.displayLink` from the run loop.
self.moveAnimator?.stopAnimation(false)
}
func move(to: FloatingPanelState, animated: Bool, completion: (() -> Void)? = nil) {
move(from: state, to: to, animated: animated, completion: completion)
}
private func move(from: FloatingPanelState, to: FloatingPanelState, animated: Bool, completion: (() -> Void)? = nil) {
assert(layoutAdapter.validStates.contains(to), "Can't move to '\(to)' state because it's not valid in the layout")
guard let vc = ownerVC else {
completion?()
return
}
if !isScrollable(state: state) {
lockScrollView()
}
tearDownActiveInteraction()
interruptAnimationIfNeeded()
if animated {
let updateScrollView: () -> Void = { [weak self] in
guard let self = self else { return }
if self.isScrollable(state: self.state), 0 == self.layoutAdapter.offset(from: self.state) {
self.unlockScrollView()
} else {
self.lockScrollView()
}
}
let animator: UIViewPropertyAnimator
switch (from, to) {
case (.hidden, let to):
animator = vc.animatorForPresenting(to: to)
case (_, .hidden):
let animationVector = CGVector(dx: abs(removalVector.dx), dy: abs(removalVector.dy))
animator = vc.animatorForDismissing(with: animationVector)
default:
startAttraction(to: to, with: .zero) { [weak self] in
self?.endAttraction(false)
updateScrollView()
completion?()
}
return
}
let shouldDoubleLayout = from == .hidden
&& surfaceView.hasStackView()
&& layoutAdapter.isIntrinsicAnchor(state: to)
animator.addAnimations { [weak self] in
guard let self = self else { return }
self.state = to
self.updateLayout(to: to)
if shouldDoubleLayout {
os_log(msg, log: sysLog, type: .info, "Lay out the surface again to modify an intrinsic size error according to UIStackView")
self.updateLayout(to: to)
}
}
animator.addCompletion { [weak self] _ in
guard let self = self else { return }
self.transitionAnimator = nil
updateScrollView()
self.ownerVC?.notifyDidMove()
completion?()
}
self.transitionAnimator = animator
if isSuspended {
return
}
animator.startAnimation()
} else {
self.state = to
self.updateLayout(to: to)
if isScrollable(state: state) {
self.unlockScrollView()
} else {
self.lockScrollView()
}
ownerVC?.notifyDidMove()
completion?()
}
}
// MARK: - Layout update
func activateLayout(
forceLayout: Bool = false,
contentInsetAdjustmentBehavior: FloatingPanelController.ContentInsetAdjustmentBehavior
) {
layoutAdapter.prepareLayout()
// preserve the current content offset if contentInsetAdjustmentBehavior is `.always`
var contentOffset: CGPoint?
if contentInsetAdjustmentBehavior == .always {
contentOffset = scrollView?.contentOffset
}
if layoutAdapter.validStates.contains(state) == false {
state = layoutAdapter.initialState
}
layoutAdapter.updateStaticConstraint()
layoutAdapter.activateLayout(for: state, forceLayout: forceLayout)
// Update the backdrop alpha only when called in `Controller.show(animated:completion:)`
// Because that prevents a backdrop flicking just before presenting a panel(#466).
if forceLayout {
backdropView.alpha = getBackdropAlpha(for: state)
}
if let contentOffset = contentOffset {
scrollView?.contentOffset = contentOffset
}
adjustScrollContentInsetIfNeeded()
}
private func updateLayout(to target: FloatingPanelState) {
layoutAdapter.activateLayout(for: target, forceLayout: true)
backdropView.alpha = getBackdropAlpha(for: target)
adjustScrollContentInsetIfNeeded()
}
private func getBackdropAlpha(for target: FloatingPanelState) -> CGFloat {
return target == .hidden ? 0.0 : layoutAdapter.backdropAlpha(for: target)
}
func getBackdropAlpha(at cur: CGFloat, with translation: CGFloat) -> CGFloat {
/* os_log(msg, log: devLog, type: .debug, "currentY: \(currentY) translation: \(translation)") */
let forwardY = (translation >= 0)
let segment = layoutAdapter.segment(at: cur, forward: forwardY)
let lowerState = segment.lower ?? layoutAdapter.mostExpandedState
let upperState = segment.upper ?? layoutAdapter.leastExpandedState
let preState = forwardY ? lowerState : upperState
let nextState = forwardY ? upperState : lowerState
let next = value(of: layoutAdapter.surfaceLocation(for: nextState))
let pre = value(of: layoutAdapter.surfaceLocation(for: preState))
let nextAlpha = layoutAdapter.backdropAlpha(for: nextState)
let preAlpha = layoutAdapter.backdropAlpha(for: preState)
if pre == next {
return preAlpha
}
return preAlpha + max(min(1.0, 1.0 - (next - cur) / (next - pre) ), 0.0) * (nextAlpha - preAlpha)
}
// MARK: - UIGestureRecognizerDelegate
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer == panGestureRecognizer else { return false }
/* os_log(msg, log: devLog, type: .debug, "shouldRecognizeSimultaneouslyWith", otherGestureRecognizer) */
switch otherGestureRecognizer {
case is FloatingPanelPanGestureRecognizer:
// All visible panels' pan gesture should be recognized simultaneously.
return true
case is UIPanGestureRecognizer,
is UISwipeGestureRecognizer,
is UIRotationGestureRecognizer,
is UIScreenEdgePanGestureRecognizer,
is UIPinchGestureRecognizer:
if surfaceView.grabberAreaContains(gestureRecognizer.location(in: surfaceView)) {
return true
}
// all gestures of the tracking scroll view should be recognized in parallel
// and handle them in self.handle(panGesture:)
return scrollView?.gestureRecognizers?.contains(otherGestureRecognizer) ?? false
default:
// Should recognize tap/long press gestures in parallel when the surface view is at an anchor position.
let adapterY = layoutAdapter.position(for: state)
return abs(value(of: layoutAdapter.surfaceLocation) - adapterY) < (1.0 / surfaceView.fp_displayScale)
}
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if otherGestureRecognizer is FloatingPanelPanGestureRecognizer {
// If this panel is the farthest descendant of visible panels,
// its ancestors' pan gesture must wait for its pan gesture to fail
if let view = otherGestureRecognizer.view, surfaceView.isDescendant(of: view) {
return true
}
}
if otherGestureRecognizer.name == "_UISheetInteractionBackgroundDismissRecognizer" {
// The dismiss gesture of a sheet modal should not begin until the pan gesture fails.
return true
}
if surfaceView.grabberAreaContains(gestureRecognizer.location(in: surfaceView)) {
return true
}
return false
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer == panGestureRecognizer else { return false }
// Should begin the pan gesture without waiting for the tracking scroll view's gestures.
// `scrollView.gestureRecognizers` can contains the following gestures
// * UIScrollViewDelayedTouchesBeganGestureRecognizer
// * UIScrollViewPanGestureRecognizer (scrollView.panGestureRecognizer)
// * _UIDragAutoScrollGestureRecognizer
// * _UISwipeActionPanGestureRecognizer
// * UISwipeDismissalGestureRecognizer
if let scrollView = scrollView {
// On short contents scroll, `_UISwipeActionPanGestureRecognizer` blocks
// the panel's pan gesture if not returns false
if let scrollGestureRecognizers = scrollView.gestureRecognizers,
scrollGestureRecognizers.contains(otherGestureRecognizer) {
switch otherGestureRecognizer {
case scrollView.panGestureRecognizer:
if surfaceView.grabberAreaContains(gestureRecognizer.location(in: surfaceView)) {
return false
}
guard isScrollable(state: state) else { return false }
// The condition where offset > 0 must not be included here. Because it will stop recognizing
// the panel pan gesture if a user starts scrolling content from an offset greater than 0.
return allowScrollPanGesture(of: scrollView) { offset in offset <= scrollBounceThreshold }
default:
return false
}
}
}
switch otherGestureRecognizer {
case is FloatingPanelPanGestureRecognizer:
// If this panel is the farthest descendant of visible panels,
// its pan gesture does not require its ancestors' pan gesture to fail
if let view = otherGestureRecognizer.view, surfaceView.isDescendant(of: view) {
return false
}
return true
case is UIPanGestureRecognizer,
is UISwipeGestureRecognizer,
is UIRotationGestureRecognizer,
is UIScreenEdgePanGestureRecognizer,
is UIPinchGestureRecognizer:
if otherGestureRecognizer.name == "_UISheetInteractionBackgroundDismissRecognizer" {
// Should begin the pan gesture without waiting the dismiss gesture of a sheet modal.
return false
}
if surfaceView.grabberAreaContains(gestureRecognizer.location(in: surfaceView)) {
return false
}
// Do not begin the pan gesture until these gestures fail
return true
default:
// Should begin the pan gesture without waiting tap/long press gestures fail
return false
}
}
// MARK: - Gesture handling
@objc func handleBackdrop(tapGesture: UITapGestureRecognizer) {
removalVector = .zero
ownerVC?.remove()
}
@objc func handle(panGesture: UIPanGestureRecognizer) {
switch panGesture {
case scrollView?.panGestureRecognizer:
guard let scrollView = scrollView else { return }
let velocity = value(of: panGesture.velocity(in: panGesture.view))
let location = panGesture.location(in: surfaceView)
let insideMostExpandedAnchor = 0 < layoutAdapter.offsetFromMostExpandedAnchor
os_log(msg, log: devLog, type: .debug, """
scroll gesture(\(state):\(panGesture.state)) -- \
inside expanded anchor = \(insideMostExpandedAnchor), \
interactionInProgress = \(interactionInProgress), \
scroll offset = \(value(of: scrollView.contentOffset)), \
location = \(value(of: location)), velocity = \(velocity)
"""
)
let baseOffset = contentOffsetForPinning(of: scrollView)
let offsetDiff = value(of: scrollView.contentOffset - baseOffset)
if insideMostExpandedAnchor {
// Prevent scrolling if needed
if isScrollable(state: state), let initialScrollOffset = initialScrollOffset {
if interactionInProgress {
os_log(msg, log: devLog, type: .debug, "settle offset -- \(value(of: initialScrollOffset))")
// Return content offset to initial offset to prevent scrolling
stopScrolling(at: initialScrollOffset)
} else {
if surfaceView.grabberAreaContains(initialLocation) {
// Preserve the current content offset in moving from full.
stopScrolling(at: initialScrollOffset)
}
/// When the scroll offset is at the pinned offset and a panel is moved, the content
/// must be fixed at the pinned position without scrolling. According to the scroll
/// pan gesture behavior, the content might have already scrolled a bit by the time
/// this handler is called. Thus `initialScrollOffset` property is used here.
if value(of: initialScrollOffset - baseOffset) == 0.0 {
stopScrolling(at: initialScrollOffset)
}
}
} else if let initialScrollOffset = initialScrollOffset {
// Return content offset to initial offset to prevent scrolling
stopScrolling(at: initialScrollOffset)
}
// Hide a scroll indicator at the non-top in dragging.
if interactionInProgress {
lockScrollView()
} else {
// Put back the scroll indicator and bounce of tracking scroll view
// for scrollable states, not most expanded state.
if isScrollable(state: state), self.transitionAnimator == nil {
switch layoutAdapter.position {
case .top, .left:
if offsetDiff < 0 && velocity > 0 {
unlockScrollView()
}
case .bottom, .right:
if offsetDiff > 0 && velocity < 0 {
unlockScrollView()
}
}
}
}
} else {
// Here handles seamless scrolling at the most expanded position
if interactionInProgress {
// Show a scroll indicator at the top in dragging.
switch layoutAdapter.position {
case .top, .left:
if offsetDiff <= 0 && velocity >= 0 {
unlockScrollView()
return
}
case .bottom, .right:
if offsetDiff >= 0 && velocity <= 0 {
unlockScrollView()
return
}
}
if isScrollable(state: state) {
// Adjust a small gap of the scroll offset just after swiping down starts in the grabber area.
if surfaceView.grabberAreaContains(location), surfaceView.grabberAreaContains(initialLocation),
let initialScrollOffset = initialScrollOffset {
stopScrolling(at: initialScrollOffset)
}
}
} else {
if isScrollable(state: state) {
let allowScroll = allowScrollPanGesture(of: scrollView) { offset in
offset <= scrollBounceThreshold || 0 < offset
}
switch layoutAdapter.position {
case .top, .left:
if velocity < 0, !allowScroll {
lockScrollView(strict: true)
}
if velocity > 0, allowScroll {
unlockScrollView()
}
case .bottom, .right:
// Hide a scroll indicator just before starting an interaction by swiping a panel down.
if velocity > 0, !allowScroll {
lockScrollView(strict: true)
}
// Show a scroll indicator when an animation is interrupted at the top and content is scrolled up
if velocity < 0, allowScroll {
unlockScrollView()
}
}
// Adjust a small gap of the scroll offset just before swiping down starts in the grabber area,
if surfaceView.grabberAreaContains(location), surfaceView.grabberAreaContains(initialLocation),
let initialScrollOffset = initialScrollOffset {
stopScrolling(at: initialScrollOffset)
}
}
}
}
case panGestureRecognizer:
let translation = panGesture.translation(in: panGestureRecognizer.view!.superview)
// The touch velocity in the surface view
let velocity = panGesture.velocity(in: panGesture.view)
// The touch location in the surface view
let location = panGesture.location(in: panGesture.view)
os_log(msg, log: devLog, type: .debug, """
panel gesture(\(state):\(panGesture.state)) -- \
translation = \(value(of: translation)), \
location = \(value(of: location)), \
velocity = \(value(of: velocity))
""")
if interactionInProgress == false, isAttracting == false,
let vc = ownerVC, vc.delegate?.floatingPanelShouldBeginDragging?(vc) == false {
return
}
interruptAnimationIfNeeded()
if panGesture.state == .began {
panningBegan(at: location)
return
}
if shouldScrollViewHandleTouch(scrollView, point: location, velocity: value(of: velocity)) {
return
}
switch panGesture.state {
case .changed:
if interactionInProgress == false {
startInteraction(with: translation, at: location)
}
panningChange(with: translation)
case .ended, .cancelled, .failed:
if interactionInProgress == false {
startInteraction(with: translation, at: location)
// Workaround: Prevent stopping the surface view b/w anchors if the pan gesture
// doesn't pass through .changed state after an interruptible animator is interrupted.
let diff = translation - .leastNonzeroMagnitude
layoutAdapter.updateInteractiveEdgeConstraint(diff: value(of: diff),
scrollingContent: true,
allowsRubberBanding: behaviorAdapter.allowsRubberBanding(for:))
}
panningEnd(with: translation, velocity: velocity)
default:
break
}
default:
return
}
}
private func interruptAnimationIfNeeded() {
if let animator = self.moveAnimator, animator.isRunning {
os_log(msg, log: devLog, type: .debug, "the attraction animator interrupted!!!")
animator.stopAnimation(true)
endAttraction(false)
}
if let animator = self.transitionAnimator {
guard 0 <= layoutAdapter.offsetFromMostExpandedAnchor else { return }
os_log(msg, log: devLog, type: .debug, "a panel animation(interruptible: \(animator.isInterruptible)) interrupted!!!")
if animator.isInterruptible {
animator.stopAnimation(false)
// A user can stop a panel at the nearest Y of a target position so this fine-tunes
// the a small gap between the presentation layer frame and model layer frame
// to unlock scroll view properly at finishAnimation(at:)
if 0 == layoutAdapter.offsetFromMostExpandedAnchor {
layoutAdapter.surfaceLocation = layoutAdapter.surfaceLocation(for: layoutAdapter.mostExpandedState)
}
animator.finishAnimation(at: .current)
} else {
animator.stopAnimation(true)
}
}
}
private func shouldScrollViewHandleTouch(_ scrollView: UIScrollView?, point: CGPoint, velocity: CGFloat) -> Bool {
// When no scrollView, nothing to handle.
guard let scrollView = scrollView, scrollView.frame.contains(initialLocation) else { return false }
// Prevents moving a panel on swipe actions using _UISwipeActionPanGestureRecognizer.
// [Warning] Do not apply this to WKWebView. Since iOS 17.4, WKWebView has an additional pan
// gesture recognizer besides UIScrollViewPanGestureRecognizer. Applying this to WKWebView
// will block panel movements because another pan gesture isn't `scrollView.panGestureRecognizer`.
if let scrollGestureRecognizers = scrollView.gestureRecognizers,
scrollView is UITableView || scrollView is UICollectionView {
for gesture in scrollGestureRecognizers {
guard gesture.state == .began || gesture.state == .changed else {
continue
}
if gesture != scrollView.panGestureRecognizer {
return true
}
}
}
guard
isScrollable(state: state), // When not top most(i.e. .full), don't scroll.
interactionInProgress == false, // When interaction already in progress, don't scroll.
abs(layoutAdapter.offset(from: state)) < 1, // Indistinguishably close to an anchor point.
!surfaceView.grabberAreaContains(initialLocation) // When the initial point is within grabber area, don't scroll
else {
return false
}
let offset = value(of: scrollView.contentOffset - contentOffsetForPinning(of: scrollView))
// The zero offset must be excluded because the offset is usually zero
// after a panel moves from half/tip to full.
switch layoutAdapter.position {
case .top, .left:
if offset < 0.0 {
return true
}
if velocity >= 0, offset > 0.0 {
return true
}
case .bottom, .right:
if offset > 0.0 {
return true
}
if velocity <= 0, offset < 0.0 {
return true
}
}
if scrollView.isDecelerating {
return true
}
if let tableView = (scrollView as? UITableView), tableView.isEditing {
return true
}
return false
}
private func panningBegan(at location: CGPoint) {
// A user interaction does not always start from Began state of the pan gesture
// because it can be recognized in scrolling a content in a content view controller.
// So here just preserve the current state if needed.
os_log(msg, log: devLog, type: .debug, "panningBegan -- location = \(value(of: location))")
guard let scrollView = scrollView else { return }
initialScrollOffset = scrollView.contentOffset
}
private func panningChange(with translation: CGPoint) {
let pre = value(of: layoutAdapter.surfaceLocation)
let diff = value(of: translation - initialTranslation)
let next = pre + diff
os_log(msg, log: devLog, type: .debug, """
panningChange -- translation = \(value(of: translation)), diff = \(diff), pre = \(pre), next = \(next)
""")
layoutAdapter.updateInteractiveEdgeConstraint(
diff: diff,
scrollingContent: shouldScrollingContentInMoving(from: pre, to: next),
allowsRubberBanding: behaviorAdapter.allowsRubberBanding(for:)
)
let cur = value(of: layoutAdapter.surfaceLocation)
backdropView.alpha = getBackdropAlpha(at: cur, with: value(of: translation))
guard (pre != cur) else { return }
if let vc = ownerVC {
vc.delegate?.floatingPanelDidMove?(vc)
}
}
/// Determines if the content should scroll while the surface is moving from `cur` to `target`.
///
/// - Note: `cur` argument starts from an anchor location of surface view in a state. For example,
/// it starts from zero if the state is full whose FloatingPanelLayoutAnchor.absoluteInset is zero
/// and there is no additional safe area insets like a navigation bar. Therefore, `cur` argument
/// can be minus if the absoluteInset is minus with such a condition.
private func shouldScrollingContentInMoving(from cur: CGFloat, to target: CGFloat) -> Bool {
// Don't allow scrolling if the initial panning location is in the grabber area.
if surfaceView.grabberAreaContains(initialLocation) {
return false
}
if let sv = scrollView, sv.panGestureRecognizer.state == .changed {
let (contentSize, bounds, alwaysBounceHorizontal, alwaysBounceVertical)
= (sv.contentSize, sv.bounds, sv.alwaysBounceHorizontal, sv.alwaysBounceVertical)
switch layoutAdapter.position {
case .top:
if cur < target, contentSize.height > bounds.height || alwaysBounceVertical {
return true
}
case .left:
if cur < target, contentSize.width > bounds.width || alwaysBounceHorizontal {
return true
}
case .bottom:
if cur > target, contentSize.height > bounds.height || alwaysBounceVertical {
return true
}
case .right:
if cur > target, contentSize.width > bounds.width || alwaysBounceHorizontal {
return true
}
}
}
return false
}
private func panningEnd(with translation: CGPoint, velocity: CGPoint) {
os_log(msg, log: devLog, type: .debug, "panningEnd -- translation = \(value(of: translation)), velocity = \(value(of: velocity))")
if state == .hidden {
os_log(msg, log: devLog, type: .debug, "Already hidden")
return
}
let currentPos = value(of: layoutAdapter.surfaceLocation)
let mainVelocity = value(of: velocity)
var target = self.targetState(from: currentPos, with: mainVelocity)
endInteraction(for: target)
if isRemovalInteractionEnabled {
let distToHidden = CGFloat(abs(currentPos - layoutAdapter.position(for: .hidden)))
switch layoutAdapter.position {
case .top, .bottom:
removalVector = (distToHidden != 0) ? CGVector(dx: 0.0, dy: velocity.y/distToHidden) : .zero
case .left, .right:
removalVector = (distToHidden != 0) ? CGVector(dx: velocity.x/distToHidden, dy: 0.0) : .zero
}
if shouldRemove(with: removalVector) {
ownerVC?.remove()
return
}
}
if let vc = ownerVC {
vc.delegate?.floatingPanelWillEndDragging?(vc, withVelocity: velocity, targetState: &target)
}
guard shouldAttract(to: target) else {
self.endWithoutAttraction(target)
return
}
if let vc = ownerVC {
vc.delegate?.floatingPanelDidEndDragging?(vc, willAttract: true)
}
startAttraction(to: target, with: velocity) { [weak self] in
self?.endAttraction(true)
}
}
// MARK: - Behavior
private func shouldRemove(with velocityVector: CGVector) -> Bool {
guard let vc = ownerVC else { return false }
if let result = vc.delegate?.floatingPanel?(vc, shouldRemoveAt: vc.surfaceLocation, with: velocityVector) {
return result
}
let threshold = behaviorAdapter.removalInteractionVelocityThreshold
switch layoutAdapter.position {
case .top:
return (velocityVector.dy <= -threshold)
case .left:
return (velocityVector.dx <= -threshold)
case .bottom:
return (velocityVector.dy >= threshold)
case .right:
return (velocityVector.dx >= threshold)
}
}
private func startInteraction(with translation: CGPoint, at location: CGPoint) {
/* Don't lock a scroll view to show a scroll indicator after hitting the top */
os_log(msg, log: devLog, type: .debug, "startInteraction -- translation = \(value(of: translation)), location = \(value(of: location))")
guard interactionInProgress == false else { return }
var offset: CGPoint = .zero
initialSurfaceLocation = layoutAdapter.surfaceLocation
if isScrollable(state: state), let scrollView = scrollView {
ifLabel: if surfaceView.grabberAreaContains(initialLocation) {
initialScrollOffset = scrollView.contentOffset
} else if scrollView.frame.contains(initialLocation) {
let pinningOffset = contentOffsetForPinning(of: scrollView)
// This code block handles the scenario where there's a navigation bar or toolbar
// above the tracking scroll view with corresponding content insets set, and users
// move the panel by interacting with these bars. One case of the scenario can be
// tested with 'Show Navigation Controller' in Samples.app
do {
// Adjust the location by subtracting scrollView's origin to reference the frame
// rectangle of the scroll view itself.
let _location = scrollView.convert(location, from: surfaceView) - scrollView.bounds.origin
os_log(msg, log: devLog, type: .debug, "startInteraction -- location in scroll view = \(_location))")
// Keep the scroll content offset if the current touch position is inside its
// content inset area.
switch layoutAdapter.position {
case .top, .left:
let base = value(of: scrollView.bounds.size)
if value(of: pinningOffset) + (base - value(of: _location)) < 0 {
initialScrollOffset = scrollView.contentOffset
break ifLabel
}
case .bottom, .right:
if value(of: pinningOffset) + value(of: _location) < 0 {
initialScrollOffset = scrollView.contentOffset
break ifLabel
}
}
}
// `initialScrollOffset` must be reset to the pinning offset because the value of `scrollView.contentOffset`,
// for instance, is a value in [-30, 0) on a bottom positioned panel with `allowScrollPanGesture(of:condition:)`.
// If it's not reset, the following logic to shift the surface frame will not work and then the scroll
// content offset will become an unexpected value.
initialScrollOffset = pinningOffset
// Shift the surface frame to negate the scroll content offset at startInteraction(at:offset:)
let offsetDiff = scrollView.contentOffset - pinningOffset
switch layoutAdapter.position {
case .top, .left:
if value(of: offsetDiff) > 0 {
offset = -offsetDiff
}
case .bottom, .right:
if value(of: offsetDiff) < 0 {
offset = -offsetDiff
}
}
} else {
initialScrollOffset = scrollView.contentOffset
}
os_log(msg, log: devLog, type: .debug, "initial scroll offset -- \(optional: initialScrollOffset)")
}
initialTranslation = translation
if let vc = ownerVC {
vc.delegate?.floatingPanelWillBeginDragging?(vc)
}
layoutAdapter.startInteraction(at: state, offset: offset)
interactionInProgress = true
lockScrollView()
}
private func endInteraction(for state: FloatingPanelState) {
os_log(msg, log: devLog, type: .debug, "endInteraction to \(state)")
if let scrollView = scrollView {
os_log(msg, log: devLog, type: .debug, "endInteraction -- scroll offset = \(scrollView.contentOffset)")
}
interactionInProgress = false
// Prevent to keep a scroll view indicator visible at the half/tip position
if !isScrollable(state: state) {
lockScrollView()
}
layoutAdapter.endInteraction(at: state)
}
private func tearDownActiveInteraction() {
guard panGestureRecognizer.isEnabled else { return }
// Cancel the pan gesture so that panningEnd(with:velocity:) is called
panGestureRecognizer.isEnabled = false
panGestureRecognizer.isEnabled = true
}
private func shouldAttract(to state: FloatingPanelState) -> Bool {
if layoutAdapter.position(for: state) == value(of: layoutAdapter.surfaceLocation) {
return false
}
return true
}
private func startAttraction(to state: FloatingPanelState, with velocity: CGPoint, completion: @escaping (() -> Void)) {
os_log(msg, log: devLog, type: .debug, "startAnimation to \(state) -- velocity = \(value(of: velocity))")
guard let vc = ownerVC else { return }
isAttracting = true
vc.delegate?.floatingPanelWillBeginAttracting?(vc, to: state)
move(to: state, with: value(of: velocity), completion: completion)
}
private func move(to state: FloatingPanelState, with velocity: CGFloat, completion: @escaping (() -> Void)) {
let (animationConstraint, target) = layoutAdapter.setUpAttraction(to: state)
let initialData = NumericSpringAnimator.Data(value: animationConstraint.constant, velocity: velocity)
moveAnimator = NumericSpringAnimator(
initialData: initialData,
target: target,
displayScale: surfaceView.fp_displayScale,
decelerationRate: behaviorAdapter.springDecelerationRate,
responseTime: behaviorAdapter.springResponseTime,
update: { [weak self] data in
guard let self = self,
let ownerVC = self.ownerVC // Ensure the owner vc is existing for `layoutAdapter.surfaceLocation`
else { return }
animationConstraint.constant = data.value
let current = self.value(of: self.layoutAdapter.surfaceLocation)
let translation = data.value - initialData.value
self.backdropView.alpha = self.getBackdropAlpha(at: current, with: translation)
// Pin the offset of the tracking scroll view while moving by this animator
if let scrollView = self.scrollView, let initialScrollOffset = self.initialScrollOffset {
self.stopScrolling(at: initialScrollOffset)
os_log(msg, log: devLog, type: .debug, "move -- pinning scroll offset = \(scrollView.contentOffset)")
}
ownerVC.notifyDidMove()
},
completion: { [weak self] in
guard let self = self,
let ownerVC = self.ownerVC
else { return }
self.updateLayout(to: state)
// Notify when it has reached the target anchor point. At this point, the surface location is equal to
// the target anchor location.
ownerVC.notifyDidMove()
completion()
})
moveAnimator?.startAnimation()
self.state = state
}
private func endAttraction(_ tryUnlockScroll: Bool) {
self.isAttracting = false
self.moveAnimator = nil
// We need to reset `initialScrollOffset` because the scroll offset can become unexpected
// under the following circumstances:
// 1. The scroll offset changes while the panel does not move.
// 2. The panel is then moved using `move(to:animate:completion:)`.
self.initialScrollOffset = nil
if let vc = ownerVC {
vc.delegate?.floatingPanelDidEndAttracting?(vc)
}
if let scrollView = scrollView {
os_log(msg, log: devLog, type: .debug, "finishAnimation -- scroll offset = \(scrollView.contentOffset)")
}
os_log(msg, log: devLog, type: .debug, """
finishAnimation -- state = \(state) \
surface location = \(layoutAdapter.surfaceLocation) \
offset from state position = \(layoutAdapter.offset(from: state))
""")
if tryUnlockScroll {
if (isScrollable(state: state) && 0 == layoutAdapter.offset(from: state))
|| shouldLooselyLockScrollView {
unlockScrollView()
}
}
}
func endWithoutAttraction(_ target: FloatingPanelState) {
// See comments in `endAttraction`
self.initialScrollOffset = nil
self.state = target
self.updateLayout(to: target)
self.unlockScrollView()
// The `floatingPanelDidEndDragging(_:willAttract:)` must be called after the state property changes.
// This allows library users to get the correct state in the delegate method.
if let vc = ownerVC {
vc.delegate?.floatingPanelDidEndDragging?(vc, willAttract: false)
}
}
func value(of point: CGPoint) -> CGFloat {
return layoutAdapter.position.mainLocation(point)
}
func value(of size: CGSize) -> CGFloat {
return layoutAdapter.position.mainDimension(size)
}
func setValue(_ newValue: CGPoint, to point: inout CGPoint) {
switch layoutAdapter.position {
case .top, .bottom:
point.y = newValue.y
case .left, .right:
point.x = newValue.x
}
}
// Distance travelled after decelerating to zero velocity at a constant rate.
// Refer to the slides p176 of [Designing Fluid Interfaces](https://developer.apple.com/videos/play/wwdc2018/803/)
private func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat {
return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate)
}
func targetState(from currentY: CGFloat, with velocity: CGFloat) -> FloatingPanelState {
os_log(msg, log: devLog, type: .debug, "targetState -- currentY = \(currentY), velocity = \(velocity)")
let sortedPositions = layoutAdapter.sortedAnchorStatesByCoordinate
guard sortedPositions.count > 1 else {
return state
}
// Projection
let decelerationRate = behaviorAdapter.momentumProjectionRate
let baseY = abs(layoutAdapter.position(for: layoutAdapter.leastExpandedState) - layoutAdapter.position(for: layoutAdapter.mostExpandedState))
let vecY = velocity / baseY
var pY = project(initialVelocity: vecY, decelerationRate: decelerationRate) * baseY + currentY
let distance = (currentY - layoutAdapter.position(for: state))
let forwardY = velocity == 0 ? distance > 0 : velocity > 0
let segment = layoutAdapter.segment(at: pY, forward: forwardY)
var fromPos: FloatingPanelState
var toPos: FloatingPanelState
let (lowerPos, upperPos) = (segment.lower ?? sortedPositions.first!, segment.upper ?? sortedPositions.last!)
(fromPos, toPos) = forwardY ? (lowerPos, upperPos) : (upperPos, lowerPos)
if behaviorAdapter.shouldProjectMomentum(to: toPos) == false {
os_log(msg, log: devLog, type: .debug, "targetState -- negate projection: distance = \(distance)")
let segment = layoutAdapter.segment(at: currentY, forward: forwardY)
var (lowerPos, upperPos) = (segment.lower ?? sortedPositions.first!, segment.upper ?? sortedPositions.last!)
// Equate the segment out of {top,bottom} most state to the {top,bottom} most segment
if lowerPos == upperPos {
if forwardY {
upperPos = lowerPos.next(in: sortedPositions)
} else {
lowerPos = lowerPos.pre(in: sortedPositions)
}
}
(fromPos, toPos) = forwardY ? (lowerPos, upperPos) : (upperPos, lowerPos)
// Block a projection to a segment over the next from the current segment
// (= Trim pY with the current segment)
if forwardY {
pY = max(min(pY, layoutAdapter.position(for: toPos.next(in: sortedPositions))), layoutAdapter.position(for: fromPos))
} else {
pY = max(min(pY, layoutAdapter.position(for: fromPos)), layoutAdapter.position(for: toPos.pre(in: sortedPositions)))
}
}
// Redirection
let redirectionalProgress = max(min(behaviorAdapter.redirectionalProgress(from: fromPos, to: toPos), 1.0), 0.0)
let progress = abs(pY - layoutAdapter.position(for: fromPos)) / abs(layoutAdapter.position(for: fromPos) - layoutAdapter.position(for: toPos))
return progress > redirectionalProgress ? toPos : fromPos
}
// MARK: - ScrollView handling
func followScrollViewBouncing() {
guard let scrollView = scrollView else {
return
}
let contentOffset = scrollView.contentOffset.y
guard contentOffset < 0, layoutAdapter.position == .bottom, isScrollable(state: state) else {
if surfaceView.transform != .identity {
surfaceView.transform = .identity
scrollView.transform = .identity
}
return
}
surfaceView.transform = CGAffineTransform(translationX: 0, y: -contentOffset)
scrollView.transform = CGAffineTransform(translationX: 0, y: contentOffset)
}
private func lockScrollView(strict: Bool = false) {
guard let scrollView = scrollView else { return }
if scrollLocked {
os_log(msg, log: devLog, type: .debug, "Already scroll locked")
return
}
scrollBounce = scrollView.bounces
if !strict, shouldLooselyLockScrollView {
// Don't change its `bounces` property. If it's changed, it will cause its scroll content offset jump at
// the most expanded anchor position while seamlessly scrolling content. This problem only occurs where its
// content mode is `.fitToBounds` and the tracking scroll content is smaller than the content view size.
// The reason why is because `bounces` prop change leads to the "content frame" change on `.fitToBounds`.
// See also https://github.com/scenee/FloatingPanel/issues/524.
} else {
scrollView.bounces = false
}
os_log(msg, log: devLog, type: .debug, "lock scroll view")
scrollLocked = true
scrollView.isDirectionalLockEnabled = true
switch layoutAdapter.position {
case .top, .bottom:
scrollIndictorVisible = scrollView.showsVerticalScrollIndicator
scrollView.showsVerticalScrollIndicator = false
case .left, .right:
scrollIndictorVisible = scrollView.showsHorizontalScrollIndicator
scrollView.showsHorizontalScrollIndicator = false
}
}
private func unlockScrollView() {
guard let scrollView = scrollView else { return }
if !scrollLocked {
os_log(msg, log: devLog, type: .debug, "Already scroll unlocked.")
return
}
os_log(msg, log: devLog, type: .debug, "unlock scroll view")
scrollLocked = false
scrollView.bounces = scrollBounce
scrollView.isDirectionalLockEnabled = false
switch layoutAdapter.position {
case .top, .bottom:
scrollView.showsVerticalScrollIndicator = scrollIndictorVisible
case .left, .right:
scrollView.showsHorizontalScrollIndicator = scrollIndictorVisible
}
}
private var shouldLooselyLockScrollView: Bool {
if surfaceView.frame == .zero {
return false
}
var isSmallScrollContentAndFitToBoundsMode: Bool {
if ownerVC?.contentMode == .fitToBounds, let scrollView = scrollView,
value(of: scrollView.contentSize) < value(of: scrollView.bounds.size) + max(layoutAdapter.offsetFromMostExpandedAnchor, 0) {
return true
}
return false
}
return isSmallScrollContentAndFitToBoundsMode
}
private func stopScrolling(at contentOffset: CGPoint) {
// Must use setContentOffset(_:animated) to force-stop deceleration
guard let scrollView = scrollView else { return }
var offset = scrollView.contentOffset
setValue(contentOffset, to: &offset)
scrollView.setContentOffset(offset, animated: false)
}
private func contentOffsetForPinning(of scrollView: UIScrollView) -> CGPoint {
if let vc = ownerVC, let origin = vc.delegate?.floatingPanel?(vc, contentOffsetForPinning: scrollView) {
return origin
}
switch layoutAdapter.position {
case .top:
return CGPoint(x: 0.0, y: scrollView.fp_contentOffsetMax.y)
case .left:
return CGPoint(x: scrollView.fp_contentOffsetMax.x, y: 0.0)
case .bottom:
return CGPoint(x: 0.0, y: 0.0 - scrollView.adjustedContentInset.top)
case .right:
return CGPoint(x: 0.0 - scrollView.adjustedContentInset.left, y: 0.0)
}
}
private func allowScrollPanGesture(of scrollView: UIScrollView, condition: (_ offset: CGFloat) -> Bool) -> Bool {
var offset: CGFloat = 0
switch layoutAdapter.position {
case .top, .left:
offset = value(of: scrollView.fp_contentOffsetMax - scrollView.contentOffset)
case .bottom, .right:
offset = value(of: scrollView.contentOffset - contentOffsetForPinning(of: scrollView))
}
return condition(offset)
}
func isScrollable(state: FloatingPanelState) -> Bool {
guard let scrollView = scrollView else { return false }
if let fpc = ownerVC,
let scrollable = fpc.delegate?.floatingPanel?(fpc, shouldAllowToScroll: scrollView, in: state)
{
return scrollable
}
return state == layoutAdapter.mostExpandedState
}
/// Adjust content inset of the tracking scroll view if the controller's
/// `contentInsetAdjustmentBehavior` is `.always` and its `contentMode` is `.static`.
/// if its content is scrollable, the content might not be fully visible on `.half`
/// state, for example. Therefore the content inset needs to adjust to display the
/// full content.
func adjustScrollContentInsetIfNeeded() {
guard
let fpc = ownerVC,
let scrollView = scrollView,
fpc.contentInsetAdjustmentBehavior == .always
else { return }
switch fpc.contentMode {
case .static:
var inset = scrollView.safeAreaInsets
let offset = layoutAdapter.offsetFromMostExpandedAnchor
if offset > 0 {
switch layoutAdapter.position {
case .top:
inset.top = offset + scrollView.safeAreaInsets.top
case .bottom:
inset.bottom = offset + scrollView.safeAreaInsets.bottom
case .left:
inset.left = offset + scrollView.safeAreaInsets.left
case .right:
inset.left = offset + scrollView.safeAreaInsets.right
}
}
scrollView.contentInset = inset
case .fitToBounds:
scrollView.contentInset = scrollView.safeAreaInsets
}
}
}
/// A gesture recognizer that looks for panning (dragging) gestures in a panel.
public final class FloatingPanelPanGestureRecognizer: UIPanGestureRecognizer {
/// The gesture starting location in the surface view which it is attached to.
fileprivate var initialLocation: CGPoint = .zero
private weak var floatingPanel: Core! // Core has this gesture recognizer as non-optional
fileprivate func set(floatingPanel: Core) {
self.floatingPanel = floatingPanel
}
init() {
super.init(target: nil, action: nil)
name = "FloatingPanelPanGestureRecognizer"
}
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
initialLocation = touches.first?.location(in: view) ?? .zero
if floatingPanel.transitionAnimator != nil || floatingPanel.moveAnimator != nil {
self.state = .began
}
}
/// The delegate of the gesture recognizer.
///
/// - Note: The delegate is used by FloatingPanel itself. If you set your own delegate object, an
/// exception is raised. If you want to handle the methods of UIGestureRecognizerDelegate, you can use `delegateProxy`.
public override weak var delegate: UIGestureRecognizerDelegate? {
get {
return super.delegate
}
set {
guard newValue is DelegateRouter else {
let exception = NSException(
name: .invalidArgumentException,
reason: "FloatingPanelController's built-in pan gesture recognizer must have its controller as its delegate. Use 'delegateProxy' property.",
userInfo: nil
)
exception.raise()
return
}
super.delegate = newValue
}
}
/// The default object implementing a set methods of the delegate of the gesture recognizer.
///
/// Use this property with ``delegateProxy`` when you need to use the default gesture behaviors in a proxy implementation.
public var delegateOrigin: UIGestureRecognizerDelegate {
return floatingPanel
}
/// A proxy object to intercept the default behavior of the gesture recognizer.
///
/// `UIGestureRecognizerDelegate` methods implementing by this object are called instead of the default delegate,
/// ``delegateOrigin``.
public weak var delegateProxy: UIGestureRecognizerDelegate? {
didSet {
self.delegate = floatingPanel?.panGestureDelegateRouter // Update the cached IMP
}
}
final class DelegateRouter: NSObject, UIGestureRecognizerDelegate {
fileprivate unowned let panGestureRecognizer: FloatingPanelPanGestureRecognizer
init(panGestureRecognizer: FloatingPanelPanGestureRecognizer) {
self.panGestureRecognizer = panGestureRecognizer
super.init()
}
override func responds(to aSelector: Selector!) -> Bool {
return panGestureRecognizer.delegateProxy?.responds(to: aSelector) == true
|| panGestureRecognizer.delegateOrigin.responds(to: aSelector)
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
if panGestureRecognizer.delegateProxy?.responds(to: aSelector) == true {
return panGestureRecognizer.delegateProxy
}
if panGestureRecognizer.delegateOrigin.responds(to: aSelector) {
return panGestureRecognizer.delegateOrigin
}
return nil
}
}
}
// MARK: - Animator
private class NumericSpringAnimator: NSObject {
struct Data {
let value: CGFloat
let velocity: CGFloat
}
private class UnfairLock {
var unfairLock = os_unfair_lock()
func lock() {
os_unfair_lock_lock(&unfairLock);
}
func tryLock() -> Bool {
return os_unfair_lock_trylock(&unfairLock);
}
func unlock() {
os_unfair_lock_unlock(&unfairLock);
}
}
private(set) var isRunning = false
private var lock = UnfairLock()
private lazy var displayLink = CADisplayLink(target: self, selector: #selector(update(_:)))
private var data: Data
private let target: CGFloat
private let displayScale: CGFloat
private let zeta: CGFloat
private let omega: CGFloat
private let update: ((_ data: Data) -> Void)
private let completion: (() -> Void)
init(initialData: Data,
target: CGFloat,
displayScale: CGFloat,
decelerationRate: CGFloat,
responseTime: CGFloat,
update: @escaping ((_ data: Data) -> Void),
completion: @escaping (() -> Void)) {
self.data = initialData
self.target = target
self.displayScale = displayScale
let frequency = 1 / responseTime // oscillation frequency
let duration: CGFloat = 0.001 // millisecond
self.zeta = abs(initialData.velocity) > 300 ? CoreGraphics.log(decelerationRate) / (-2.0 * .pi * frequency * duration) : 1.0
self.omega = 2.0 * .pi * frequency
self.update = update
self.completion = completion
}
@discardableResult
func startAnimation() -> Bool{
lock.lock()
defer { lock.unlock() }
if isRunning {
return false
}
os_log(msg, log: devLog, type: .debug, "startAnimation -- \(displayLink)")
isRunning = true
displayLink.add(to: RunLoop.main, forMode: .common)
return true
}
func stopAnimation(_ withoutFinishing: Bool) {
let locked = lock.tryLock()
defer {
if locked { lock.unlock() }
}
os_log(msg, log: devLog, type: .debug, "stopAnimation -- \(displayLink)")
isRunning = false
displayLink.invalidate()
if withoutFinishing {
return
}
completion()
}
@objc
func update(_ displayLink: CADisplayLink) {
guard lock.tryLock() else { return }
defer { lock.unlock() }
let pre = data.value
var cur = pre
var velocity = data.velocity
spring(x: &cur,
v: &velocity,
xt: target,
zeta: zeta,
omega: omega,
h: CGFloat(displayLink.targetTimestamp - displayLink.timestamp))
data = Data(value: cur, velocity: velocity)
update(data)
if abs(target - data.value) <= (1 / displayScale),
abs(pre - data.value) / (1 / displayScale) <= 1 {
stopAnimation(false)
}
}
/**
- Parameters:
- x: value
- v: velocity
- xt: target value
- zeta: damping ratio
- omega: angular frequency
- h: time step
*/
private func spring(x: inout CGFloat, v: inout CGFloat, xt: CGFloat, zeta: CGFloat, omega: CGFloat, h: CGFloat) {
let f = 1.0 + 2.0 * h * zeta * omega
let h2 = pow(h, 2)
let o2 = pow(omega, 2)
let det = f + h2 * o2
x = (f * x + h * v + h2 * o2 * xt) / det
v = (v + h * o2 * (xt - x)) / det
}
}
extension FloatingPanelController {
func suspendTransitionAnimator(_ suspended: Bool) {
self.floatingPanel.isSuspended = suspended
}
var transitionAnimator: UIViewPropertyAnimator? {
return self.floatingPanel.transitionAnimator
}
}