Created
January 26, 2015 19:31
-
-
Save andymatuschak/5b4165d278c265cd5a35 to your computer and use it in GitHub Desktop.
Source for the Khan Academy app's unusual scrolling interactions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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 horizontalScrollViews: [MultiDirectionAdjudicatingScrollViewType] { | |
return filter(scrollViews) { $0.scrollsOnlyHorizontally } | |
} | |
private var verticalScrollViews: [MultiDirectionAdjudicatingScrollViewType] { | |
return filter(scrollViews) { $0.scrollsOnlyVertically } | |
} | |
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 = [] | |
recognizedDirection = nil | |
initialTouchScreenLocation = nil | |
activeScrollView = nil | |
} | |
public override func touchesBegan(touches: NSSet!, withEvent event: UIEvent!) { | |
// Ignore all the touches except the first one which has hit a scroll view. | |
let touchesToIgnore: NSMutableSet = NSMutableSet(set: touches) | |
if self.numberOfTouches() == 0 { | |
for touch in touches { | |
let touch = touch as UITouch | |
let hitTestView = view!.hitTest(touch.locationInView(view!), withEvent: nil)! | |
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.scrollsOnlyHorizontally { | |
caughtDecelerationAxis = .Horizontal | |
} else if scrollView.scrollsOnlyVertically { | |
caughtDecelerationAxis = .Vertical | |
} | |
} | |
} | |
touchesToIgnore.removeObject(touch) | |
super.touchesBegan(NSSet(object: 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 as UITouch, forEvent: event) | |
} | |
} | |
} | |
public override func touchesMoved(touches: NSSet!, withEvent event: UIEvent!) { | |
super.touchesMoved(touches, withEvent: event) | |
if recognizedDirection == nil { | |
let currentTouch = touches.anyObject() as UITouch // We only support one finger. | |
updateRecognizedDirectionEstimate(currentTouch) | |
} | |
if recognizedDirection != nil && activeScrollView == nil { | |
handleGestureBegan() | |
} | |
} | |
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!) | |
if kha_CGPointDistance(initialTouchViewLocation, currentTouchViewLocation) > MultiDirectionAdjudicatingGestureRecognizer.hysteresis { | |
var deltaVector = CGPoint(x: currentTouchViewLocation.x - initialTouchViewLocation.x, y: currentTouchViewLocation.y - initialTouchViewLocation.y) | |
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 { | |
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 { | |
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 | |
} | |
} | |
} | |
// Prevent all the non-active scroll views from moving. | |
for scrollView in scrollViews { | |
if scrollView !== activeScrollView { | |
scrollView.stopRubberbanding() | |
} | |
} | |
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 | |
} | |
} | |
} | |
public override func touchesEnded(touches: NSSet!, withEvent event: UIEvent!) { | |
super.touchesEnded(touches, withEvent: event) | |
if state == .Ended { | |
handleGestureEnded() | |
} | |
} | |
public override func touchesCancelled(touches: NSSet!, withEvent event: UIEvent!) { | |
super.touchesCancelled(touches, withEvent: event) | |
if state == .Cancelled { | |
handleGestureEnded() | |
} | |
} | |
/// The distance the user must move their finger (in screen space) before we try to estimate the direction of scrolling. | |
private class var hysteresis: CGFloat { | |
return 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 class var caughtDecelerationAxisBias: CGFloat { | |
// Comes out to a 60 degree window instead of a 45 degree one. | |
return 1.7 | |
} | |
} | |
class MultiDirectionAdjudicatingScrollView: UIScrollView { | |
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) | |
} | |
} | |
} | |
@objc private 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(), 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 { | |
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) | |
} | |
} | |
} | |
@objc private 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(), 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. | |
@objc private protocol MultiDirectionAdjudicatingScrollViewType: class { | |
var disableOffsetUpdates: Bool { get set } | |
var bounds: CGRect { get } | |
@objc var decelerating: Bool { @objc(isDecelerating) get } | |
var scrollsOnlyHorizontally: Bool { get } | |
var scrollsOnlyVertically: 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 {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// UIScrollView+KHAExtensions.swift | |
// Khan Academy | |
// | |
// Created by Andy Matuschak on 12/5/14. | |
// Copyright (c) 2014 Khan Academy. All rights reserved. | |
// | |
import Foundation | |
extension UIScrollView { | |
/// Used for iTunes Store-style paging-ish scroll behavior: the scroll views can move freely between many items, but when you let go, it'll land on item boundaries in a visually pleasant way. | |
/// Can be used in either dimension; works in terms of CGFloats instead of CGPoints. | |
public class func retargetContentOffset(offset: CGFloat, toBoundaryOfItemsWithSize itemSize: CGFloat, boundsSize: CGFloat, contentSize: CGFloat, velocity: CGFloat) -> CGFloat { | |
// If we're already in the bounds-sized page of the scroll view, we shouldn't even try to retarget: we've got no room. | |
if offset >= contentSize - boundsSize { | |
return offset | |
} else { | |
// Bias in the direction of motion | |
let roundingFunction: CGFloat -> CGFloat = velocity == 0 ? round : velocity > 0 ? ceil : floor | |
// Round to item boundary | |
let roundedOffset = roundingFunction(offset / itemSize) * itemSize | |
// But don't let it overflow the scroll view content area | |
return min(roundedOffset, max(0, contentSize - boundsSize)) | |
} | |
} | |
/// 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 scrollsOnlyHorizontally: Bool { | |
let horizontallyOverflows = contentSize.height <= bounds.size.height && contentSize.width > bounds.size.width | |
return horizontallyOverflows || (!horizontallyOverflows && alwaysBounceHorizontal && !alwaysBounceVertical) | |
} | |
public var scrollsOnlyVertically: Bool { | |
let verticallyOverflows = contentSize.width <= bounds.size.width && contentSize.height > bounds.size.height | |
return verticallyOverflows || (!verticallyOverflows && !alwaysBounceHorizontal && alwaysBounceVertical) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment