Files
FloatingPanel/Framework/Sources/FloatingPanel.swift
T

418 lines
15 KiB
Swift

//
// Copyright © 2018 scenee. All rights reserved.
//
import UIKit
///
/// FloatingPanel presentation model
///
class FloatingPanel: NSObject, UIGestureRecognizerDelegate {
/* Cause 'terminating with uncaught exception of type NSException' error on Swift Playground
unowned let view: UIView
*/
let surfaceView: FloatingPanelSurfaceView
let backdropView: FloatingPanelBackdropView
private unowned let viewcontroller: FloatingPanelController
weak var scrollView: UIScrollView? {
didSet {
configureScrollable()
}
}
var safeAreaInsets: UIEdgeInsets! {
get {
return layoutAdapter.safeAreaInsets
}
set {
layoutAdapter.safeAreaInsets = newValue
}
}
private(set) var state: FloatingPanelPosition = .tip {
didSet {
switch state {
case .full:
backdropView.alpha = layoutAdapter.layout.backdropAlpha
default:
backdropView.alpha = 0.0
}
configureScrollable()
}
}
var layoutAdapter: FloatingPanelLayoutAdapter
var behavior: FloatingPanelBehavior
private var animator: UIViewPropertyAnimator?
private let panGesture: UIPanGestureRecognizer
private var initialFrame: CGRect = .zero
private var transOffsetY: CGFloat = 0
private var interactionInProgress: Bool = false
init(_ vc: FloatingPanelController, layout: FloatingPanelLayout, behavior: FloatingPanelBehavior) {
viewcontroller = vc
surfaceView = vc.view as! FloatingPanelSurfaceView
backdropView = FloatingPanelBackdropView()
backdropView.backgroundColor = .black
backdropView.alpha = 0.0
self.layoutAdapter = FloatingPanelLayoutAdapter(surfaceView: surfaceView,
backdropView: backdropView,
layout: layout)
self.behavior = behavior
panGesture = UIPanGestureRecognizer()
if #available(iOS 11.0, *) {
panGesture.name = "FloatingPanelSurface"
}
super.init()
surfaceView.addGestureRecognizer(panGesture)
panGesture.addTarget(self, action: #selector(handle(panGesture:)))
panGesture.delegate = self
}
func layoutViews(in vc: UIViewController) {
unowned let view = vc.view!
view.insertSubview(backdropView, belowSubview: surfaceView)
backdropView.frame = view.bounds
layoutAdapter.prepareLayout(toParent: vc)
}
func move(to: FloatingPanelPosition, animated: Bool, completion: (() -> Void)? = nil) {
if animated {
let animator = behavior.presentAnimator(from: state, to: to)
animator.addAnimations { [weak self] in
self?.updateLayout(to: to)
self?.state = to
}
animator.addCompletion { _ in
completion?()
}
animator.startAnimation()
} else {
self.updateLayout(to: to)
self.state = to
completion?()
}
}
func present(animated: Bool, completion: (() -> Void)? = nil) {
self.layoutAdapter.activateLayout(of: nil)
move(to: layoutAdapter.layout.initialPosition, animated: animated, completion: completion)
}
func dismiss(animated: Bool, completion: (() -> Void)? = nil) {
if animated {
let animator = behavior.dismissAnimator(from: state)
animator.addAnimations { [weak self] in
self?.updateLayout(to: nil)
}
animator.addCompletion { _ in
completion?()
}
animator.startAnimation()
} else {
self.updateLayout(to: nil)
completion?()
}
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
log.debug("gestureRecognizer", gestureRecognizer,
"shouldRecognizeSimultaneouslyWith", otherGestureRecognizer)
if #available(iOS 11.0, *) {
log.debug("gestureRecognizer",
String(describing: gestureRecognizer.name),
"shouldRecognizeSimultaneouslyWith",
String(describing: otherGestureRecognizer.name))
}
switch (gestureRecognizer, otherGestureRecognizer) {
case (panGesture, scrollView?.panGestureRecognizer):
return state == .full
case (panGesture, is UIPanGestureRecognizer):
return false
default:
return true
}
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// Do not begin any gestures until the pan gesture fails at non-full position.
return gestureRecognizer == panGesture && state != .full
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
private func configureScrollable() {
switch state {
case .full:
scrollView?.isScrollEnabled = true
default:
scrollView?.isScrollEnabled = false
}
}
@objc func handle(panGesture: UIPanGestureRecognizer) {
let translation = panGesture.translation(in: panGesture.view!.superview)
let velocity = panGesture.velocity(in: panGesture.view)
let location = panGesture.location(in: panGesture.view)
if #available(iOS 11.0, *) {
log.debug("Gesture >>>>", panGesture.name!)
}
if let scrollView = scrollView, scrollView.frame.contains(location), interactionInProgress == false {
log.debug("ScrollView.contentOffset >>>", scrollView.contentOffset)
if state == .full {
if scrollView.contentOffset.y > scrollView.contentOffsetZero.y {
return
}
if scrollView.isDecelerating {
return
}
if interactionInProgress == false, velocity.y < 0 {
return
}
}
scrollView.contentOffset.y = scrollView.contentOffsetZero.y
}
log.debug(panGesture.state, ">>>", "{ translation: \(translation), velocity: \(velocity) }")
switch panGesture.state {
case .began:
panningBegan()
case .changed:
panningChange(with: translation)
case .ended, .cancelled, .failed:
panningEnd(with: translation, velocity: velocity)
case .possible:
break
}
}
private func panningBegan() {
// 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 I don't nothing here.
log.debug("panningBegan \(initialFrame)")
}
private func panningChange(with translation: CGPoint) {
log.debug("panningChange")
if interactionInProgress == false {
startInteraction(with: translation)
}
var frame = initialFrame
frame.origin.y = getCurrentY(from: initialFrame, with: translation)
surfaceView.frame = frame
viewcontroller.delegate?.floatingPanelDidMove(viewcontroller)
backdropView.alpha = updateBackdropAlpha(with: translation)
}
private func panningEnd(with translation: CGPoint, velocity: CGPoint) {
log.debug("panningEnd")
if interactionInProgress == false {
initialFrame = surfaceView.frame
}
let targetPosition = self.targetPosition(with: translation, velocity: velocity)
let distance = self.distance(to: targetPosition, with: translation)
endInteraction(for: targetPosition)
viewcontroller.delegate?.floatingPanelDidEndDragging(viewcontroller, withVelocity: velocity, targetPosition: targetPosition)
viewcontroller.delegate?.floatingPanelWillBeginDecelerating(viewcontroller)
startAnimation(to: targetPosition, at: distance, with: velocity)
}
private func startInteraction(with translation: CGPoint) {
log.debug("startInteraction")
initialFrame = surfaceView.frame
transOffsetY = translation.y
viewcontroller.delegate?.floatingPanelWillBeginDragging(viewcontroller)
if let scrollView = scrollView {
scrollView.isScrollEnabled = false
}
interactionInProgress = true
}
private func endInteraction(for targetPosition: FloatingPanelPosition) {
log.debug("endInteraction for \(targetPosition)")
if let scrollView = scrollView {
if targetPosition == .full {
scrollView.isScrollEnabled = true
}
}
interactionInProgress = false
}
private func getCurrentY(from rect: CGRect, with translation: CGPoint) -> CGFloat {
let dy = translation.y - transOffsetY
let y = rect.offsetBy(dx: 0.0, dy: dy).origin.y
let topY = layoutAdapter.topY
let topInset = layoutAdapter.topInset
let topBuffer = layoutAdapter.layout.topInteractionBuffer
let bottomY = layoutAdapter.bottomY
let bottomBuffer = layoutAdapter.layout.bottomInteractionBuffer
return max(topY - topInset + topBuffer, min(bottomY + bottomBuffer, y))
}
private func startAnimation(to targetPosition: FloatingPanelPosition, at distance: CGFloat, with velocity: CGPoint) {
let targetY = layoutAdapter.positionY(for: targetPosition)
let velocityVector = (distance != 0) ? CGVector(dx: 0, dy: velocity.y/distance) : .zero
let animator = behavior.interactionAnimator(to: targetPosition, with: velocityVector)
animator.addAnimations { [weak self] in
guard let self = self else { return }
if self.state == targetPosition {
self.surfaceView.frame.origin.y = targetY
} else {
self.updateLayout(to: targetPosition)
}
self.state = targetPosition
}
animator.addCompletion { [weak self] pos in
guard let self = self else { return }
guard
self.interactionInProgress == false,
animator == self.animator,
pos == .end
else { return }
self.finishAnimation(at: targetPosition)
}
animator.startAnimation()
self.animator = animator
}
private func finishAnimation(at targetPosition: FloatingPanelPosition) {
log.debug("finishAnimation \(targetPosition)")
self.animator = nil
self.viewcontroller.delegate?.floatingPanelDidEndDecelerating(self.viewcontroller)
}
private func updateLayout(to target: FloatingPanelPosition?) {
self.layoutAdapter.activateLayout(of: target)
}
private func updateBackdropAlpha(with translation: CGPoint) -> CGFloat {
let topY = layoutAdapter.topY
let middleY = layoutAdapter.middleY
let currentY = getCurrentY(from: initialFrame, with: translation)
return (1 - (currentY - topY) / (middleY - topY)) * layoutAdapter.layout.backdropAlpha
}
// Animation handling
private func distance(to targetPosition: FloatingPanelPosition, with translation: CGPoint) -> CGFloat {
let topY = layoutAdapter.topY
let middleY = layoutAdapter.middleY
let bottomY = layoutAdapter.bottomY
let currentY = getCurrentY(from: initialFrame, with: translation)
switch targetPosition {
case .full:
return CGFloat(fabs(Double(currentY - topY)))
case .half:
return CGFloat(fabs(Double(currentY - middleY)))
case .tip:
return CGFloat(fabs(Double(currentY - bottomY)))
}
}
// 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) -> CGFloat {
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate)
}
private func targetPosition(with translation: CGPoint, velocity: CGPoint) -> (FloatingPanelPosition) {
let currentY = getCurrentY(from: initialFrame, with: translation)
let supportedPositions = Set(layoutAdapter.layout.supportedPositions)
assert(supportedPositions.count > 1)
switch supportedPositions {
case Set([.full, .half]):
return targetPosition(from: [.half, .tip], at: currentY, velocity: velocity)
case Set([.half, .tip]):
return targetPosition(from: [.half, .tip], at: currentY, velocity: velocity)
case Set([.full, .tip]):
return targetPosition(from: [.full, .tip], at: currentY, velocity: velocity)
default:
/*
[topY|full]---[th1]---[middleY|default]---[th2]---[bottomY|collapsed]
*/
let topY = layoutAdapter.topY
let middleY = layoutAdapter.middleY
let bottomY = layoutAdapter.bottomY
let th1 = (topY + middleY) / 2
let th2 = (middleY + bottomY) / 2
switch currentY {
case ..<th1:
if project(initialVelocity: velocity.y) >= (middleY - currentY) {
return .half
} else {
return .full
}
case ...middleY:
if project(initialVelocity: velocity.y) <= (topY - currentY) {
return .full
} else {
return .half
}
case ..<th2:
if project(initialVelocity: velocity.y) >= (bottomY - currentY) {
return .tip
} else {
return .half
}
default:
if project(initialVelocity: velocity.y) <= (middleY - currentY) {
return .half
} else {
return .tip
}
}
}
}
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 th = (topY + bottomY) / 2
switch currentY {
case ..<th:
if project(initialVelocity: velocity.y) >= (bottomY - currentY) {
return bottom
} else {
return top
}
default:
if project(initialVelocity: velocity.y) <= (topY - currentY) {
return top
} else {
return bottom
}
}
}
}