Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Increasing UIScrollView rubber banding resistance

I've got a scenario I'm trying to implement in my app where I'd like to make a UIScrollView behave more 'stiff' once you start forcing it to scroll past its normal content bounds.

What I mean by this, is when you're either at the top or bottom of a scroll view, if you tap down and keep dragging, you can usually get the scroll view to keep scroling beyond its bounds, but it gradually builds up resistance, until it stops usually about half way in the middle of the view's bounds. When you lift your finger, it snaps back to the bounds of the scroll region.

What I'm trying to achieve, is I'd like to make that 'out-of-bounds' dragging effect a lot more heavy, so instead of the user dragging the scroll view and it 'bottoming-out' mid way through the scroll view bounds, it completely stops around 20% or so past its scrolling bounds instead.

I've been experimenting with overriding the scroll view's contentOffset inside the scrollViewDidScroll: delegate method, but that doesn't seem to work since re-setting the contentOffset in there seems to screw up further delegate calls of the same method.

My next idea was to monitor the UIPanGestureRecognizer associated with the scrollview and try and determine the proper UIScrollView contentOffset based off events coming out of that. That being said, I thought that might start getting on the hacky side, so I thought I'd ask here for any other solutions I hadn't considered before I try something that could potentially be messy.

Thanks!

like image 365
TiM Avatar asked Dec 07 '13 04:12

TiM


2 Answers

I am spiking on the following code in Swift. This is for horizontal scrolling and can easily be adapted to vertical one. Solutions differ depending on whether paging is enabled or not. Both are given below.

class ScrollViewDelegate : NSObject, UIScrollViewDelegate
{
    let maxOffset: CGFloat  // offset of the rightmost content (scrollview contentSize.width - frame.width)
    var prevOffset: CGFloat = 0  // previous offset (after adjusting the value)

    var totalDistance: CGFloat = 0  // total distance it would have moved (had we not restricted)
    let reductionFactor: CGFloat = 0.2  // percent of total distance it will be allowed to move (under restriction)
    let scaleFactor: CGFloat = UIScreen.mainScreen().scale  // pixels per point, for smooth translation in respective devices

    init(maxOffset: CGFloat)
    {
        self.maxOffset = maxOffset  // scrollView.contentSize.width - scrollView.frame.size.width
    }

    func scrollViewDidScroll(scrollView: UIScrollView)
    {
        let flipped = scrollView.contentOffset.x >= maxOffset  // dealing with left edge or right edge rubber band
        let currentOffset = flipped ? maxOffset - scrollView.contentOffset.x : scrollView.contentOffset.x  // for right edge, flip the values as if screen is folded in half towards the left

        if(currentOffset <= 0)  // if dragging/moving beyond the edge
        {
            if(currentOffset <= prevOffset)  // if dragging/moving beyond previous offset
            {
                totalDistance += currentOffset - prevOffset  // add the "proposed delta" move to total distance
                prevOffset = round(scaleFactor * totalDistance * reductionFactor) / scaleFactor  // set the prevOffset to fraction of total distance
                scrollView.contentOffset.x = flipped ? maxOffset - prevOffset : prevOffset  // set the target offset, after negating any flipping
            }
            else  // if dragging/moving is reversed, though still beyond the edge
            {
                totalDistance = currentOffset / reductionFactor  // set totalDistance from offset (reverse of prevOffset calculation above)
                prevOffset = currentOffset  // set prevOffset
            }
        }
        else  // if dragging/moving inside the edge
        {
            totalDistance = 0  // reset the values
            prevOffset = 0
        }
    }
}

When paging is enabled, the bounce back to the resting point doesn't seem to work as expected. Instead of stopping at the page boundary, the rubber band is overshooting it and halting at a non-page offset. This happens if the pull from the edge is with a fast flick such that it continues to move in that direction even after you lift the finger, before reversing the direction and coming back to the resting point. It seems to work fine if you simply pause and leave or even flick it back towards the resting point. To address this, in the below code I try to identify the possibility of overshooting and forcefully stop it while it returns and attempts to cross the expected page boundary.

class PageScrollViewDelegate : NSObject, UIScrollViewDelegate
{
    let maxOffset: CGFloat  // offset of the rightmost content (scrollview contentSize.width - frame.width)
    var prevOffset: CGFloat = 0  // previous offset (after adjusting the value)

    var totalDistance: CGFloat = 0  // total distance it would have moved (had we not restricted)
    let reductionFactor: CGFloat = 0.2  // percent of total distance it will be allowed to move (under restriction)
    let scaleFactor: CGFloat = UIScreen.mainScreen().scale  // pixels per point, for smooth translation in respective devices

    var draggingOver: Bool = false  // finger dragging is over or not
    var overshoot: Bool = false  // is there a chance for page to overshoot page boundary while falling back

    init(maxOffset: CGFloat)
    {
        self.maxOffset = maxOffset  // scrollView.contentSize.width - scrollView.frame.size.width
    }

    func scrollViewWillBeginDragging(scrollView: UIScrollView)
    {
        draggingOver = false  // reset the flags
        overshoot = false
    }

    func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool)
    {
        draggingOver = true  // finger dragging is over
    }

    func scrollViewDidScroll(scrollView: UIScrollView)
    {
        let flipped = scrollView.contentOffset.x >= 0.5 * maxOffset // dealing with left edge or right edge rubber band
        let currentOffset = flipped ? maxOffset - scrollView.contentOffset.x : scrollView.contentOffset.x  // for right edge, flip the values as if screen is folded in half towards the left

        if(currentOffset <= 0)  // if dragging/moving beyond the edge
        {
            if(currentOffset <= prevOffset)  // if dragging/moving beyond previous offset
            {
                overshoot = draggingOver  // is content moving farther away even after dragging is over (caused by fast flick, which can cause overshooting page boundary while falling back)

                totalDistance += currentOffset - prevOffset  // add the "proposed delta" move to total distance
                prevOffset = round(scaleFactor * totalDistance * reductionFactor) / scaleFactor  // set the prevOffset to fraction of total distance
                scrollView.contentOffset.x = flipped ? maxOffset - prevOffset : prevOffset  // set the target offset, after negating any flipping
            }
            else  // if dragging/moving is reversed, though still beyond the edge
            {
                totalDistance = currentOffset / reductionFactor  // set totalDistance from offset (reverse of prevOffset calculation above)
                prevOffset = currentOffset  // set prevOffset
            }
        }
        else  // if dragging/moving inside the edge
        {
            if(overshoot)  // if this movement is a result of overshooting
            {
                scrollView.setContentOffset(CGPointMake(flipped ? maxOffset : 0, scrollView.contentOffset.y), animated: false)  // bring it to resting point and stop further scrolling (this is a patch to control overshooting)
            }

            totalDistance = 0  // reset the values
            prevOffset = 0
        }
    }
}
like image 136
programmist Avatar answered Sep 20 '22 11:09

programmist


This isn't going to be easy.

If this is just a one-off scroll view, I might suggest using UIKit Dynamics with some push, attachment, and spring behaviors to get exactly the effect you want.

If you're looking to do this for every table view in your app I think watching the pan gesture recognizer is a reasonable enough approach. Just off the top of my head I would observe the gesture's state, when it ends I would capture the vertical velocity of the view, use the UIScrollView method to calculate it's stopping position and then animate it from its current position to its resting position with a spring animation. You'll have to calculate the duration yourself using the ending velocity of the pan and the remaining distance + the overshoot amount.

like image 41
Mark Adams Avatar answered Sep 21 '22 11:09

Mark Adams