-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathXTUIFlickDynamics.m
370 lines (301 loc) · 10.6 KB
/
XTUIFlickDynamics.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
//
// FlickDynamics.m
// (c) 2009 Dave Peck <davepeck [at] davepeck [dot] org>
// Let me know if you have any questions/improvements.
//
// 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 do provide scroll/flick behavior on your custom views.
//
// This code is released under the BSD license. If you use my code in your product,
// please put my name somewhere in the credits.
//
#import "XTUIFlickDynamics.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)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 * 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)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];
}
+(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)dealloc
{
if (history != nil)
{
free(history);
history = nil;
}
}
-(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