davepeck (owner)

Revisions

gist: 100855 Download_button fork
public
Description:
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
Public Clone URL: git://gist.github.com/100855.git
FlickDynamics.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
//
// 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
//
// 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