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 cipherCOM/e13d63cb64eecb50bcebd2ef46f57ed8 to your computer and use it in GitHub Desktop.
Save cipherCOM/e13d63cb64eecb50bcebd2ef46f57ed8 to your computer and use it in GitHub Desktop.
Source for the Khan Academy app's unusual scrolling interactions
//
// 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 {}
//
// 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