Skip to content

Instantly share code, notes, and snippets.

@davepeck
Created April 24, 2009 00:26
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save davepeck/100855 to your computer and use it in GitHub Desktop.
Save davepeck/100855 to your computer and use it in GitHub Desktop.
Objective-C code that mimics the flick-to-scroll behavior found in iPhone scrolling views. This code is independent of coordinate system, animation rate, and the specific UI context you're working with -- perfect especially for getting good scrolling beha
//
// FlickDynamics.h
// (c) 2009 Dave Peck <davepeck [at] davepeck [dot] org>
// http://davepeck.org/
//
// This code is released under the BSD license. If you use my code in your product,
// please put my name somewhere in the credits and let me know about it!
//
// This code mimics the scroll/flick dynamics of the iPhone UIScrollView.
// What's cool about this code is that it is entirely independent of any iPhone
// UI, so you can use it to provide scroll/flick behavior on your custom views.
//
// The key thing (which you'll learn fast if you try and build this yourself) is that
// you can't just rely on the last two points to compute your motion vector. Instead
// you need to "look back in time" to figure out where the touch was, say, 0.07 seconds
// ago. That will give you a much better sense of your vector and speed.
//
// In order to answer the question "where was the touch 0.07 seconds ago" we keep a
// history of the last N touches. When the user's touch is released, we look back through
// the history and use linear interpolation to determine where the touch _would have been_
// had we recorded a touch at exactly 0.07 seconds ago. We use that point as the basis
// for our motion vector. To ensure that we never scroll "too fast," we clamp down on
// any large motion that we compute, being sure to maintain the direction while reducing
// the magnitude of motion.
//
// This code is coordinate system agnostic. I've chosen constants that made sense for
// a coordinate system where the viewport is 1.0 by 1.0 in size. However, when you
// initialize this code, it will scale the constants as appropriate for your viewport size.
// (For example, it works fine if your viewport is 320 x 480 in size.)
//
// This code expects that you already have an animation loop running. By default, the
// expectation is that you will call animate: sixty times a second. If you want to
// run at a different rate, be sure to initialize this class with your expected animation
// rate. Again, the built-in constants will be scaled to match.
//
#import <Foundation/Foundation.h>
typedef struct TouchInfo {
double x;
double y;
NSTimeInterval time; // all relative to the 1970 GMT epoch
} TouchInfo;
@interface FlickDynamics : NSObject {
TouchInfo *history;
NSUInteger historyCount;
NSUInteger historyHead;
double currentScrollLeft;
double currentScrollTop;
double animationRate;
double viewportWidth;
double viewportHeight;
double scrollBoundsLeft;
double scrollBoundsTop;
double scrollBoundsRight;
double scrollBoundsBottom;
double motionX;
double motionY;
double motionDamp;
double motionMultiplier;
double motionMinimum;
double flickThresholdX;
double flickThresholdY;
}
+(id)flickDynamicsWithViewportWidth:(double)viewportWidth viewportHeight:(double)viewportHeight scrollBoundsLeft:(double)scrollBoundsLeft scrollBoundsTop:(double)scrollBoundsTop scrollBoundsRight:(double)scrollBoundsRight scrollBoundsBottom:(double)scrollBoundsBottom animationRate:(NSTimeInterval)animationRate;
+(id)flickDynamicsWithViewportWidth:(double)viewportWidth viewportHeight:(double)viewportHeight scrollBoundsLeft:(double)scrollBoundsLeft scrollBoundsTop:(double)scrollBoundsTop scrollBoundsRight:(double)scrollBoundsRight scrollBoundsBottom:(double)scrollBoundsBottom;
@property (readwrite) double currentScrollLeft;
@property (readwrite) double currentScrollTop;
-(void)startTouchAtX:(double)x y:(double)y;
-(void)moveTouchAtX:(double)x y:(double)y;
-(void)endTouchAtX:(double)x y:(double)y;
-(void)animate; /* call this with whatever periodicity you specified on initialization */
-(void)stopMotion;
@end
//
// FlickDynamics.m
// (c) 2009 Dave Peck <davepeck [at] davepeck [dot] org>
// http://davepeck.org/
//
// This code is released under the BSD license. If you use my code in your product,
// please put my name somewhere in the credits and let me know about it!
//
// This code mimics the scroll/flick dynamics of the iPhone UIScrollView.
// What's cool about this code is that it is entirely independent of any iPhone
// UI, so you can use it to provide scroll/flick behavior on your custom views.
//
// The key thing (which you'll learn fast if you try and build this yourself) is that
// you can't just rely on the last two points to compute your motion vector. Instead
// you need to "look back in time" to figure out where the touch was, say, 0.07 seconds
// ago. That will give you a much better sense of your vector and speed.
//
// In order to answer the question "where was the touch 0.07 seconds ago" we keep a
// history of the last N touches. When the user's touch is released, we look back through
// the history and use linear interpolation to determine where the touch _would have been_
// had we recorded a touch at exactly 0.07 seconds ago. We use that point as the basis
// for our motion vector. To ensure that we never scroll "too fast," we clamp down on
// any large motion that we compute, being sure to maintain the direction while reducing
// the magnitude of motion.
//
// This code is coordinate system agnostic. I've chosen constants that made sense for
// a coordinate system where the viewport is 1.0 by 1.0 in size. However, when you
// initialize this code, it will scale the constants as appropriate for your viewport size.
// (For example, it works fine if your viewport is 320 x 480 in size.)
//
// This code expects that you already have an animation loop running. By default, the
// expectation is that you will call animate: sixty times a second. If you want to
// run at a different rate, be sure to initialize this class with your expected animation
// rate. Again, the built-in constants will be scaled to match.
//
#import "FlickDynamics.h"
/* these assume a 1.0 x 1.0 viewport at 60FPS */
// these constants were determined by experimentation
const double DEFAULT_MOTION_DAMP = 0.95;
const double DEFAULT_MOTION_MINIMUM = 0.0001;
const double DEFAULT_FLICK_THRESHOLD = 0.01;
const double DEFAULT_ANIMATION_RATE = 1.0f / 60.0f;
const double DEFAULT_MOTION_MULTIPLIER = 0.25f;
const double MOTION_MAX = 0.065f;
const NSTimeInterval FLICK_TIME_BACK = 0.07;
const NSUInteger DEFAULT_CAPACITY = 20;
@interface FlickDynamics (FlickDynamicsPrivate)
-(id)initWithViewportWidth:(double)myViewportWidth viewportHeight:(double)myViewportHeight scrollBoundsLeft:(double)myScrollBoundsLeft scrollBoundsTop:(double)myScrollBoundsTop scrollBoundsRight:(double)myScrollBoundsRight scrollBoundsBottom:(double)myScrollBoundsBottom animationRate:(NSTimeInterval)myAnimationRate;
-(void)dealloc;
-(void)clearHistory;
-(void)addToHistory:(TouchInfo)info;
-(TouchInfo)getHistoryAtIndex:(NSUInteger)index;
-(TouchInfo)getRecentHistory;
-(void)ensureValidScrollPosition;
-(double)linearMap:(double)value valueMin:(double)valueMin valueMax:(double)valueMax targetMin:(double)targetMin targetMax:(double)targetMax;
-(double)linearInterpolate:(double)from to:(double)to percent:(double)percent;
@end
@implementation FlickDynamics (FlickDynamicsPrivate)
-(id)initWithViewportWidth:(double)myViewportWidth viewportHeight:(double)myViewportHeight scrollBoundsLeft:(double)myScrollBoundsLeft scrollBoundsTop:(double)myScrollBoundsTop scrollBoundsRight:(double)myScrollBoundsRight scrollBoundsBottom:(double)myScrollBoundsBottom animationRate:(NSTimeInterval)myAnimationRate
{
self = [super init];
if (self != nil)
{
// "history" is a buffer of the last N touches. For performance, it is
// managed as a circular queue; older items are just dropped from it.
history = (TouchInfo*) malloc(sizeof(TouchInfo) * DEFAULT_CAPACITY);
historyCount = 0;
historyHead = 0;
currentScrollLeft = 0.0;
currentScrollTop = 0.0;
animationRate = myAnimationRate;
viewportWidth = myViewportWidth;
viewportHeight = myViewportHeight;
scrollBoundsLeft = myScrollBoundsLeft;
scrollBoundsTop = myScrollBoundsTop;
scrollBoundsRight = myScrollBoundsRight;
scrollBoundsBottom = myScrollBoundsBottom;
// our default constants assume a 1.0 x 1.0 viewport at 60FPS.
// here is where we scale them. Only some of our constants are FPS dependent.
double animationRateAdjustment = myAnimationRate / DEFAULT_ANIMATION_RATE;
double xAdjustment = myViewportWidth / 1.0;
double yAdjustment = myViewportHeight / 1.0;
double viewportAdjustment = (xAdjustment + yAdjustment) / 2.0;
motionDamp = pow(DEFAULT_MOTION_DAMP, animationRateAdjustment);
motionMultiplier = DEFAULT_MOTION_MULTIPLIER; /* does not need to be affected by viewportAdjustment */
motionMinimum = DEFAULT_MOTION_MINIMUM * viewportAdjustment;
flickThresholdX = DEFAULT_FLICK_THRESHOLD * xAdjustment;
flickThresholdY = DEFAULT_FLICK_THRESHOLD * yAdjustment;
motionX = 0.0;
motionY = 0.0;
}
return self;
}
-(void)dealloc
{
if (history != nil)
{
free(history);
history = nil;
}
[super dealloc];
}
-(void)clearHistory
{
historyCount = 0;
historyHead = 0;
}
-(void)addToHistory:(TouchInfo)info
{
NSUInteger rawIndex;
if (historyCount < DEFAULT_CAPACITY)
{
rawIndex = historyCount;
historyCount += 1;
}
else
{
rawIndex = historyHead;
historyHead += 1;
if (historyHead == DEFAULT_CAPACITY)
{
historyHead = 0;
}
}
history[rawIndex].x = info.x;
history[rawIndex].y = info.y;
history[rawIndex].time = info.time;
}
-(TouchInfo)getHistoryAtIndex:(NSUInteger)index
{
NSUInteger rawIndex = historyHead + index;
if (rawIndex >= DEFAULT_CAPACITY)
{
rawIndex -= DEFAULT_CAPACITY;
}
return history[rawIndex];
}
-(TouchInfo)getRecentHistory
{
return [self getHistoryAtIndex:(historyCount-1)];
}
-(void)ensureValidScrollPosition
{
if (currentScrollLeft + viewportWidth > scrollBoundsRight)
{
currentScrollLeft = scrollBoundsRight - viewportWidth;
}
if (currentScrollLeft < scrollBoundsLeft)
{
currentScrollLeft = scrollBoundsLeft;
}
if (scrollBoundsBottom < scrollBoundsTop)
{
// inverted (gl-style) viewport
if (currentScrollTop - viewportHeight < scrollBoundsBottom)
{
currentScrollTop = scrollBoundsBottom + viewportHeight;
}
if (currentScrollTop > scrollBoundsTop)
{
currentScrollTop = scrollBoundsTop;
}
}
else
{
// regular (Y increases downward) viewport
if (currentScrollTop + viewportHeight > scrollBoundsBottom)
{
currentScrollTop = scrollBoundsBottom - viewportHeight;
}
if (currentScrollTop < scrollBoundsTop)
{
currentScrollTop = scrollBoundsTop;
}
}
}
-(double)linearMap:(double)value valueMin:(double)valueMin valueMax:(double)valueMax targetMin:(double)targetMin targetMax:(double)targetMax
{
double zeroValue = value - valueMin;
double valueRange = valueMax - valueMin;
double targetRange = targetMax - targetMin;
double zeroTargetValue = zeroValue * (targetRange / valueRange);
double targetValue = zeroTargetValue + targetMin;
return targetValue;
}
-(double)linearInterpolate:(double)from to:(double)to percent:(double)percent
{
return (from * (1.0f - percent)) + (to * percent);
}
@end
@implementation FlickDynamics
+(id)flickDynamicsWithViewportWidth:(double)myViewportWidth viewportHeight:(double)myViewportHeight scrollBoundsLeft:(double)myScrollBoundsLeft scrollBoundsTop:(double)myScrollBoundsTop scrollBoundsRight:(double)myScrollBoundsRight scrollBoundsBottom:(double)myScrollBoundsBottom animationRate:(NSTimeInterval)myAnimationRate
{
return [[[FlickDynamics alloc] initWithViewportWidth:myViewportWidth viewportHeight:myViewportHeight scrollBoundsLeft:myScrollBoundsLeft scrollBoundsTop:myScrollBoundsTop scrollBoundsRight:myScrollBoundsRight scrollBoundsBottom:myScrollBoundsBottom animationRate:myAnimationRate] autorelease];
}
+(id)flickDynamicsWithViewportWidth:(double)myViewportWidth viewportHeight:(double)myViewportHeight scrollBoundsLeft:(double)myScrollBoundsLeft scrollBoundsTop:(double)myScrollBoundsTop scrollBoundsRight:(double)myScrollBoundsRight scrollBoundsBottom:(double)myScrollBoundsBottom
{
return [FlickDynamics flickDynamicsWithViewportWidth:myViewportWidth viewportHeight:myViewportHeight scrollBoundsLeft:myScrollBoundsLeft scrollBoundsTop:myScrollBoundsTop scrollBoundsRight:myScrollBoundsRight scrollBoundsBottom:myScrollBoundsBottom animationRate:DEFAULT_ANIMATION_RATE];
}
@synthesize currentScrollLeft;
@synthesize currentScrollTop;
-(void)startTouchAtX:(double)x y:(double)y
{
[self stopMotion];
[self clearHistory];
TouchInfo info;
info.x = x;
info.y = y;
info.time = [[NSDate date] timeIntervalSince1970];
[self addToHistory:info];
}
-(void)moveTouchAtX:(double)x y:(double)y
{
TouchInfo old = [self getRecentHistory];
TouchInfo new;
new.x = x;
new.y = y;
new.time = [[NSDate date] timeIntervalSince1970];
[self addToHistory:new];
currentScrollLeft += (old.x - new.x);
currentScrollTop += (old.y - new.y);
[self ensureValidScrollPosition];
}
-(void)endTouchAtX:(double)x y:(double)y
{
TouchInfo old = [self getRecentHistory];
TouchInfo last;
last.x = x;
last.y = y;
last.time = [[NSDate date] timeIntervalSince1970];
[self addToHistory:last];
// do the standard scrolling motion in response
currentScrollLeft += (old.x - last.x);
currentScrollTop += (old.y - last.y);
[self ensureValidScrollPosition];
// find the first point in our touch history that is younger than FLICK_TIME_BACK seconds.
// this point, and the point of release, will allow us to find our vector for motion.
NSTimeInterval crossoverTime = last.time - FLICK_TIME_BACK;
NSUInteger recentIndex = 0;
for (NSUInteger testIndex = 0; testIndex < historyCount; testIndex++)
{
TouchInfo testInfo = [self getHistoryAtIndex:testIndex];
if (testInfo.time > crossoverTime)
{
recentIndex = testIndex;
break;
}
}
if (recentIndex == 0)
{
// this is a very fast gesture. we will want to interpolate this point
// and the next _as if_ they projected out to where the touch would have
// been at time NOW - FLICK_TIME_BACK
recentIndex += 1;
}
// We have the two points closest to FLICK_TIME_BACK seconds
// Use linear interpolation to decide where the point _would_ have been at FLICK_TIME_BACK seconds
TouchInfo recentInfo = [self getHistoryAtIndex:recentIndex];
TouchInfo previousInfo = [self getHistoryAtIndex:(recentIndex - 1)];
double crossoverTimePercent = [self linearMap:crossoverTime valueMin:previousInfo.time valueMax:recentInfo.time targetMin:0.0f targetMax:1.0f];
double flickX = [self linearInterpolate:previousInfo.x to:recentInfo.x percent:crossoverTimePercent];
double flickY = [self linearInterpolate:previousInfo.y to:recentInfo.y percent:crossoverTimePercent];
// Dampen the motion along each axis if it is too small to matter
if (fabs(last.x - flickX) < flickThresholdX)
{
flickX = last.x;
}
if (fabs(last.y - flickY) < flickThresholdY)
{
flickY = last.y;
}
// this is not a flick gesture if there is no motion after interpolation and dampening
if ((last.x == flickX) && (last.y == flickY))
{
return;
}
// determine our raw motion
double rawMotionX = (flickX - last.x) * motionMultiplier;
double rawMotionY = (flickY - last.y) * motionMultiplier;
// Clamp down on motion to prevent extreme speeds.
// To keep the direction of motion correct, make sure to
// preserve the "aspect ratio."
double absX = fabs(rawMotionX);
double absY = fabs(rawMotionY);
if (absX >= MOTION_MAX && absX >= absY)
{
double scaleFactor = MOTION_MAX / absX;
rawMotionX *= scaleFactor;
rawMotionY *= scaleFactor;
}
else if (absY >= MOTION_MAX)
{
double scaleFactor = MOTION_MAX / absY;
rawMotionX *= scaleFactor;
rawMotionY *= scaleFactor;
}
// done! assign our motion!
motionX = rawMotionX;
motionY = rawMotionY;
}
-(void)animate
{
if (motionX == 0.0 && motionY == 0.0)
{
return;
}
currentScrollLeft += motionX;
currentScrollTop += motionY;
motionX *= motionDamp;
motionY *= motionDamp;
if (fabs(motionX) < motionMinimum)
{
motionX = 0.0;
}
if (fabs(motionY) < motionMinimum)
{
motionY = 0.0;
}
[self ensureValidScrollPosition];
}
-(void)stopMotion
{
motionX = 0.0;
motionY = 0.0;
}
@end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment