Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save an0/36cc28bdd260546cd6d0 to your computer and use it in GitHub Desktop.
Save an0/36cc28bdd260546cd6d0 to your computer and use it in GitHub Desktop.
//
// MultiDirectionAdjudicatingScrollView.swift
// Khan Academy
//
// Created by Andy Matuschak on 12/16/14.
// Copyright (c) 2014 Khan Academy. All rights reserved.
//
import UIKit
import UIKit.UIGestureRecognizerSubclass
/**
Add this gesture recognizer to the outermost scroll view in a view hierarchy including multiple nested scroll views to get a much more permissive scrolling behavior: panning while one scroll view is decelerating doesn't necessarily scroll that scroll view. Attempting to scroll an inner scroll view that can't scroll any further will devolve to outer scroll views.
Scroll views participating in this behavior must subclass from a MultiDirectionAdjudicatingScrollViewType (see below).
This class has no client-facing API: simply add the gesture recognizer, and it will create the behavior described above.
*/
public class MultiDirectionAdjudicatingGestureRecognizer: UIPanGestureRecognizer {
private enum RecognizedDirection: Printable {
case Left
case Right
case Up
case Down
var description: String {
switch self {
case .Left: return "Left"
case .Right: return "Right"
case .Up: return "Up"
case .Down: return "Down"
}
}
var isHorizontal: Bool {
switch self {
case .Left, .Right: return true
case .Up, .Down: return false
}
}
var isVertical: Bool {
switch self {
case .Left, .Right: return false
case .Up, .Down: return true
}
}
}
private enum DecelerationAxis: Printable {
case Vertical
case Horizontal
var description: String {
switch self {
case .Vertical: return "Vertical"
case .Horizontal: return "Horizontal"
}
}
}
private var scrollViews = [MultiDirectionAdjudicatingScrollViewType]()
private var activeScrollView: MultiDirectionAdjudicatingScrollViewType?
private var recognizedDirection: RecognizedDirection?
private var initialTouchScreenLocation: CGPoint?
/// If the user touches a scroll view that was decelerating, this property stores the axis of that decelerating scroll view; we'll bias towards this axis for adjudication.
private var caughtDecelerationAxis: DecelerationAxis?
public override func reset() {
super.reset()
scrollViews = []
activeScrollView = nil
recognizedDirection = nil
initialTouchScreenLocation = nil
caughtDecelerationAxis = nil
}
public override func touchesBegan(touches: Set<NSObject>!, withEvent event: UIEvent!) {
// Ignore all the touches except the first one which has hit a scroll view.
var touchesToIgnore = touches as! Set<UITouch>
if numberOfTouches() == 0 {
for touch in touches as! Set<UITouch> {
let hitTestView = view!.hitTest(touch.locationInView(view!), withEvent: event)!
var hitScrollViews = [MultiDirectionAdjudicatingScrollViewType]()
// Record all the scroll views in the hit hierarchy.
var currentView = hitTestView
while currentView != view!.superview {
if currentView is MultiDirectionAdjudicatingScrollViewType {
hitScrollViews.append(currentView as! MultiDirectionAdjudicatingScrollViewType)
}
currentView = currentView.superview!
}
if hitScrollViews.count > 0 {
initialTouchScreenLocation = view!.window!.convertPoint(touch.locationInView(nil), toWindow: nil)
scrollViews = hitScrollViews
for scrollView in scrollViews {
// Don't let any of them move until we decide which direction is "official."
scrollView.disableOffsetUpdates = true
if scrollView.decelerating {
if scrollView.canScrollHorizontally && !scrollView.canScrollVertically {
caughtDecelerationAxis = .Horizontal
} else if scrollView.canScrollVertically && !scrollView.canScrollHorizontally {
caughtDecelerationAxis = .Vertical
}
}
}
touchesToIgnore.remove(touch)
super.touchesBegan([touch], withEvent: event)
break
}
}
}
// If we're ignoring all the touches that just arrived, and we have no touches currently, that means we've failed to recognize.
if touchesToIgnore == touches {
state = .Failed
} else {
for touch in touchesToIgnore {
ignoreTouch(touch, forEvent: event)
}
}
}
public override func touchesMoved(touches: Set<NSObject>!, withEvent event: UIEvent!) {
super.touchesMoved(touches, withEvent: event)
if recognizedDirection == nil {
let currentTouch = touches.first as! UITouch // We only support one finger.
updateRecognizedDirectionEstimate(currentTouch)
}
if recognizedDirection != nil && activeScrollView == nil {
handleGestureBegan()
}
}
public override func touchesEnded(touches: Set<NSObject>!, withEvent event: UIEvent!) {
super.touchesEnded(touches, withEvent: event)
if state == .Ended {
handleGestureEnded()
}
}
public override func touchesCancelled(touches: Set<NSObject>!, withEvent event: UIEvent!) {
super.touchesCancelled(touches, withEvent: event)
if state == .Cancelled {
handleGestureEnded()
}
}
private func updateRecognizedDirectionEstimate(currentTouch: UITouch) {
// TODO(andy): Ideally, we'd perform this computation in interface-oriented screen space, but iOS 7 makes that really difficult, so we'll do it in view space.
let initialTouchViewLocation = view!.convertPoint(view!.window!.convertPoint(initialTouchScreenLocation!, fromWindow: nil), fromView: nil)
let currentTouchViewLocation = currentTouch.locationInView(view!)
var deltaVector = currentTouchViewLocation - initialTouchViewLocation
if abs(deltaVector) > MultiDirectionAdjudicatingGestureRecognizer.hysteresis {
if caughtDecelerationAxis == .Horizontal {
deltaVector.x *= MultiDirectionAdjudicatingGestureRecognizer.caughtDecelerationAxisBias
} else if caughtDecelerationAxis == .Vertical {
deltaVector.y *= MultiDirectionAdjudicatingGestureRecognizer.caughtDecelerationAxisBias
}
if abs(deltaVector.y) > abs(deltaVector.x) {
recognizedDirection = (deltaVector.y > 0) ? .Down : .Up
} else {
recognizedDirection = (deltaVector.x > 0) ? .Right : .Left
}
}
}
private func handleGestureBegan() {
let recognizedDirection = self.recognizedDirection!
// Determine which scroll view is active.
if recognizedDirection.isHorizontal {
let horizontalScrollViews = filter(scrollViews) { $0.canScrollHorizontally }
activeScrollView = horizontalScrollViews.last ?? scrollViews.last
for scrollView in horizontalScrollViews {
if recognizedDirection == .Left && scrollView.contentOffset.x < scrollView.maximumContentOffset.x {
activeScrollView = scrollView
break
}
if recognizedDirection == .Right && scrollView.contentOffset.x > scrollView.minimumContentOffset.x {
activeScrollView = scrollView
break
}
}
} else {
let verticalScrollViews = filter(scrollViews) { $0.canScrollVertically }
activeScrollView = verticalScrollViews.last ?? scrollViews.last
for scrollView in verticalScrollViews {
if recognizedDirection == .Up && scrollView.contentOffset.y < scrollView.maximumContentOffset.y {
activeScrollView = scrollView
break
}
if recognizedDirection == .Down && scrollView.contentOffset.y > scrollView.minimumContentOffset.y {
activeScrollView = scrollView
break
}
}
}
// Release the active scroll views for scrolling.
activeScrollView?.disableOffsetUpdates = false
}
private func handleGestureEnded() {
// Non-active scroll views must not decelerate--if we don't intervene, they'll inherit the current gesture velocity when we release.
for scrollView in scrollViews {
if scrollView !== activeScrollView {
// If the scroll view was supposed to land somewhere in particular, go there.
scrollView.disableOffsetUpdates = false
let overriddenDecelerationTargetOffset = scrollView.overriddenDecelerationTargetOffset()
if overriddenDecelerationTargetOffset == scrollView.contentOffset {
scrollView.stopDecelerating()
scrollView.stopRubberbanding()
} else {
UIView.animateWithDuration(0.3, delay: 0.0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.0, options: .AllowUserInteraction, animations: {
scrollView.contentOffset = overriddenDecelerationTargetOffset
}, completion: nil)
}
scrollView.disableOffsetUpdates = true
}
}
}
/// The distance the user must move their finger (in screen space) before we try to estimate the direction of scrolling.
private static let hysteresis: CGFloat = 15
/// If the user touches a scroll view that's decelerating, we'll scale their movement along the deceleration axis by this factor to make it easier to continue in that axis.
private static let caughtDecelerationAxisBias: CGFloat = 1.7 // Comes out to a 60 degree window instead of a 45 degree one.
}
class MultiDirectionAdjudicatingScrollView: UIScrollView, UIGestureRecognizerDelegate {
private var disableOffsetUpdates: Bool = false
override var bounds: CGRect {
get {
return super.bounds
}
set {
if !(disableOffsetUpdates && (dragging || decelerating)) {
super.bounds = newValue
} else {
super.bounds = CGRect(origin: self.bounds.origin, size: newValue.size)
}
}
}
func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
private func overriddenDecelerationTargetOffset() -> CGPoint {
if let scrollViewWillEndDragging = delegate?.scrollViewWillEndDragging {
var targetOffset = contentOffset
scrollViewWillEndDragging(self, withVelocity: CGPoint.zeroPoint, targetContentOffset: &targetOffset)
return targetOffset
} else {
return contentOffset
}
}
}
// This is a single-inheritance OO language with no trait-like feature, so we're stuck repeating this:
class MultiDirectionAdjudicatingCollectionView: UICollectionView, UIGestureRecognizerDelegate {
private var disableOffsetUpdates: Bool = false
override var bounds: CGRect {
get {
return super.bounds
}
set {
if !(disableOffsetUpdates && (dragging || decelerating)) {
super.bounds = newValue
} else {
super.bounds = CGRect(origin: self.bounds.origin, size: newValue.size)
}
}
}
func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
private func overriddenDecelerationTargetOffset() -> CGPoint {
if let scrollViewWillEndDragging = delegate?.scrollViewWillEndDragging {
var targetOffset = contentOffset
scrollViewWillEndDragging(self, withVelocity: CGPoint.zeroPoint, targetContentOffset: &targetOffset)
return targetOffset
} else {
return contentOffset
}
}
}
// This protocol is used so that the adjudicating gesture recognizer can work with both scroll views and collection views.
private protocol MultiDirectionAdjudicatingScrollViewType: class {
var disableOffsetUpdates: Bool { get set }
var bounds: CGRect { get }
@objc var decelerating: Bool { @objc(isDecelerating) get }
var canScrollHorizontally: Bool { get }
var canScrollVertically: Bool { get }
var contentOffset: CGPoint { get set }
var minimumContentOffset: CGPoint { get }
var maximumContentOffset: CGPoint { get }
var contentSize: CGSize { get }
func stopDecelerating()
func stopRubberbanding()
/// Returns a delegate-overridden deceleration target (assuming zero velocity). Returns the current content offset if the delegate doesn't exist or doesn't implement that method.
/// I'd love to use an optional CGPoint for that instead, but this has to be an @objc protocol (to make an array of instances of this protocol above), and that's not allowed.
func overriddenDecelerationTargetOffset() -> CGPoint
}
extension MultiDirectionAdjudicatingScrollView: MultiDirectionAdjudicatingScrollViewType {}
extension MultiDirectionAdjudicatingCollectionView: MultiDirectionAdjudicatingScrollViewType {}
func -(a: CGPoint, b: CGPoint) -> CGPoint {
return CGPoint(x: a.x - b.x, y: a.y - b.y)
}
func abs(a: CGPoint) -> CGFloat {
return sqrt(a.x * a.x + a.y * a.y)
}
//
// UIScrollView+KHAExtensions.swift
// Khan Academy
//
// Created by Andy Matuschak on 12/5/14.
// Copyright (c) 2014 Khan Academy. All rights reserved.
//
import UIKit
extension UIScrollView {
/// The minimum value of contentOffset before rubber banding.
public var minimumContentOffset: CGPoint {
return CGPoint(
x: -contentInset.left,
y: -contentInset.top
)
}
/// The maximum value of contentOffset before rubber banding.
public var maximumContentOffset: CGPoint {
return CGPoint(
x: max(0, contentSize.width + contentInset.right - bounds.size.width),
y: max(0, contentSize.height + contentInset.bottom - bounds.size.height)
)
}
/// Returns the closest offset to the argument that would not cause rubberbanding.
public func clipOffset(var offset: CGPoint) -> CGPoint {
offset.x = min(max(offset.x, minimumContentOffset.x), maximumContentOffset.x)
offset.y = min(max(offset.y, minimumContentOffset.y), maximumContentOffset.y)
return offset
}
/// Immediately halts deceleration if it is occurring.
public func stopDecelerating() {
// This is kind of a hack, but UIScrollView does an "is equal" check and returns immediately if you try to set the content offset to be the same thing. But if you change the content offset, deceleration stops.
var offset = contentOffset
offset.x -= 1.0
offset.y -= 1.0
contentOffset = offset
offset.x += 1.0;
offset.y += 1.0;
contentOffset = offset
}
/// If the scroll view was outside its content bounds, animates to the nearest in-bounds point.
public func stopRubberbanding() {
let clippedOffset = clipOffset(contentOffset)
if contentOffset != clippedOffset {
UIView.animateWithDuration(0.3, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.0, options: .AllowUserInteraction, animations: {
self.contentOffset = clippedOffset
}, completion: nil)
}
}
public var canScrollHorizontally: Bool {
let horizontallyOverflows = contentSize.width + contentInset.left + contentInset.right > bounds.size.width
return horizontallyOverflows || alwaysBounceHorizontal
}
public var canScrollVertically: Bool {
let verticallyOverflows = contentSize.height + contentInset.top + contentInset.bottom > bounds.size.height
return verticallyOverflows || alwaysBounceVertical
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment