UIScrollView's Inertia, Bouncing and Rubber-Banding with UIKit Dynamics
Two months ago Ole Begemann wrote a great article about rebuilding UIScrollView
from scratch: Understanding UIScrollView. A couple of days later Rounak Jain and Grant Paul added inertial scrolling, bouncing and rubber-banding to it using Facebook's Pop framework. Since then, I've wanted to recreate the same effect with UIKit Dynamics and have finally done that this week.
Animating bounds with UIKit Dynamics
Objects animatable with UIKit Dynamics have to conform to the UIDynamicItem
protocol:
@protocol UIDynamicItem <NSObject>
@property (nonatomic, readwrite) CGPoint center;
@property (nonatomic, readonly) CGRect bounds;
@property (nonatomic, readwrite) CGAffineTransform transform;
@end
Dynamics uses center
and transform
properties to move items based on its internal algorithm1, and bounds
property to compute collisions. It follows that we can't use Dynamics directly on the scroll view to animate its bounds
. UIDynamicItem
is a protocol, though, so we can create a plain old NSObject
subclass conforming to it:
@interface CSCDynamicItem : NSObject <UIDynamicItem>
@property (nonatomic, readwrite) CGPoint center;
@property (nonatomic, readonly) CGRect bounds;
@property (nonatomic, readwrite) CGAffineTransform transform;
@end
@implementation CSCDynamicItem
- (instancetype)init {
self = [super init];
if (self) {
// Sets non-zero `bounds`, because otherwise Dynamics throws an exception.
_bounds = CGRectMake(0, 0, 1, 1);
}
return self;
}
@end
and use this class to drive changes of the scroll view's bounds
. For the simplicity's sake we'll assume that the dynamic item's center
maps to the scroll view's bounds.origin
. There is a couple of ways to bind these values:
- We could register ourselves as an observer of the dynamic item's
center
key path and update thebounds
when its value changes. - We could pass the scroll view instance to the dynamic item and update its bounds from within
setCenter:
. - We could leverage the fact that
UIDynamicBehavior
contains anaction
property, described as:
The block you want to execute during dynamic animation. The dynamic animator calls the action block on every animation step.
We'll go with the last one, because it's the most succinct and—I think—it fits this case the best. Here it is in code:
behavior.action = ^{
CGRect bounds = weakSelf.bounds;
bounds.origin = weakSelf.dynamicItem.center;
weakSelf.bounds = bounds;
};
Inertial Scrolling
We're now ready to add an inertial scrolling. There are two things to it: 1) when a user lifts a finger off the screen after panning, the scroll view should continue to scroll with the same velocity vector and 2) the scrolling should slow down with time. While browsing through the documentation of built-in behaviors we quickly notice this one sentence in UIDynamicItemBehavior
description:
One notable and common use of a dynamic item behavior is to confer a velocity to a dynamic item to match the ending velocity of a user gesture.
The class is really flexible, it supports both linear and angular motions. We're going to use –addLinearVelocity:forItem:
to push the dynamic item with a velocity grabbed from the gesture recognizer. Adding inertia is a simple matter of changing resistance
property's value. Here's a full setup of this behavior:
self.dynamicItem.center = self.bounds.origin;
UIDynamicItemBehavior *decelerationBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[self.dynamicItem]];
[decelerationBehavior addLinearVelocity:velocity forItem:self.dynamicItem];
decelerationBehavior.resistance = 2.0;
__weak typeof(self) weakSelf = self;
decelerationBehavior.action = ^{
CGRect bounds = weakSelf.bounds;
bounds.origin = weakSelf.dynamicItem.center;
weakSelf.bounds = bounds;
};
[self.animator addBehavior:decelerationBehavior];
You can also see the whole file at this stage on GitHub.
Rubber-Banding
Rubber-banding is the easiest part and it actually doesn't use Dynamics at all. We just have to alter the bounds during panning according to the equation:
f(x, d, c) = (x * d * c) / (d + c * x)
where,
x – distance from the edge
c – constant (UIScrollView uses 0.55)
d – dimension, either width or height
Here's how it works:
Bouncing
We'll use UIAttachmentBehavior
for a bouncing effect. The idea here is simple: when the bounds.origin
crosses the visible area (derived from contentSize
) we calculate the anchor, which is a final position the user should end up at:
// mostly based on Grant Paul’s code from the Pop-based version
CGPoint maxBoundsOrigin = CGPointMake(self.contentSize.width - bounds.size.width,
self.contentSize.height - bounds.size.height);
CGPoint target = bounds.origin;
if (outsideBoundsMinimum) {
target.x = fmin(maxBoundsOrigin.x, fmax(target.x, 0.0));
target.y = fmin(maxBoundsOrigin.y, fmax(target.y, 0.0));
} else if (outsideBoundsMaximum) {
target.x = fmax(0, fmin(target.x, maxBoundsOrigin.x));
target.y = fmax(0, fmin(target.y, maxBoundsOrigin.y));
}
and attach the behavior to it:
UIAttachmentBehavior *springBehavior = [[UIAttachmentBehavior alloc] initWithItem:self.dynamicItem attachedToAnchor:target];
// Has to be equal to zero, because otherwise the bounds.origin wouldn't exactly match the target's position.
springBehavior.length = 0;
// These two values were chosen by trial and error.
springBehavior.damping = 1;
springBehavior.frequency = 2;
[self.animator addBehavior:springBehavior];
The rest of the method is mostly boilerplate, you can see it on GitHub.
Everything looks great until we try the scroll view with scrolling enabled in both directions. If a panning gesture has non-zero vertical and horizontal velocities, there's an ugly oscillation when the bounds.origin
is close to the target
:
It happens, because the attachment behavior doesn't simply simulate spring animation along the line. It can be influenced by other behaviors, and it is in our case, by decelerationBahavior
, causing it to rotate around its anchor point. We could try to remove decelerationBahavior
while adding springBehavior
, but it would in turn zero out the velocity.
After some debugging2 I noticed that the problem lies in the calculation of the spring's anchor. Animations are discrete, so for example when the scroll view is pushed to the left, bounds.origin
can take the following values:
x y
46.984947 78.164795
36.891747 82.781387
20.600927 90.232750
8.031227 95.982079
-4.141042 101.549622
-13.288508 105.733643
x
is never exactly equal to 0, so we have to calculate y
for x = 0
manually by solving a system of two linear equations:
y_1 = a*x_1 + b
y_2 = a*x_2 + b
With that, we're able to calculate the point at which the bounds.origin
crossed the left edge and attach the spring to the correct position. Calculations are analogous for other edges. Here is a final version in action:
Conclusion
We learned how to use Dynamics in less common situations and how to leverage a flexibility provided by its protocol-based design. The final result is really close to that provided by UIKit. I have to admit, though, that the code is less obvious than when done with Pop, because:
- We had to use an intermediary object to animate the
bounds
. It's currently—and I doubt it ever will be—not possible to create relationships between custom properties with Dynamics. UIAttachmentBehavior
, unlike Pop'sPOPSpringAnimation
, doesn't have avelocity
property, so we had to keepdecelerationBehavior
and calculate the anchor point manually.
The final version is available on GitHub.
Sidenote: The work on Pop started long before Dynamics was introduced. We can only wonder what would have happened with Pop, had Dynamics been introduced earlier.
-
It uses Box2D, a 2-dimensional physics simulator engine, under the hood. ↩
-
In the meantime I made a quick fix by adding the
UICollisionBehavior
. Here's how it worked. It’s an interesting effect, but not what we wanted to achieve. ↩