Increasing UIScrollView rubber banding resistance - ios

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!

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
}
}
}

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.

I have made a GitHub project that handle this case
Bounce Scroll View
You can control the resistance of the scrolling through it

This Code will stop the scroll at the margin you want
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
CGFloat margin = scrollView.frame.size.height/4.0;
if (scrollView.contentOffset.y < -margin) {
CGPoint offset = scrollView.contentOffset;
offset.y = -margin;
scrollView.contentOffset = offset;
} else if (scrollView.contentOffset.y > (scrollView.contentSize.height-scrollView.frame.size.height) + margin) {
CGPoint offset = scrollView.contentOffset;
offset.y = (scrollView.contentSize.height-scrollView.frame.size.height) + margin;
scrollView.contentOffset = offset;
}
}
Hope it helps

Related

Edit the alpha of navigation bar when scrolls tableview

I wanna ask if its possible to change the alpha value of a navigation bar when the user is scrolling a tableview.
I have the algorithm, but i need something help to get changes on real time.
/* This is the offset at the bottom of the scroll view. */
CGFloat totalScroll = scrollView.contentSize.height - scrollView.bounds.size.height;
/* This is the current offset. */
CGFloat offset = - scrollView.contentOffset.y;
/* This is the percentage of the current offset / bottom offset. */
CGFloat percentage = offset / totalScroll;
/* When percentage = 0, the alpha should be 1 so we should flip the percentage. */
scrollView.alpha = (1.f - percentage);
It's probably too late, but for future reference, you could do something like this:
func scrollViewDidScroll(scrollView: UIScrollView) {
self.navigationController!.navigationBar.alpha = 1 - (self.tableView.contentOffset.y / (self.tableView.contentSize.height - self.tableView.frame.size.height));
}
On this delegate you have the values of contentOffset of scrollview or tableView, and you can observe the value of this property to make the behavior you desire.
Hope it helps!

How to change alpha value along with scrolling

I could not found any where for this kind sticky issue.My issue is when the user started scrolling i need to change the alpha value.At starting of scrolling alpha value should be 1, then at middle of scrolling alpha value should be 0.5, at end it must be 0.This what i need to do.i could not find with googling. help me plz
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
/* This is the offset at the bottom of the scroll view. */
CGFloat totalScroll = scrollView.contentSize.height - scrollView.bounds.size.height;
/* This is the current offset. */
CGFloat offset = - scrollView.contentOffset.y;
/* This is the percentage of the current offset / bottom offset. */
CGFloat percentage = offset / totalScroll;
/* When percentage = 0, the alpha should be 1 so we should flip the percentage. */
scrollView.alpha = (1.f - percentage);
}
Here is how to do it in Swift, i have 2 UILabel in my controller, and i need to increase alpha for the first UILabel and decrease alpha for the second :
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if (scrollView.contentOffset.y >= (scrollView.contentSize.height - scrollView.frame.size.height)) {
// Bottom
}
if (scrollView.contentOffset.y < 0){
// Top
lblTitleHeader.alpha = 0
lblTitleBody.alpha = 1
}
if (scrollView.contentOffset.y >= 0 && scrollView.contentOffset.y < (scrollView.contentSize.height - scrollView.frame.size.height)){
// Middle
let percentage: CGFloat = (scrollView.contentOffset.y) / 20
// This label loses alpha when you scroll down (or slide up)
lblTitleHeader.alpha = (percentage)
// This label gets more alpha when you scroll up (or slide down)
lblTitleBody.alpha = (1 - percentage)
}
}

Changing the UITableView height based on scroll in iOS

I want to change the height of my UITableView based on the scrolled content. Right now I do it by getting the scrollViewDidScroll event and then getting scrolled value and then change the height. Here is my code (for simplification I omitted the irrelevant code):
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
float currentOffset = scrollView.contentOffset.y
double delta = currentOffset - storedOffset;
if(delta != 0)
{
delta = abs(delta);
CGRect newListFrame = myTableView.frame;
float newListHeight = newListFrame.size.height + delta;
newListFrame.size.height = newListHeight;
myTableView.frame = newListFrame;
}
storedOffset = currentOffset;
}
But this approach is wrong because with this approach my UITableView's content is scrolled only a little bit and that's not what I want. I just want to get the value of that list that would be scrolled without actually scrolling it. Is there any way to do that? I thing I could get raw finger moved event but can I get it on UITableVIew? Can I do something like this using a UITableView method?

UIView animation with UIPanGestureRecognizer velocity way too fast (not decelerating)

Update: Though I'd still like to solve this, I ended up switching to animateWithDuration:delay:options:animations:completion: and it works much nicer. It's missing that nice "bounce" at the end that the spring affords, but at least it's controllable.
I am trying to create a nice gesture-driven UI for iOS but am running into some difficulties getting the values to result in a nice natural feeling app.
I am using animateWithDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion: because I like the bouncy spring animation. I am initializing the velocity argument with the velocity as given by the gesture recognizer in the completed state. The problem is if I pan quickly enough and let go, the velocity is in the thousands, and my view ends up flying right off the screen and then bouncing back and forth with such dizzying vengeance.
I'm even adjusting the duration of the animation relative to the amount of distance the view needs to move, so that if there are only a few pixels needed, the animation will take less time. That, however, didn't solve the issue. It still ends up going nuts.
What I want to happen is the view should start out at whatever velocity the user is dragging it at, but it should quickly decelerate when reaching the target point and only bounce a little bit at the end (as it does if the velocity is something reasonable).
I wonder if I am using this method or the values correctly. Here is some code to show what I'm doing. Any help would be appreciated!
- (void)handlePanGesture:(UIPanGestureRecognizer*)gesture {
CGPoint offset = [gesture translationInView:self.view];
CGPoint velocity = [gesture velocityInView:self.view];
NSLog(#"pan gesture state: %d, offset: %f velocity: %f", gesture.state, offset.x, velocity.x);
static CGFloat initialX = 0;
switch ( gesture.state ) {
case UIGestureRecognizerStateBegan: {
initialX = self.blurView.x;
break; }
case UIGestureRecognizerStateChanged: {
self.blurView.x = initialX + offset.x;
break; }
default:
case UIGestureRecognizerStateCancelled:
case UIGestureRecognizerStateEnded: {
if ( velocity.x > 0 )
[self openMenuWithVelocity:velocity.x];
else
[self closeMenuWithVelocity:velocity.x];
break; }
}
}
- (void)openMenuWithVelocity:(CGFloat)velocity {
if ( velocity < 0 )
velocity = 1.5f;
CGFloat distance = -40 - self.blurView.x;
CGFloat distanceRatio = distance / 260;
NSLog(#"distance: %f ratio: %f", distance, distanceRatio);
[UIView animateWithDuration:(0.9f * distanceRatio) delay:0 usingSpringWithDamping:0.7 initialSpringVelocity:velocity options:UIViewAnimationOptionBeginFromCurrentState animations:^{
self.blurView.x = -40;
} completion:^(BOOL finished) {
self.isMenuOpen = YES;
}];
}
Came across this post while looking for a solution to a related issue. The problem is, you're passing in the velocity from UIPanGestureRecognizer, which is in points/second, when - animateWithDuration:delay:usingSpringWithDamping:initialSpringVelocity:options:animations:completion wants… a slightly odder value:
The initial spring velocity. For smooth start to the animation, match this value to the view’s velocity as it was prior to attachment.
A value of 1 corresponds to the total animation distance traversed in one second. For example, if the total animation distance is 200 points and you want the start of the animation to match a view velocity of 100 pt/s, use a value of 0.5.
The animation method wants velocity in "distances" per second, not points per second. So, the value you should be passing in is (velocity from the gesture recognizer) / (total distance traveled during the animation).
That said, that's exactly what I'm doing and there's still a slight, yet noticeable "hiccup" between when the gesture recognizer is moving it, and when the animation picks up. That said, it should still work a lot better than what you had before. 😃
First you need to calculate the remaining distance that the animation will have to take care of. When you have the delta distance you can proceed to calculate the velocity like this:
CGFloat springVelocity = fabs(gestureRecognizerVelocity / distanceToAnimate);
For clean velocity transfer you must use UIViewAnimationOptionCurveLinear.
I had a slightly different need, but my code may help, namely the velocity calculation, based on (pan velocity / pan translation).
A bit of context: I needed to use a panGestureRecognizer on the side of a UIView to resize it.
If the iPad is in portrait mode, the view is attached on the left, bottom
and right sides, and I drag on the top border of the view to resize
it.
If the iPad is in landscape mode, the view is attached to the
left, top and bottom sides, and I drag on the right border to resize it.
This is what I used in the IBAction for the UIPanGestureRecognizer:
var velocity: CGFloat = 1
switch gesture.state {
case .changed:
// Adjust the resizableView size according to the gesture translation
let translation = gesture.translation(in: resizableView)
let panVelocity = gesture.velocity(in: resizableView)
if isPortrait { // defined previously in the class based on UIDevice orientation
let newHeight = resizableViewHeightConstraint.constant + (-translation.y)
resizableViewHeightConstraint.constant = newHeight
// UIView animation initialSpringVelocity requires a velocity based on the total distance traveled during the animation
velocity = -panVelocity.y / -panTranslation.y
} else { // Landscape
let newWidth = resizableViewWidthConstraint.constant + (translation.x)
// Limit the resizing to half the width on the left and the full width on the right
resizableViewWidthConstraint.constant = min(max(resizableViewInitialSize, newWidth), self.view.bounds.width)
// UIView animation initialSpringVelocity requires a velocity based on the total distance traveled during the animation
velocity = panVelocity.x / panTranslation.x
}
UIView.animate(withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 1,
initialSpringVelocity: velocity,
options: [.curveEaseInOut],
animations: {
self.view.layoutIfNeeded()
},
completion: nil)
// Reset translation
gesture.setTranslation(CGPoint.zero, in: resizableView)
}
Hope that helps.

Resizing UITableView as seen in Rdio iOS app

Im attempting to replicate the resizing behaviour of a screen seen in the iOS app Rdio. The screen in question provides an overview of a selected album and contains a UIView on the top half and a UITableView on the bottom half. When the tableView is scrolled, it first resizes upwards to fill the screen, then begins to scroll through its content normally once the maximum height is reached.
After some searching I found this question: Dragging UITableView which is basically asking for the same thing, however its accepted method is the same as my initial thoughts & trial, which was to use a UIPanGestureRecognizer and resize the tableviews height according to the translation of the pan.
This does not provide the behaviour i'm looking for. Using this method only allows you to statically drag the tableviews height up or down and it has the added issue of the panGesture overriding that of the tableViews which then prevents scrolling through the content.
The resizing behaviour of the Rdio app functions and feels exactly like a UIScrollView, it has inertia. You can drag it all the way, flick it up or down, and it smoothly resizes. When the tableView has reached its full-size or original half-size, the remaining inertia is seemingly passed on the tableview causing the cells to scroll as they normally would for that amount. I know they must be manipulating UIScrollViews, I just can't figure out how.
As a final note, eventually I will be using AutoLayout on this screen so i'm wondering how that will potentially hinder or help this situation as well.
Update
This approach has gotten me closest to the behaviour i'm looking for so far.
Flicking the tableView upwards behaves exactly like I wanted it to (resize with inertia & continue scrolling when max height is reached), although with less sensitivity than i'd like. Flicking downwards however, provides no inertia and instantly stops.
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
CGRect scrollViewFrame = scrollView.frame;
CGFloat scrollViewYOffset = scrollView.contentOffset.y;
if (scrollViewFrame.origin.y <= kTableViewMaxYOrigin || scrollViewFrame.origin.y <= _originalTableViewFrame.origin.y)
{
scrollViewFrame.origin.y -= scrollViewYOffset;
if(scrollViewFrame.origin.y >= kTableViewMaxYOrigin && scrollViewFrame.origin.y <= _originalTableViewFrame.origin.y)
{
scrollViewFrame.size.height += scrollViewYOffset;
scrollView.frame = scrollViewFrame;
scrollView.contentOffset = CGPointZero;
}
}
}
I made a version that uses autolayout instead.
It took me a while of trial and error to get this one right!
Please use the comments and ask me if the answer is unclear.
In viewDidLoad save the initial height of your layout constraint determining the lowest down you want the scrollview to be.
- (void)viewDidLoad
{
...
_initialTopLayoutConstraintHeight = self.topLayoutConstraint.constant;
...
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
BOOL leaveScrollAlone = self.topLayoutConstraint.constant == _initialTopLayoutConstraintHeight && scrollView.contentOffset.y <= 0;
if (leaveScrollAlone)
{
// This allows for bounce when swiping your finger downwards and reaching the stopping point
return;
}
// Do some capping of that layout constraint to keep it from going past the range you want it to be.
// In this case, I use self.topLayoutGuide.length so that my UICollectionView scales all the way until
// it hits the bottom of the navigation bar
CGFloat topLayoutConstraintLength = _initialTopLayoutConstraintHeight - scrollView.contentInset.top;
topLayoutConstraintLength = MAX(topLayoutConstraintLength, self.topLayoutGuide.length);
topLayoutConstraintLength = MIN(topLayoutConstraintLength, _initialTopLayoutConstraintHeight);
self.topLayoutConstraint.constant = topLayoutConstraintLength;
// Keep content seemingly still while the UICollectionView resizes
if (topLayoutConstraintLength > self.topLayoutGuide.length)
{
scrollView.contentInset = UIEdgeInsetsMake(scrollView.contentInset.top + scrollView.contentOffset.y,
scrollView.contentInset.left,
scrollView.contentInset.bottom,
scrollView.contentInset.right);
scrollView.contentOffset = CGPointZero;
}
// This helps get rid of the extraneous contentInset.top we accumulated for keeping
// the content static while the UICollectionView resizes
if (scrollView.contentOffset.y < 0)
{
self.topLayoutConstraint.constant -= scrollView.contentOffset.y;
scrollView.contentInset = UIEdgeInsetsMake(scrollView.contentInset.top + scrollView.contentOffset.y,
scrollView.contentInset.left,
scrollView.contentInset.bottom,
scrollView.contentInset.right);
}
// Prevents strange jittery artifacts
[self.view layoutIfNeeded];
}
It turns out the key component to getting the smooth inertial resizing in both directions was to update the scrollViews contentInset.top by its contentOffset.y.
I believe this makes sense in retrospect as if the content within is already at the top it cannot scroll anymore, hence the sudden stop rather than smooth scroll. At least thats my understanding.
Another key point was to make sure the cells only started scrolling once maximum or original height was achieved. This was done simply by setting the scrollViews contentOffset to CGPointZero each time the view resized until maximum or original height was reached.
Here is the - (void)scrollViewDidScroll:(UIScrollView *)scrollView method demonstrating how to achieve this effect.
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
CGRect scrollViewFrame = scrollView.frame;
CGFloat scrollViewTopContentInset = scrollView.contentInset.top;
CGFloat scrollViewYOffset = scrollView.contentOffset.y;
if (scrollViewFrame.origin.y <= kTableViewMaxYOrigin || scrollViewFrame.origin.y <= _originalTableViewFrame.origin.y)
{
scrollViewFrame.origin.y -= scrollViewYOffset;
if(scrollViewFrame.origin.y >= kTableViewMaxYOrigin && scrollViewFrame.origin.y <= _originalTableViewFrame.origin.y)
{
scrollViewFrame.size.height += scrollViewYOffset;
scrollViewTopContentInset += scrollViewYOffset;
scrollView.frame = scrollViewFrame;
scrollView.contentInset = UIEdgeInsetsMake(scrollViewTopContentInset, 0, 0, 0);
scrollView.contentOffset = CGPointZero;
}
}
}
I haven't seen the app in question, but from your description... in your tableView's delegate method -scrollViewDidScroll:, set tableView.frame.origin.y to albumView.frame.height - tableView.contentOffset.y and change its height accordingly.
(If you're using autolayout, you'll have to change the constraints pertaining to the tableView's frame rather than the frame itself.)

Resources