/
jquery.scroller.js
375 lines (313 loc) · 11.5 KB
/
jquery.scroller.js
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
(function() {
if (!('isArray' in Array)) {
Array.isArray = function(o) {
return Object.prototype.toString.call(o)==='[object Array]';
};
}
var Scroller = {};
/*
* class - add or remove a class
*
* This animation should be used with `shift` because it relies on a
* negative scroll value.
*
* classname: The name of the class to add when the scroll value becomes
* positive, and to remove when it becomes negative. This uses
* the jQuery addClass/removeClass functions, so you can specify
* multiple classnames using spaces.
*/
Scroller.class = function(classname) {
var is_on = false; // this is cheaper than using hasClass
return function(event, args) {
console.log([args.t, is_on]);
if (args.t > 0 && !is_on) {
$(this).addClass(classname);
is_on = true;
}
else if (args.t <= 0 && is_on) {
$(this).removeClass(classname);
is_on = false;
}
}
};
/*
* shift - Shift the scroll position by a number of pixels
*
* This control is different from `between` because animations will still play
* after this one - but they may receive a negative scroll value.
*
* by: The number of pixels to shift it by. A positive number will cause
* later animations to begin that number of pixels of scroll later than
* it would have.
*/
Scroller.shift = function(by) {
return function(event, args) {
args.scroll_top -= by;
by *= args.pixel;
args.t -= by;
}
};
/*
* parallax - Scroll faster or slower than the page to produce a parallax effect.
*
* layer: The perceived layer the element will be on. The main page is on layer zero.
* Negative layers will be behind the main content; positive in front. (Assuming
* you style it properly so they look like that)
*/
Scroller.parallax = function(layer) {
// use layer as coefficient of t value
var coef = Math.pow(2, layer) - 1;
return function(event, args) {
var $this = $(this);
args.elem_offset.left += 0;
args.elem_offset.top += 0 - (args.t * coef) / args.pixel;
};
};
/*
* slide - Move <x> pixels rightwards and <y> pixels downwards over
* a range of <scroll> pixels of scroll.
*
* x: Distance to move in X. Positive is rightwards; negative, leftwards.
* y: Distance to move in Y. Positive is downwards; negative, upwards.
* scroll: Scroll range over which this movement will happen.
*/
Scroller.slide = function(dx, dy, dt) {
return function(event, args) {
// convert dt out of pixel values
var t = dt * args.pixel;
var proportion = args.t/t;
if (proportion > 1) return;
args.elem_offset.left += dx * proportion;
args.elem_offset.top += dy * proportion;
}
};
/*
* between - constrain further animations in the chain to the specified
* scroll region.
*
* from: Scroll position in pixels at which animation will start, or
* 'onscreen' to wait for the element to scroll into view.
* to: Scroll position in pixels at which to stop the animation, or
* 'offscreen', as if it matters, to wait for the element to scroll
* out of view.
*/
Scroller.between = function(from, to) {
return function(event, args) {
var f = from;
var t = to;
if (f == 'onscreen') {
f = elem_offset.top - args.view_height;
}
if (t == 'offscreen') {
t = elem_offset.top + $(this).outerHeight();
}
var total_height = t ? t-f : args.scroll_range - f;
// Subtract the "from" position from scroll position.
args.scroll_top -= f;
f *= args.pixel;
args.t -= f;
// If the result is > 0 we have scrolled past that
if (args.t > 0) {
// We don't subtract the "to" position and test that because it'll
// ruin the scroll values for the chain.
if (t == undefined || args.scroll_top < total_height) {
return;
}
// Stop all scrolling activity past t
// Don't return false - we actually want to halt further animations, rather than reset them
args.scroll_top = total_height;
args.t = total_height * args.pixel;
return;
}
// If t < 0 we don't start yet
return false;
};
};
/*
* orbit - revolve around position defined by <centre>, at speed defined by <speed>.
* This animation is *absolute* and will hence undo any positioning done by
* prior animations. To adjust the orbit, apply transformations afterwards.
*
* This differs from CSS rotate because it will not affect the orientation of
* the element as it orbits.
*
* speed: Number of pixels of scroll to complete one full rotation. Positive numbers
* scroll clockwise; negative numbers, anticlockwise.
* centre: Either an array of [x, y] or a jQuery object, whose centre will be used.
* start_at: Optional distance round the circle to start, in degrees.
*/
Scroller.orbit = function(speed, centre, start_at) {
var self = $(this);
start_at = start_at || 0;
var cos = Math.cos, sin = Math.sin, sqrt = Math.sqrt, pi = Math.PI, pow = Math.pow;
var adjust = [self.outerWidth() / 2, self.outerHeight() / 2];
speed = 2 * pi / speed;
start_at *= pi / 180; // radians.
if (! Array.isArray(centre)) {
var offset = centre.offset();
centre = [offset.left + (centre.outerWidth() / 2), offset.top + (centre.outerHeight()/2)];
}
return function(event, args) {
var radius =
Math.abs(
sqrt(
pow(args.elem_offset.top + adjust[1] - centre[1], 2)
+ pow(args.elem_offset.left + adjust[0] - centre[0], 2)
));
args.elem_offset.left = radius * cos(args.scroll_top * speed + start_at) - adjust[0] + centre[0];
args.elem_offset.top = radius * sin(args.scroll_top * speed + start_at) - adjust[1] + centre[1];
};
};
/*
* rotate - use CSS rotation transform to rotate around <centre> at a speed defined by
* <speed>.
*
* speed: Number of pixels of scroll to complete one full rotation. Positive numbers
* scroll clockwise; negative numbers, anticlockwise.
* centre: Array of [x, y] to set the default centre of rotation as % a la CSS.
* start_at: Optional distance round the circle to start, in degrees.
*/
Scroller.rotate = function(speed, centre, start_at) {
var self = $(this);
var setup = {}
if (centre) {
$.extend(setup, {
'transform-origin': centre[0] + '% ' + centre[1] + '%',
'-ms-transform-origin': centre[0] + '% ' + centre[1] + '%',
'-webkit-transform-origin': centre[0] + '% ' + centre[1] + '%',
'-moz-transform-origin': centre[0] + '% ' + centre[1] + '%',
'-o-transform-origin': centre[0] + '% ' + centre[1] + '%'
});
}
if (start_at) {
$.extend(setup, {
'transform': 'rotate(' + start_at + 'deg)',
'-ms-transform': 'rotate(' + start_at + 'deg)',
'-webkit-transform': 'rotate(' + start_at + 'deg)',
'-moz-transform': 'rotate(' + start_at + 'deg)',
'-o-transform': 'rotate(' + start_at + 'deg)'
});
}
self.css(setup);
start_at = 0;
return function (event, args) {
var t_speed = speed * args.pixel;
var deg = 360 * (args.t / t_speed) + start_at;
var css = {
'transform': 'rotate(' + deg + 'deg)',
'-ms-transform': 'rotate(' + deg + 'deg)',
'-webkit-transform': 'rotate(' + deg + 'deg)',
'-moz-transform': 'rotate(' + deg + 'deg)',
'-o-transform': 'rotate(' + deg + 'deg)'
};
self.css(css);
}
};
/*
* css - tween some CSS attributes over <speed> pixels. For CSS rotate transform, use rotate.
*
* speed: Number of pixels of scroll to go from current value to new value.
* attributes: Object with CSS attributes as properties and the target value as values.
* Only numeric values are supported, of course. Units used need to match the
* ones defined by your CSS. CSS values not predefined will be ignored.
*/
Scroller.css = function(speed, attr) {
var self = $(this),
original_values = {};
$.each(attr, function(key, value) {
original_values[key] = self.css(key);
});
return function(event, args) {
var css = {};
var t_speed = speed * args.pixel;
$.each(attr, function(key, value) {
if (! original_values[key]) return;
var orig = parseFloat(original_values[key]),
target = parseFloat(value);
var current = (target - orig) * (args.t / t_speed);
if (current > target) current = target;
css[key] = original_values[key].replace(orig, current);
});
self.css(css);
};
};
$.fn.scroller = function() {
var self = this,
api = {},
elements = [],
invariant_values = {};
api.add = function(elem, anims) {
var animations = [];
$.each(anims, function(i,anim) {
if (typeof anim[0] == 'string') {
if (!Scroller[anim[0]]) {
throw anim[0] + " is not a recognised animation function.";
}
animations.push(Scroller[anim[0]].apply(elem,anim[1]));
}
});
elements.push(elem);
// Each chain should start based on the original position of the element.
var offset = elem.offset();
// Go through the listed animations in order so that each subsequent one
// sees the changes from the previous one. Don't bind each animation to the
// event separately because we want synchronicity
elem.bind('scroller.animate', function(event, args) {
var new_offset = $.extend({}, offset);
// The interface to animating the element is to literally change the args
// object as we go down the chain, or to return false to stop the chain.
// Hence, we must close over a clone of it.
var chain_args = $.extend({}, args);
chain_args.elem_offset = $.extend({}, offset);
$.each(animations, function(i, anim) {
// we want elem in $(this)
var shift = anim.call(elem, event, chain_args);
// Stop doing anything if we return false
if (shift === false) {
return false;
}
});
$(this).offset(chain_args.elem_offset);
});
};
function scroller(e) {
var args = $.extend({}, invariant_values);
var scrollTop = self.scrollTop(),
t = scrollTop / args.scroll_range;
args.scroll_top = scrollTop;
args.t = t;
// normalise the distance down the document we are scrolled.
$.each(elements, function() {
var elem_offset = this.offset();
var elem_viewport_range = args.view_height + this.outerHeight();
// position of element in scroll range. Elements that can't touch the top of the
// viewport will have a value >1
args.elem_t = elem_offset.top / invariant_values.scroll_range;
// proportion of the scroll range that is the height of the element.
args.elem_height_t = this.outerHeight() / args.scroll_range;
// Position of element in the window. Elements appear in the window at 0 and disappear completely
// at 1; hence they touch the top of the window at slightly more than 1, a margin proportionate to their height.
args.elem_window_t = ((args.scroll_top + args.view_height - elem_offset.top) / elem_viewport_range);
this.trigger('scroller.animate', args);
});
}
function compute_invariants() {
// Normalise all values to be some proportion of scrollRange
var contentHeight = (self[0] == window) ? $(document).height() : self[0].scrollHeight,
viewportHeight = (self[0] == window) ? self.height() : self.innerHeight(),
scrollRange = contentHeight - viewportHeight;
invariant_values = {
viewport_height_t: viewportHeight / scrollRange,
doc_height: contentHeight,
view_height: viewportHeight,
scroll_range: scrollRange,
pixel: 1/scrollRange
};
}
compute_invariants();
this.resize(compute_invariants);
this.scroll(scroller);
this.data('scroller', api);
return this;
};
})();