Files
FloatingPanel/Framework/Sources/FloatingPanel.swift
T
Shin Yamamoto 6851e3b072 Support both of Swift 5 and 4.2
The default Swift version leaves 4.0. Because it avoids build errors
with Carthage on each Xcode version from the source compatibility
between Swift 4.0, 4.2 and 5.0.

With regard to CocoaPods, the pod spec is going to support
`swift_versions` introduced in CocoaPods v1.7.0. For now, a user needs
to override `SWIFT_VERSION` appropriately in Podfile.
2019-04-04 12:08:27 +09:00

926 lines
36 KiB
Swift

//
// Copyright © 2018 Shin Yamamoto. All rights reserved.
//
import UIKit
import UIKit.UIGestureRecognizerSubclass // For Xcode 9.4.1
///
/// FloatingPanel presentation model
///
class FloatingPanel: NSObject, UIGestureRecognizerDelegate, UIScrollViewDelegate {
// MUST be a weak reference to prevent UI freeze on the presentation modally
weak var viewcontroller: FloatingPanelController!
let surfaceView: FloatingPanelSurfaceView
let backdropView: FloatingPanelBackdropView
var layoutAdapter: FloatingPanelLayoutAdapter
var behavior: FloatingPanelBehavior
weak var scrollView: UIScrollView? {
didSet {
guard let scrollView = scrollView else { return }
scrollView.panGestureRecognizer.addTarget(self, action: #selector(handle(panGesture:)))
scrollBouncable = scrollView.bounces
scrollIndictorVisible = scrollView.showsVerticalScrollIndicator
}
}
private(set) var state: FloatingPanelPosition = .hidden {
didSet { viewcontroller.delegate?.floatingPanelDidChangePosition(viewcontroller) }
}
private var isBottomState: Bool {
let remains = layoutAdapter.supportedPositions.filter { $0.rawValue > state.rawValue }
return remains.count == 0
}
let panGestureRecognizer: FloatingPanelPanGestureRecognizer
var isRemovalInteractionEnabled: Bool = false
fileprivate var animator: UIViewPropertyAnimator?
private var initialFrame: CGRect = .zero
private var initialTranslationY: CGFloat = 0
private var initialLocation: CGPoint = .nan
var interactionInProgress: Bool = false
var isDecelerating: Bool = false
// Scroll handling
private var initialScrollOffset: CGPoint = .zero
private var initialScrollFrame: CGRect = .zero
private var stopScrollDeceleration: Bool = false
private var scrollBouncable = false
private var scrollIndictorVisible = false
// MARK: - Interface
init(_ vc: FloatingPanelController, layout: FloatingPanelLayout, behavior: FloatingPanelBehavior) {
viewcontroller = vc
surfaceView = FloatingPanelSurfaceView()
surfaceView.backgroundColor = .white
backdropView = FloatingPanelBackdropView()
backdropView.backgroundColor = .black
backdropView.alpha = 0.0
self.layoutAdapter = FloatingPanelLayoutAdapter(surfaceView: surfaceView,
backdropView: backdropView,
layout: layout)
self.behavior = behavior
panGestureRecognizer = FloatingPanelPanGestureRecognizer()
if #available(iOS 11.0, *) {
panGestureRecognizer.name = "FloatingPanelSurface"
}
super.init()
panGestureRecognizer.floatingPanel = self
surfaceView.addGestureRecognizer(panGestureRecognizer)
panGestureRecognizer.addTarget(self, action: #selector(handle(panGesture:)))
panGestureRecognizer.delegate = self
}
func move(to: FloatingPanelPosition, animated: Bool, completion: (() -> Void)? = nil) {
move(from: state, to: to, animated: animated, completion: completion)
}
private func move(from: FloatingPanelPosition, to: FloatingPanelPosition, animated: Bool, completion: (() -> Void)? = nil) {
if state != layoutAdapter.topMostState {
lockScrollView()
}
tearDownActiveInteraction()
if animated {
let animator: UIViewPropertyAnimator
switch (from, to) {
case (.hidden, let to):
animator = behavior.addAnimator(self.viewcontroller, to: to)
case (let from, .hidden):
animator = behavior.removeAnimator(self.viewcontroller, from: from)
case (let from, let to):
animator = behavior.moveAnimator(self.viewcontroller, from: from, to: to)
}
animator.addAnimations { [weak self] in
guard let `self` = self else { return }
self.state = to
self.updateLayout(to: to)
}
animator.addCompletion { [weak self] _ in
guard let `self` = self else { return }
self.animator = nil
completion?()
}
self.animator = animator
animator.startAnimation()
} else {
self.state = to
self.updateLayout(to: to)
completion?()
}
}
// MARK: - Layout update
private func updateLayout(to target: FloatingPanelPosition) {
self.layoutAdapter.activateLayout(of: target)
}
private func getBackdropAlpha(with translation: CGPoint) -> CGFloat {
let currentY = surfaceView.frame.minY
let next = directionalPosition(at: currentY, with: translation)
let pre = redirectionalPosition(at: currentY, with: translation)
let nextY = layoutAdapter.positionY(for: next)
let preY = layoutAdapter.positionY(for: pre)
let nextAlpha = layoutAdapter.layout.backdropAlphaFor(position: next)
let preAlpha = layoutAdapter.layout.backdropAlphaFor(position: pre)
if preY == nextY {
return preAlpha
} else {
return preAlpha + max(min(1.0, 1.0 - (nextY - currentY) / (nextY - preY) ), 0.0) * (nextAlpha - preAlpha)
}
}
// MARK: - UIGestureRecognizerDelegate
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer == panGestureRecognizer else { return false }
/* log.debug("shouldRecognizeSimultaneouslyWith", otherGestureRecognizer) */
if viewcontroller.delegate?.floatingPanel(viewcontroller, shouldRecognizeSimultaneouslyWith: otherGestureRecognizer) ?? false {
return true
}
switch otherGestureRecognizer {
case is UIPanGestureRecognizer,
is UISwipeGestureRecognizer,
is UIRotationGestureRecognizer,
is UIScreenEdgePanGestureRecognizer,
is UIPinchGestureRecognizer:
// 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 always recognize tap/long press gestures in parallel
return true
}
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer == panGestureRecognizer else { return false }
/* log.debug("shouldBeRequiredToFailBy", otherGestureRecognizer) */
return false
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer == panGestureRecognizer else { return false }
/* log.debug("shouldRequireFailureOf", otherGestureRecognizer) */
// 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) {
return false
}
}
if viewcontroller.delegate?.floatingPanel(viewcontroller, shouldRecognizeSimultaneouslyWith: otherGestureRecognizer) ?? false {
return false
}
switch otherGestureRecognizer {
case is UIPanGestureRecognizer,
is UISwipeGestureRecognizer,
is UIRotationGestureRecognizer,
is UIScreenEdgePanGestureRecognizer,
is UIPinchGestureRecognizer:
// 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
}
}
var grabberAreaFrame: CGRect {
let grabberAreaFrame = CGRect(x: surfaceView.bounds.origin.x,
y: surfaceView.bounds.origin.y,
width: surfaceView.bounds.width,
height: FloatingPanelSurfaceView.topGrabberBarHeight * 2)
return grabberAreaFrame
}
// MARK: - Gesture handling
@objc func handle(panGesture: UIPanGestureRecognizer) {
let velocity = panGesture.velocity(in: panGesture.view)
switch panGesture {
case scrollView?.panGestureRecognizer:
guard let scrollView = scrollView else { return }
let location = panGesture.location(in: surfaceView)
let belowTop = surfaceView.frame.minY > layoutAdapter.topY
log.debug("scroll gesture(\(state):\(panGesture.state)) --",
"belowTop = \(belowTop),",
"interactionInProgress = \(interactionInProgress),",
"scroll offset = \(scrollView.contentOffset.y),",
"location = \(location.y), velocity = \(velocity.y)")
if belowTop {
// Scroll offset pinning
if state == layoutAdapter.topMostState {
if interactionInProgress {
log.debug("settle offset --", initialScrollOffset.y)
scrollView.setContentOffset(initialScrollOffset, animated: false)
} else {
if grabberAreaFrame.contains(location) {
// Preserve the current content offset in moving from full.
scrollView.setContentOffset(initialScrollOffset, animated: false)
} else {
let offset = scrollView.contentOffset.y - scrollView.contentOffsetZero.y
if offset < 0 {
fitToBounds(scrollView: scrollView)
let translation = panGesture.translation(in: panGestureRecognizer.view!.superview)
startInteraction(with: translation, at: location)
}
}
}
} else {
scrollView.setContentOffset(initialScrollOffset, animated: false)
}
// Always hide a scroll indicator at the non-top.
if interactionInProgress {
lockScrollView()
}
} else {
// Always show a scroll indicator at the top.
if interactionInProgress {
unlockScrollView()
} else {
let offset = scrollView.contentOffset.y - scrollView.contentOffsetZero.y
if state == layoutAdapter.topMostState, offset < 0, velocity.y > 0 {
fitToBounds(scrollView: scrollView)
let translation = panGesture.translation(in: panGestureRecognizer.view!.superview)
startInteraction(with: translation, at: location)
}
}
}
case panGestureRecognizer:
let translation = panGesture.translation(in: panGestureRecognizer.view!.superview)
let location = panGesture.location(in: panGesture.view)
log.debug("panel gesture(\(state):\(panGesture.state)) --",
"translation = \(translation.y), location = \(location.y), velocity = \(velocity.y)")
if let animator = self.animator {
log.debug("panel animation interrupted!!!")
if animator.isInterruptible {
animator.stopAnimation(false)
animator.finishAnimation(at: .current)
}
self.animator = nil
// A user can stop a panel at the nearest Y of a target position
if abs(surfaceView.frame.minY - layoutAdapter.topY) < 1 {
surfaceView.frame.origin.y = layoutAdapter.topY
}
}
if interactionInProgress == false,
viewcontroller.delegate?.floatingPanelShouldBeginDragging(viewcontroller) == false {
return
}
if panGesture.state == .began {
panningBegan(at: location)
return
}
if shouldScrollViewHandleTouch(scrollView, point: location, velocity: velocity) {
return
}
switch panGesture.state {
case .changed:
if interactionInProgress == false {
startInteraction(with: translation, at: location)
}
panningChange(with: translation)
case .ended, .cancelled, .failed:
panningEnd(with: translation, velocity: velocity)
default:
break
}
default:
return
}
}
private func shouldScrollViewHandleTouch(_ scrollView: UIScrollView?, point: CGPoint, velocity: CGPoint) -> Bool {
// When no scrollView, nothing to handle.
guard let scrollView = scrollView else { return false }
// For _UISwipeActionPanGestureRecognizer
if let scrollGestureRecognizers = scrollView.gestureRecognizers {
for gesture in scrollGestureRecognizers {
guard gesture.state == .began || gesture.state == .changed
else { continue }
if gesture != scrollView.panGestureRecognizer {
return true
}
}
}
guard
state == layoutAdapter.topMostState, // When not top most(i.e. .full), don't scroll.
interactionInProgress == false, // When interaction already in progress, don't scroll.
surfaceView.frame.minY == layoutAdapter.topY
else {
return false
}
// When the current and initial point within grabber area, do scroll.
if grabberAreaFrame.contains(point), !grabberAreaFrame.contains(initialLocation) {
return true
}
guard
scrollView.frame.contains(initialLocation), // When initialLocation not in scrollView, don't scroll.
!grabberAreaFrame.contains(point) // When point within grabber area, don't scroll.
else {
return false
}
let offset = scrollView.contentOffset.y - scrollView.contentOffsetZero.y
// The zero offset must be excluded because the offset is usually zero
// after a panel moves from half/tip to full.
if offset > 0.0 {
return true
}
if scrollView.isDecelerating {
return true
}
if velocity.y <= 0 {
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.
log.debug("panningBegan -- location = \(location.y)")
initialLocation = location
if state == layoutAdapter.topMostState {
if let scrollView = scrollView {
initialScrollFrame = scrollView.frame
}
} else {
if let scrollView = scrollView {
initialScrollOffset = scrollView.contentOffset
}
}
}
private func panningChange(with translation: CGPoint) {
log.debug("panningChange -- translation = \(translation.y)")
let pre = surfaceView.frame.minY
let dy = translation.y - initialTranslationY
layoutAdapter.updateInteractiveTopConstraint(diff: dy,
allowsTopBuffer: allowsTopBuffer(for: dy))
backdropView.alpha = getBackdropAlpha(with: translation)
preserveContentVCLayoutIfNeeded()
let didMove = (pre != surfaceView.frame.minY)
guard didMove else { return }
viewcontroller.delegate?.floatingPanelDidMove(viewcontroller)
}
private func allowsTopBuffer(for translationY: CGFloat) -> Bool {
let preY = surfaceView.frame.minY
let nextY = initialFrame.offsetBy(dx: 0.0, dy: translationY).minY
if let scrollView = scrollView, scrollView.panGestureRecognizer.state == .changed,
preY > 0 && preY > nextY {
return false
} else {
return true
}
}
private var disabledBottomAutoLayout = false
// Prevent stretching a view having a constraint to SafeArea.bottom in an overflow
// from the full position because SafeArea is global in a screen.
private func preserveContentVCLayoutIfNeeded() {
// Must include topY
if (surfaceView.frame.minY <= layoutAdapter.topY) {
if !disabledBottomAutoLayout {
viewcontroller.contentViewController?.view?.constraints.forEach({ (const) in
switch viewcontroller.contentViewController?.layoutGuide.bottomAnchor {
case const.firstAnchor:
(const.secondItem as? UIView)?.disableAutoLayout()
const.isActive = false
case const.secondAnchor:
(const.firstItem as? UIView)?.disableAutoLayout()
const.isActive = false
default:
break
}
})
}
disabledBottomAutoLayout = true
} else {
if disabledBottomAutoLayout {
viewcontroller.contentViewController?.view?.constraints.forEach({ (const) in
switch viewcontroller.contentViewController?.layoutGuide.bottomAnchor {
case const.firstAnchor:
(const.secondItem as? UIView)?.enableAutoLayout()
const.isActive = true
case const.secondAnchor:
(const.firstItem as? UIView)?.enableAutoLayout()
const.isActive = true
default:
break
}
})
}
disabledBottomAutoLayout = false
}
}
private func panningEnd(with translation: CGPoint, velocity: CGPoint) {
log.debug("panningEnd -- translation = \(translation.y), velocity = \(velocity.y)")
if state == .hidden {
log.debug("Already hidden")
return
}
stopScrollDeceleration = (surfaceView.frame.minY > layoutAdapter.topY) // Projecting the dragging to the scroll dragging or not
if stopScrollDeceleration {
DispatchQueue.main.async { [weak self] in
guard let `self` = self else { return }
self.stopScrollingWithDeceleration(at: self.initialScrollOffset)
}
}
let targetPosition = self.targetPosition(with: velocity)
let distance = self.distance(to: targetPosition)
endInteraction(for: targetPosition)
if isRemovalInteractionEnabled, isBottomState {
let velocityVector = (distance != 0) ? CGVector(dx: 0,
dy: min(abs(velocity.y)/distance, behavior.removalVelocity)) : .zero
if shouldStartRemovalAnimation(with: velocityVector) {
viewcontroller.delegate?.floatingPanelDidEndDraggingToRemove(viewcontroller, withVelocity: velocity)
self.startRemovalAnimation(with: velocityVector) { [weak self] in
guard let `self` = self else { return }
self.viewcontroller.dismiss(animated: false, completion: { [weak self] in
guard let `self` = self else { return }
self.viewcontroller.delegate?.floatingPanelDidEndRemove(self.viewcontroller)
})
}
return
}
}
viewcontroller.delegate?.floatingPanelDidEndDragging(viewcontroller, withVelocity: velocity, targetPosition: targetPosition)
// Workaround: Disable a tracking scroll to prevent bouncing a scroll content in a panel animating
let isScrollEnabled = scrollView?.isScrollEnabled
if let scrollView = scrollView, targetPosition != .full {
scrollView.isScrollEnabled = false
}
startAnimation(to: targetPosition, at: distance, with: velocity)
// Workaround: Reset `self.scrollView.isScrollEnabled`
if let scrollView = scrollView, targetPosition != .full,
let isScrollEnabled = isScrollEnabled {
scrollView.isScrollEnabled = isScrollEnabled
}
}
private func shouldStartRemovalAnimation(with velocityVector: CGVector) -> Bool {
let posY = layoutAdapter.positionY(for: state)
let currentY = surfaceView.frame.minY
let bottomMaxY = layoutAdapter.bottomMaxY
let vth = behavior.removalVelocity
let pth = max(min(behavior.removalProgress, 1.0), 0.0)
let num = (currentY - posY)
let den = (bottomMaxY - posY)
guard num >= 0, den != 0, (num / den >= pth || velocityVector.dy == vth)
else { return false }
return true
}
private func startRemovalAnimation(with velocityVector: CGVector, completion: (() -> Void)?) {
let animator = self.behavior.removalInteractionAnimator(self.viewcontroller, with: velocityVector)
animator.addAnimations { [weak self] in
self?.updateLayout(to: .hidden)
}
animator.addCompletion({ _ in
self.animator = nil
completion?()
})
self.animator = animator
animator.startAnimation()
}
private func startInteraction(with translation: CGPoint, at location: CGPoint) {
/* Don't lock a scroll view to show a scroll indicator after hitting the top */
log.debug("startInteraction -- translation = \(translation.y), location = \(location.y)")
guard interactionInProgress == false else { return }
initialFrame = surfaceView.frame
if state == layoutAdapter.topMostState, let scrollView = scrollView {
if grabberAreaFrame.contains(location) {
initialScrollOffset = scrollView.contentOffset
} else {
settle(scrollView: scrollView)
initialScrollOffset = scrollView.contentOffsetZero
}
log.debug("initial scroll offset --", initialScrollOffset)
}
initialTranslationY = translation.y
viewcontroller.delegate?.floatingPanelWillBeginDragging(viewcontroller)
layoutAdapter.startInteraction(at: state)
interactionInProgress = true
}
private func endInteraction(for targetPosition: FloatingPanelPosition) {
log.debug("endInteraction to \(targetPosition)")
if let scrollView = scrollView {
log.debug("endInteraction -- scroll offset = \(scrollView.contentOffset)")
}
interactionInProgress = false
// Prevent to keep a scroll view indicator visible at the half/tip position
if state != layoutAdapter.topMostState {
lockScrollView()
}
layoutAdapter.endInteraction(at: targetPosition)
}
private func tearDownActiveInteraction() {
// Cancel the pan gesture so that panningEnd(with:velocity:) is called
panGestureRecognizer.isEnabled = false
panGestureRecognizer.isEnabled = true
}
private func startAnimation(to targetPosition: FloatingPanelPosition, at distance: CGFloat, with velocity: CGPoint) {
log.debug("startAnimation to \(targetPosition) -- distance = \(distance), velocity = \(velocity.y)")
isDecelerating = true
viewcontroller.delegate?.floatingPanelWillBeginDecelerating(viewcontroller)
let velocityVector = (distance != 0) ? CGVector(dx: 0, dy: min(abs(velocity.y)/distance, 30.0)) : .zero
let animator = behavior.interactionAnimator(self.viewcontroller, to: targetPosition, with: velocityVector)
animator.addAnimations { [weak self] in
guard let `self` = self else { return }
self.state = targetPosition
self.updateLayout(to: targetPosition)
}
animator.addCompletion { [weak self] pos in
guard let `self` = self else { return }
self.finishAnimation(at: targetPosition)
}
self.animator = animator
animator.startAnimation()
}
private func finishAnimation(at targetPosition: FloatingPanelPosition) {
log.debug("finishAnimation to \(targetPosition)")
self.isDecelerating = false
self.animator = nil
self.viewcontroller.delegate?.floatingPanelDidEndDecelerating(self.viewcontroller)
if let scrollView = scrollView {
log.debug("finishAnimation -- scroll offset = \(scrollView.contentOffset)")
}
stopScrollDeceleration = false
// Don't unlock scroll view in animating view when presentation layer != model layer
if state == layoutAdapter.topMostState {
unlockScrollView()
}
}
private func distance(to targetPosition: FloatingPanelPosition) -> CGFloat {
let topY = layoutAdapter.topY
let middleY = layoutAdapter.middleY
let bottomY = layoutAdapter.bottomY
let currentY = surfaceView.frame.minY
switch targetPosition {
case .full:
return CGFloat(abs(currentY - topY))
case .half:
return CGFloat(abs(currentY - middleY))
case .tip:
return CGFloat(abs(currentY - bottomY))
case .hidden:
fatalError("Now .hidden must not be used for a user interaction")
}
}
private func directionalPosition(at currentY: CGFloat, with translation: CGPoint) -> FloatingPanelPosition {
return getPosition(at: currentY, with: translation, directional: true)
}
private func redirectionalPosition(at currentY: CGFloat, with translation: CGPoint) -> FloatingPanelPosition {
return getPosition(at: currentY, with: translation, directional: false)
}
private func getPosition(at currentY: CGFloat, with translation: CGPoint, directional: Bool) -> FloatingPanelPosition {
let supportedPositions: Set = layoutAdapter.supportedPositions
if supportedPositions.count == 1 {
return state
}
let isForwardYAxis = (translation.y >= 0)
switch supportedPositions {
case [.full, .half]:
return (isForwardYAxis == directional) ? .half : .full
case [.half, .tip]:
return (isForwardYAxis == directional) ? .tip : .half
case [.full, .tip]:
return (isForwardYAxis == directional) ? .tip : .full
default:
let middleY = layoutAdapter.middleY
if currentY > middleY {
return (isForwardYAxis == directional) ? .tip : .half
} else {
return (isForwardYAxis == directional) ? .half : .full
}
}
}
// 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)
}
private func targetPosition(with velocity: CGPoint) -> (FloatingPanelPosition) {
let currentY = surfaceView.frame.minY
let supportedPositions = layoutAdapter.supportedPositions
if supportedPositions.count == 1 {
return state
}
switch supportedPositions {
case [.full, .half]:
return targetPosition(from: [.full, .half], at: currentY, velocity: velocity)
case [.half, .tip]:
return targetPosition(from: [.half, .tip], at: currentY, velocity: velocity)
case [.full, .tip]:
return targetPosition(from: [.full, .tip], at: currentY, velocity: velocity)
default:
/*
[topY|full]---[th1]---[middleY|half]---[th2]---[bottomY|tip]
*/
let topY = layoutAdapter.topY
let middleY = layoutAdapter.middleY
let bottomY = layoutAdapter.bottomY
let nextState: FloatingPanelPosition
let forwardYDirection: Bool
/*
full <-> half <-> tip
*/
switch state {
case .full:
nextState = .half
forwardYDirection = true
case .half:
nextState = (currentY > middleY) ? .tip : .full
forwardYDirection = (currentY > middleY)
case .tip:
nextState = .half
forwardYDirection = false
case .hidden:
fatalError("Now .hidden must not be used for a user interaction")
}
let redirectionalProgress = max(min(behavior.redirectionalProgress(viewcontroller, from: state, to: nextState), 1.0), 0.0)
let th1: CGFloat
let th2: CGFloat
if forwardYDirection {
th1 = topY + (middleY - topY) * redirectionalProgress
th2 = middleY + (bottomY - middleY) * redirectionalProgress
} else {
th1 = middleY - (middleY - topY) * redirectionalProgress
th2 = bottomY - (bottomY - middleY) * redirectionalProgress
}
let decelerationRate = behavior.momentumProjectionRate(viewcontroller)
let baseY = abs(bottomY - topY)
let vecY = velocity.y / baseY
let pY = project(initialVelocity: vecY, decelerationRate: decelerationRate) * baseY + currentY
switch currentY {
case ..<th1:
switch pY {
case bottomY...:
return behavior.shouldProjectMomentum(viewcontroller, for: .tip) ? .tip : .half
case middleY...:
return .half
case topY...:
return .full
default:
return .full
}
case ...middleY:
switch pY {
case bottomY...:
return behavior.shouldProjectMomentum(viewcontroller, for: .tip) ? .tip : .half
case middleY...:
return .half
case topY...:
return .half
default:
return .full
}
case ..<th2:
switch pY {
case bottomY...:
return .tip
case middleY...:
return .half
case topY...:
return .half
default:
return behavior.shouldProjectMomentum(viewcontroller, for: .full) ? .full : .half
}
default:
switch pY {
case bottomY...:
return .tip
case middleY...:
return .tip
case topY...:
return .half
default:
return behavior.shouldProjectMomentum(viewcontroller, for: .full) ? .full : .half
}
}
}
}
private func targetPosition(from positions: [FloatingPanelPosition], at currentY: CGFloat, velocity: CGPoint) -> FloatingPanelPosition {
assert(positions.count == 2)
let top = positions[0]
let bottom = positions[1]
let topY = layoutAdapter.positionY(for: top)
let bottomY = layoutAdapter.positionY(for: bottom)
let target = top == state ? bottom : top
let redirectionalProgress = max(min(behavior.redirectionalProgress(viewcontroller, from: state, to: target), 1.0), 0.0)
let th = topY + (bottomY - topY) * redirectionalProgress
let decelerationRate = behavior.momentumProjectionRate(viewcontroller)
let pY = project(initialVelocity: velocity.y, decelerationRate: decelerationRate) + currentY
switch currentY {
case ..<th:
if pY >= bottomY {
return bottom
} else {
return top
}
default:
if pY <= topY {
return top
} else {
return bottom
}
}
}
// MARK: - ScrollView handling
private func lockScrollView() {
guard let scrollView = scrollView else { return }
scrollView.isDirectionalLockEnabled = true
scrollView.bounces = false
scrollView.showsVerticalScrollIndicator = false
}
private func unlockScrollView() {
guard let scrollView = scrollView else { return }
scrollView.isDirectionalLockEnabled = false
scrollView.bounces = scrollBouncable
scrollView.showsVerticalScrollIndicator = scrollIndictorVisible
}
private func fitToBounds(scrollView: UIScrollView) {
log.debug("fit scroll view to bounds -- scroll offset =", scrollView.contentOffset.y)
surfaceView.frame.origin.y = layoutAdapter.topY - scrollView.contentOffset.y
scrollView.transform = CGAffineTransform.identity.translatedBy(x: 0.0,
y: scrollView.contentOffset.y)
scrollView.scrollIndicatorInsets = UIEdgeInsets(top: -scrollView.contentOffset.y,
left: 0.0,
bottom: 0.0,
right: 0.0)
}
private func settle(scrollView: UIScrollView) {
log.debug("settle scroll view")
let frame = surfaceView.layer.presentation()?.frame ?? surfaceView.frame
surfaceView.transform = .identity
surfaceView.frame = frame
scrollView.transform = .identity
scrollView.frame = initialScrollFrame
scrollView.contentOffset = scrollView.contentOffsetZero
scrollView.scrollIndicatorInsets = .zero
}
private func stopScrollingWithDeceleration(at contentOffset: CGPoint) {
// Must use setContentOffset(_:animated) to force-stop deceleration
scrollView?.setContentOffset(contentOffset, animated: false)
}
}
class FloatingPanelPanGestureRecognizer: UIPanGestureRecognizer {
fileprivate weak var floatingPanel: FloatingPanel?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
if floatingPanel?.animator != nil {
self.state = .began
}
}
override weak var delegate: UIGestureRecognizerDelegate? {
get {
return super.delegate
}
set {
guard newValue is FloatingPanel else {
let exception = NSException(name: .invalidArgumentException,
reason: "FloatingPanelController's built-in pan gesture recognizer must have its controller as its delegate.",
userInfo: nil)
exception.raise()
return
}
super.delegate = newValue
}
}
}