afff000d8c
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.
1457 lines
60 KiB
Swift
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
|
|
}
|
|
}
|