Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save steveswing/72194aca8ca5ea021321 to your computer and use it in GitHub Desktop.

Select an option

Save steveswing/72194aca8ca5ea021321 to your computer and use it in GitHub Desktop.

Revisions

  1. @andymatuschak andymatuschak created this gist Jan 26, 2015.
    341 changes: 341 additions & 0 deletions MultiDirectionAdjudicatingScrollView.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,341 @@
    //
    // 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 {}
    82 changes: 82 additions & 0 deletions UIScrollView+KHAExtensions.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,82 @@
    //
    // 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)
    }
    }