// // FlickDynamics.m // (c) 2009 Dave Peck // 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