Created
April 24, 2009 00:26
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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 | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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