├── .gitignore ├── .npmignore ├── LICENSE.txt ├── README.md ├── bower.json ├── index.js ├── lib ├── Scroller.js ├── animate.js └── numericEqual.js ├── package.json └── test ├── animation.js ├── animationInterrupt.js ├── animationOverlap.js ├── autoposition.js ├── boundaries.js ├── events.js ├── initialization.js ├── movement.js └── relative.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /test/ 3 | /bower.json 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Zynga Inc., http://zynga.com/ 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Scroller 2 | ======== 3 | 4 | A pure logic component for scrolling/zooming. 5 | It is independent of any specific kind of rendering or event system. 6 | Scroller has been forked from [ZyngaScroller](https://github.com/zynga/scroller/) to incorporate UMD support. 7 | Pull-to-refresh and EasyScroll have been removed from this fork. 8 | 9 | The "demo" folder contains examples for usage with DOM and Canvas renderings which works both, on mouse and touch driven devices. 10 | 11 | 12 | Demos 13 | ----- 14 | 15 | See [ZyngaScroller](https://github.com/zynga/scroller/)'s original demos here: http://popham.github.com/scroller/ 16 | 17 | 18 | Features 19 | -------- 20 | 21 | * Customizable enabling/disabling of scrolling for x-axis and y-axis 22 | * Deceleration (decelerates when user action ends in motion) 23 | * Bouncing (bounces back on the edges) 24 | * Paging (snap to full page width/height) 25 | * Snapping (snap to an user definable pixel grid) 26 | * Zooming (automatic centered zooming or based on a point in the view with configurable min/max zoom) 27 | * Locking (locks drag direction based on initial movement) 28 | * Pull-to-Refresh (Pull top out of the boundaries to start refresh of list) 29 | * Configurable regarding whether animation should be used. 30 | 31 | Options 32 | ------- 33 | 34 | These are the available options with their defaults. 35 | Options can be modified using the second constructor parameter or during runtime by modification of `scrollerObj.options.optionName`. 36 | 37 | * scrollingX = `true` 38 | * scrollingY = `true` 39 | * animating = `true` 40 | * animationDuration = `250` 41 | * bouncing = `true` 42 | * locking = `true` 43 | * paging = `false` 44 | * snapping = `false` 45 | * zooming = `false` 46 | * minZoom = `0.5` 47 | * maxZoom = `3` 48 | 49 | Usage 50 | ----- 51 | 52 | Callback (first parameter of constructor) is required. Options are optional. 53 | Defaults are listed above. 54 | The created instance must have proper dimensions using a `setDimensions()` call. 55 | Afterwards you can pass in event data or manually control scrolling/zooming via the API. 56 | 57 | ```js 58 | var scrollerObj = new Scroller(function(left, top, zoom) { 59 | // apply coordinates/zooming 60 | }, { 61 | scrollingY: false 62 | }); 63 | 64 | // Configure to have an outer dimension of 1000px and inner dimension of 3000px 65 | scrollerObj.setDimensions(1000, 1000, 3000, 3000); 66 | ``` 67 | 68 | Public API 69 | ---------- 70 | 71 | * Setup scroll object dimensions. 72 | `scrollerObj.setDimensions(clientWidth, clientHeight, contentWidth, contentHeight);` 73 | * Setup scroll object position (in relation to the document). 74 | Required for zooming to event position (mousewheel, touchmove). 75 | `scrollerObj.setPosition(clientLeft, clientTop);` 76 | * Setup snap dimensions (only needed when `snapping` is enabled) 77 | `scrollerObj.setSnapSize(width, height);` 78 | * Setup pull-to-refresh. 79 | Height of the info region plus three callbacks which are executed on the different stages. 80 | `scrollerObj.activatePullToRefresh(height, activate, deactivate, start);` 81 | * Stop pull-to-refresh session. 82 | Called inside the logic started by start callback for activatePullToRefresh call. 83 | `scrollerObj.finishPullToRefresh();` 84 | * Get current scroll positions and zooming. 85 | `scrollerObj.getValues() => { left, top, zoom }` 86 | * Zoom to a specific level. 87 | Origin defines the pixel position where zooming should centering to. 88 | Defaults to center of scrollerObj. 89 | `scrollerObj.zoomTo(level, animate ? false, originLeft ? center, originTop ? center)` 90 | * Zoom by a given amount. 91 | Same as `zoomTo` but by a relative value. 92 | `scrollerObj.zoomBy(factor, animate ? false, originLeft ? center, originTop ? center);` 93 | * Scroll to a specific position. 94 | `scrollerObj.scrollTo(left, top, animate ? false);` 95 | * Scroll by the given amount. 96 | `scrollerObj.scrollBy(leftOffset, topOffset, animate ? false);` 97 | 98 | Event API 99 | --------- 100 | 101 | This API part can be used to pass event data to the `scrollerObj` to react on user actions. 102 | 103 | * `doMouseZoom(wheelDelta, timeStamp, pageX, pageY)` 104 | * `doTouchStart(touches, timeStamp)` 105 | * `doTouchMove(touches, timeStamp, scale)` 106 | * `doTouchEnd(timeStamp)` 107 | 108 | For a touch device just pass the native `touches` event data to the doTouch* methods. 109 | On mouse systems one can emulate this data using an array with just one element: 110 | 111 | * Touch device: `doTouchMove(e.touches, e.timeStamp);` 112 | * Mouse device: `doTouchMove([e], e.timeStamp);` 113 | 114 | To zoom using the `mousewheel` event just pass the data like this: 115 | 116 | * `doMouseZoom(e.wheelDelta, e.timeStamp, e.pageX, e.pageY);` 117 | 118 | For more information about this please take a look at the demos. 119 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scroller", 3 | "main": "index.js", 4 | "version": "0.0.2", 5 | "homepage": "https://github.com/popham/scroller", 6 | "authors": [ 7 | "Tim Popham " 8 | ], 9 | "description": "Accelerated panning and zooming for HTML and Canvas", 10 | "moduleType": [ 11 | "amd", 12 | "globals", 13 | "node" 14 | ], 15 | "keywords": [ 16 | "Scrolling", 17 | "Scroll", 18 | "Scroller", 19 | "Touch" 20 | ], 21 | "license": "MIT", 22 | "ignore": [ 23 | "**/.*", 24 | "node_modules", 25 | "bower_components", 26 | "test", 27 | "tests", 28 | "package.json", 29 | "demo" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | // AMD 4 | define(['exports', './lib/animate', './lib/Scroller'], factory); 5 | } else if (typeof exports === 'object') { 6 | // CommonJS 7 | factory(exports, require('./lib/animate'), require('./lib/Scroller')); 8 | } 9 | }(this, function (exports, animate, Scroller) { 10 | exports.animate = animate; 11 | exports.Scroller = Scroller; 12 | })); 13 | -------------------------------------------------------------------------------- /lib/Scroller.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Scroller 3 | * http://github.com/zynga/scroller 4 | * 5 | * Copyright 2011, Zynga Inc. 6 | * Licensed under the MIT License. 7 | * https://raw.github.com/zynga/scroller/master/MIT-LICENSE.txt 8 | * 9 | * Based on the work of: Unify Project (unify-project.org) 10 | * http://unify-project.org 11 | * Copyright 2011, Deutsche Telekom AG 12 | * License: MIT + Apache (V2) 13 | */ 14 | 15 | (function (root, factory) { 16 | if (typeof define === 'function' && define.amd) { 17 | // AMD 18 | define(['./animate'], factory); 19 | } else if (typeof module === 'object') { 20 | // CommonJS 21 | module.exports = factory(require('./animate')); 22 | } else { 23 | // Browser globals 24 | root.Scroller = factory(root.animate); 25 | } 26 | }(this, function (animate) { 27 | var NOOP = function () {}; 28 | 29 | /** 30 | * A pure logic 'component' for 'virtual' scrolling/zooming. 31 | */ 32 | var Scroller = function (callback, options) { 33 | this.__callback = callback; 34 | 35 | this.options = { 36 | /** Enable scrolling on x-axis */ 37 | scrollingX: true, 38 | 39 | /** Enable scrolling on y-axis */ 40 | scrollingY: true, 41 | 42 | /** Enable animations for deceleration, snap back, zooming and scrolling */ 43 | animating: true, 44 | 45 | /** duration for animations triggered by scrollTo/zoomTo */ 46 | animationDuration: 250, 47 | 48 | /** Enable bouncing (content can be slowly moved outside and jumps back after releasing) */ 49 | bouncing: true, 50 | 51 | /** Enable locking to the main axis if user moves only slightly on one of them at start */ 52 | locking: true, 53 | 54 | /** Enable pagination mode (switching between full page content panes) */ 55 | paging: false, 56 | 57 | /** Enable snapping of content to a configured pixel grid */ 58 | snapping: false, 59 | 60 | /** Enable zooming of content via API, fingers and mouse wheel */ 61 | zooming: false, 62 | 63 | /** Minimum zoom level */ 64 | minZoom: 0.5, 65 | 66 | /** Maximum zoom level */ 67 | maxZoom: 3, 68 | 69 | /** Multiply or decrease scrolling speed **/ 70 | speedMultiplier: 1, 71 | 72 | /** Callback that is fired on the later of touch end or deceleration end, 73 | provided that another scrolling action has not begun. Used to know 74 | when to fade out a scrollbar. */ 75 | scrollingComplete: NOOP, 76 | 77 | /** This configures the amount of change applied to deceleration when reaching boundaries **/ 78 | penetrationDeceleration : 0.03, 79 | 80 | /** This configures the amount of change applied to acceleration when reaching boundaries **/ 81 | penetrationAcceleration : 0.08 82 | }; 83 | 84 | for (var key in options) { 85 | this.options[key] = options[key]; 86 | } 87 | }; 88 | 89 | 90 | // Easing Equations (c) 2003 Robert Penner, all rights reserved. 91 | // Open source under the BSD License. 92 | 93 | /** 94 | * @param pos {Number} position between 0 (start of effect) and 1 (end of effect) 95 | **/ 96 | var easeOutCubic = function (pos) { 97 | return (Math.pow((pos - 1), 3) + 1); 98 | }; 99 | 100 | /** 101 | * @param pos {Number} position between 0 (start of effect) and 1 (end of effect) 102 | **/ 103 | var easeInOutCubic = function (pos) { 104 | if ((pos /= 0.5) < 1) { 105 | return 0.5 * Math.pow(pos, 3); 106 | } 107 | 108 | return 0.5 * (Math.pow((pos - 2), 3) + 2); 109 | }; 110 | 111 | 112 | Scroller.prototype = { 113 | 114 | /* 115 | --------------------------------------------------------------------------- 116 | INTERNAL FIELDS :: STATUS 117 | --------------------------------------------------------------------------- 118 | */ 119 | 120 | /** {Boolean} Whether only a single finger is used in touch handling */ 121 | __isSingleTouch: false, 122 | 123 | /** {Boolean} Whether a touch event sequence is in progress */ 124 | __isTracking: false, 125 | 126 | /** {Boolean} Whether a deceleration animation went to completion. */ 127 | __didDecelerationComplete: false, 128 | 129 | /** 130 | * {Boolean} Whether a gesture zoom/rotate event is in progress. Activates when 131 | * a gesturestart event happens. This has higher priority than dragging. 132 | */ 133 | __isGesturing: false, 134 | 135 | /** 136 | * {Boolean} Whether the user has moved by such a distance that we have enabled 137 | * dragging mode. Hint: It's only enabled after some pixels of movement to 138 | * not interrupt with clicks etc. 139 | */ 140 | __isDragging: false, 141 | 142 | /** 143 | * {Boolean} Not touching and dragging anymore, and smoothly animating the 144 | * touch sequence using deceleration. 145 | */ 146 | __isDecelerating: false, 147 | 148 | /** 149 | * {Boolean} Smoothly animating the currently configured change 150 | */ 151 | __isAnimating: false, 152 | 153 | 154 | 155 | /* 156 | --------------------------------------------------------------------------- 157 | INTERNAL FIELDS :: DIMENSIONS 158 | --------------------------------------------------------------------------- 159 | */ 160 | 161 | /** {Integer} Viewport left boundary */ 162 | __clientLeft: 0, 163 | 164 | /** {Integer} Viewport right boundary */ 165 | __clientTop: 0, 166 | 167 | /** {Integer} Viewport width */ 168 | __clientWidth: 0, 169 | 170 | /** {Integer} Viewport height */ 171 | __clientHeight: 0, 172 | 173 | /** {Integer} Full content's width */ 174 | __contentWidth: 0, 175 | 176 | /** {Integer} Full content's height */ 177 | __contentHeight: 0, 178 | 179 | /** {Integer} Snapping width for content */ 180 | __snapWidth: 100, 181 | 182 | /** {Integer} Snapping height for content */ 183 | __snapHeight: 100, 184 | 185 | /** {Number} Zoom level */ 186 | __zoomLevel: 1, 187 | 188 | /** {Number} Scroll position on x-axis */ 189 | __scrollLeft: 0, 190 | 191 | /** {Number} Scroll position on y-axis */ 192 | __scrollTop: 0, 193 | 194 | /** {Integer} Maximum allowed scroll position on x-axis */ 195 | __maxScrollLeft: 0, 196 | 197 | /** {Integer} Maximum allowed scroll position on y-axis */ 198 | __maxScrollTop: 0, 199 | 200 | /* {Number} Scheduled left position (final position when animating) */ 201 | __scheduledLeft: 0, 202 | 203 | /* {Number} Scheduled top position (final position when animating) */ 204 | __scheduledTop: 0, 205 | 206 | /* {Number} Scheduled zoom level (final scale when animating) */ 207 | __scheduledZoom: 0, 208 | 209 | 210 | 211 | /* 212 | --------------------------------------------------------------------------- 213 | INTERNAL FIELDS :: LAST POSITIONS 214 | --------------------------------------------------------------------------- 215 | */ 216 | 217 | /** {Number} Left position of finger at start */ 218 | __lastTouchLeft: null, 219 | 220 | /** {Number} Top position of finger at start */ 221 | __lastTouchTop: null, 222 | 223 | /** {Date} Timestamp of last move of finger. Used to limit tracking range for deceleration speed. */ 224 | __lastTouchMove: null, 225 | 226 | /** {Array} List of positions, uses three indexes for each state: left, top, timestamp */ 227 | __positions: null, 228 | 229 | 230 | 231 | /* 232 | --------------------------------------------------------------------------- 233 | INTERNAL FIELDS :: DECELERATION SUPPORT 234 | --------------------------------------------------------------------------- 235 | */ 236 | 237 | /** {Integer} Minimum left scroll position during deceleration */ 238 | __minDecelerationScrollLeft: null, 239 | 240 | /** {Integer} Minimum top scroll position during deceleration */ 241 | __minDecelerationScrollTop: null, 242 | 243 | /** {Integer} Maximum left scroll position during deceleration */ 244 | __maxDecelerationScrollLeft: null, 245 | 246 | /** {Integer} Maximum top scroll position during deceleration */ 247 | __maxDecelerationScrollTop: null, 248 | 249 | /** {Number} Current factor to modify horizontal scroll position with on every step */ 250 | __decelerationVelocityX: null, 251 | 252 | /** {Number} Current factor to modify vertical scroll position with on every step */ 253 | __decelerationVelocityY: null, 254 | 255 | 256 | 257 | /* 258 | --------------------------------------------------------------------------- 259 | PUBLIC API 260 | --------------------------------------------------------------------------- 261 | */ 262 | 263 | /** 264 | * Configures the dimensions of the client (outer) and content (inner) elements. 265 | * Requires the available space for the outer element and the outer size of the inner element. 266 | * All values which are falsy (null or zero etc.) are ignored and the old value is kept. 267 | * 268 | * @param clientWidth {Integer ? null} Inner width of outer element 269 | * @param clientHeight {Integer ? null} Inner height of outer element 270 | * @param contentWidth {Integer ? null} Outer width of inner element 271 | * @param contentHeight {Integer ? null} Outer height of inner element 272 | */ 273 | setDimensions : function (clientWidth, clientHeight, contentWidth, contentHeight) { 274 | // Only update values which are defined 275 | if (clientWidth !== null) { 276 | this.__clientWidth = clientWidth; 277 | } 278 | 279 | if (clientHeight !== null) { 280 | this.__clientHeight = clientHeight; 281 | } 282 | 283 | if (contentWidth !== null) { 284 | this.__contentWidth = contentWidth; 285 | } 286 | 287 | if (contentHeight !== null) { 288 | this.__contentHeight = contentHeight; 289 | } 290 | 291 | // Refresh maximums 292 | this.__computeScrollMax(); 293 | 294 | // Refresh scroll position 295 | this.scrollTo(this.__scrollLeft, this.__scrollTop, true); 296 | }, 297 | 298 | 299 | /** 300 | * Sets the client coordinates in relation to the document. 301 | * 302 | * @param left {Integer ? 0} Left position of outer element 303 | * @param top {Integer ? 0} Top position of outer element 304 | */ 305 | setPosition : function (left, top) { 306 | this.__clientLeft = left || 0; 307 | this.__clientTop = top || 0; 308 | }, 309 | 310 | 311 | /** 312 | * Configures the snapping (when snapping is active) 313 | * 314 | * @param width {Integer} Snapping width 315 | * @param height {Integer} Snapping height 316 | */ 317 | setSnapSize : function (width, height) { 318 | this.__snapWidth = width; 319 | this.__snapHeight = height; 320 | }, 321 | 322 | 323 | /** 324 | * Returns the scroll position and zooming values 325 | * 326 | * @return {Map} `left` and `top` scroll position and `zoom` level 327 | */ 328 | getValues : function () { 329 | return { 330 | left: this.__scrollLeft, 331 | top: this.__scrollTop, 332 | right: this.__scrollLeft + this.__clientWidth/this.__zoomLevel, 333 | bottom: this.__scrollTop + this.__clientHeight/this.__zoomLevel, 334 | zoom: this.__zoomLevel 335 | }; 336 | }, 337 | 338 | 339 | /** 340 | * Get point in in content space from scroll coordinates. 341 | */ 342 | getPoint : function (scrollLeft, scrollTop) { 343 | var values = this.getValues(); 344 | 345 | return { 346 | left : scrollLeft / values.zoom, 347 | top : scrollTop / values.zoom 348 | }; 349 | }, 350 | 351 | 352 | /** 353 | * Returns the maximum scroll values 354 | * 355 | * @return {Map} `left` and `top` maximum scroll values 356 | */ 357 | getScrollMax : function () { 358 | return { 359 | left: this.__maxScrollLeft, 360 | top: this.__maxScrollTop 361 | }; 362 | }, 363 | 364 | 365 | /** 366 | * Zooms to the given level. Supports optional animation. Zooms 367 | * the center when no coordinates are given. 368 | * 369 | * @param level {Number} Level to zoom to 370 | * @param isAnimated {Boolean ? false} Whether to use animation 371 | * @param fixedLeft {Number ? undefined} Stationary point's left coordinate (vector in client space) 372 | * @param fixedTop {Number ? undefined} Stationary point's top coordinate (vector in client space) 373 | * @param callback {Function ? null} A callback that gets fired when the zoom is complete. 374 | */ 375 | zoomTo : function (level, isAnimated, fixedLeft, fixedTop, callback) { 376 | if (!this.options.zooming) { 377 | throw new Error("Zooming is not enabled!"); 378 | } 379 | 380 | // Add callback if exists 381 | if(callback) { 382 | this.__zoomComplete = callback; 383 | } 384 | 385 | // Stop deceleration 386 | if (this.__isDecelerating) { 387 | animate.stop(this.__isDecelerating); 388 | this.__isDecelerating = false; 389 | } 390 | 391 | var oldLevel = this.__zoomLevel; 392 | 393 | // Normalize fixed point to center of viewport if not defined 394 | if (fixedLeft === undefined) { 395 | fixedLeft = this.__clientWidth / 2; 396 | } 397 | 398 | if (fixedTop === undefined) { 399 | fixedTop = this.__clientHeight / 2; 400 | } 401 | 402 | // Limit level according to configuration 403 | level = Math.max(Math.min(level, this.options.maxZoom), this.options.minZoom); 404 | 405 | // Recompute maximum values while temporary tweaking maximum scroll ranges 406 | this.__computeScrollMax(level); 407 | 408 | // Recompute left and top scroll positions based on new zoom level. 409 | // Choosing the new viewport so that the origin's position remains 410 | // fixed, we have central dilation about the origin. 411 | // * Fixed point, $F$, remains stationary in content space and in the 412 | // viewport. 413 | // * Initial scroll position, $S_i$, in content space. 414 | // * Final scroll position, $S_f$, in content space. 415 | // * Initial scaling factor, $k_i$. 416 | // * Final scaling factor, $k_f$. 417 | // 418 | // * $S_i \mapsto S_f$. 419 | // * $(S_i - F) k_i = (S_f - F) k_f$. 420 | // * $(S_i - F) k_i/k_f = (S_f - F)$. 421 | // * $S_f = F + (S_i - F) k_i/k_f$. 422 | // 423 | // Fixed point location, $\vector{f} = (F - S_i) k_i$. 424 | // * $F = S_i + \vector{f}/k_i$. 425 | // * $S_f = S_i + \vector{f}/k_i + (S_i - S_i - \vector{f}/k_i) k_i/k_f$. 426 | // * $S_f = S_i + \vector{f}/k_i - \vector{f}/k_f$. 427 | // * $S_f k_f = S_i k_f + (k_f/k_i - 1)\vector{f}$. 428 | // * $S_f k_f = (k_f/k_i)(S_i k_i) + (k_f/k_i - 1) \vector{f}$. 429 | var k = level / oldLevel; 430 | var left = k*(this.__scrollLeft + fixedLeft) - fixedLeft; 431 | var top = k*(this.__scrollTop + fixedTop) - fixedTop; 432 | 433 | // Limit x-axis 434 | if (left > this.__maxScrollLeft) { 435 | left = this.__maxScrollLeft; 436 | } else if (left < 0) { 437 | left = 0; 438 | } 439 | 440 | // Limit y-axis 441 | if (top > this.__maxScrollTop) { 442 | top = this.__maxScrollTop; 443 | } else if (top < 0) { 444 | top = 0; 445 | } 446 | 447 | // Push values out 448 | this.__publish(left, top, level, isAnimated); 449 | }, 450 | 451 | 452 | /** 453 | * Zooms the content by the given factor. 454 | * 455 | * @param factor {Number} Zoom by given factor 456 | * @param isAnimated {Boolean ? false} Whether to use animation 457 | * @param originLeft {Number ? 0} Zoom in at given left coordinate 458 | * @param originTop {Number ? 0} Zoom in at given top coordinate 459 | * @param callback {Function ? null} A callback that gets fired when the zoom is complete. 460 | */ 461 | zoomBy : function (factor, isAnimated, originLeft, originTop, callback) { 462 | this.zoomTo(this.__zoomLevel * factor, isAnimated, originLeft, originTop, callback); 463 | }, 464 | 465 | 466 | /** 467 | * Scrolls to the given position. Respect limitations and snapping automatically. 468 | * 469 | * @param left {Number?null} Horizontal scroll position, keeps current if value is null 470 | * @param top {Number?null} Vertical scroll position, keeps current if value is null 471 | * @param isAnimated {Boolean?false} Whether the scrolling should happen using an animation 472 | * @param zoom {Number} [1.0] Zoom level to go to 473 | */ 474 | scrollTo : function (left, top, isAnimated, zoom) { 475 | // Stop deceleration 476 | if (this.__isDecelerating) { 477 | animate.stop(this.__isDecelerating); 478 | this.__isDecelerating = false; 479 | } 480 | 481 | // Correct coordinates based on new zoom level 482 | if (zoom !== undefined && zoom !== this.__zoomLevel) { 483 | if (!this.options.zooming) { 484 | throw new Error("Zooming is not enabled!"); 485 | } 486 | 487 | left *= zoom; 488 | top *= zoom; 489 | 490 | // Recompute maximum values while temporary tweaking maximum scroll ranges 491 | this.__computeScrollMax(zoom); 492 | } else { 493 | // Keep zoom when not defined 494 | zoom = this.__zoomLevel; 495 | } 496 | 497 | if (!this.options.scrollingX) { 498 | left = this.__scrollLeft; 499 | } else { 500 | if (this.options.paging) { 501 | left = Math.round(left / this.__clientWidth) * this.__clientWidth; 502 | } else if (this.options.snapping) { 503 | left = Math.round(left / this.__snapWidth) * this.__snapWidth; 504 | } 505 | } 506 | 507 | if (!this.options.scrollingY) { 508 | top = this.__scrollTop; 509 | } else { 510 | if (this.options.paging) { 511 | top = Math.round(top / this.__clientHeight) * this.__clientHeight; 512 | } else if (this.options.snapping) { 513 | top = Math.round(top / this.__snapHeight) * this.__snapHeight; 514 | } 515 | } 516 | 517 | // Limit for allowed ranges 518 | left = Math.max(Math.min(this.__maxScrollLeft, left), 0); 519 | top = Math.max(Math.min(this.__maxScrollTop, top), 0); 520 | 521 | // Don't animate when no change detected, still call publish to make sure 522 | // that rendered position is really in-sync with internal data 523 | if (left === this.__scrollLeft && top === this.__scrollTop) { 524 | isAnimated = false; 525 | } 526 | 527 | // Publish new values 528 | this.__publish(left, top, zoom, isAnimated); 529 | }, 530 | 531 | 532 | /** 533 | * Scroll by the given offset 534 | * 535 | * @param left {Number ? 0} Scroll x-axis by given offset 536 | * @param top {Number ? 0} Scroll x-axis by given offset 537 | * @param isAnimated {Boolean ? false} Whether to animate the given change 538 | */ 539 | scrollBy : function (left, top, isAnimated) { 540 | var startLeft = this.__isAnimating ? this.__scheduledLeft : this.__scrollLeft; 541 | var startTop = this.__isAnimating ? this.__scheduledTop : this.__scrollTop; 542 | 543 | this.scrollTo(startLeft + (left || 0), startTop + (top || 0), isAnimated); 544 | }, 545 | 546 | 547 | /* 548 | --------------------------------------------------------------------------- 549 | EVENT CALLBACKS 550 | --------------------------------------------------------------------------- 551 | */ 552 | 553 | /** 554 | * Mouse wheel handler for zooming support 555 | */ 556 | doMouseZoom : function (wheelDelta, timeStamp, pageX, pageY) { 557 | var change = wheelDelta > 0 ? 0.97 : 1.03; 558 | 559 | return this.zoomTo(this.__zoomLevel * change, false, pageX - this.__clientLeft, pageY - this.__clientTop); 560 | }, 561 | 562 | 563 | /** 564 | * Touch start handler for scrolling support 565 | */ 566 | doTouchStart : function (touches, timeStamp) { 567 | // Array-like check is enough here 568 | if (touches.length === undefined) { 569 | throw new Error("Invalid touch list: " + touches); 570 | } 571 | 572 | if (timeStamp instanceof Date) { 573 | timeStamp = timeStamp.valueOf(); 574 | } 575 | if (typeof timeStamp !== "number") { 576 | throw new Error("Invalid timestamp value: " + timeStamp); 577 | } 578 | 579 | // Reset interruptedAnimation flag 580 | this.__interruptedAnimation = true; 581 | 582 | // Stop deceleration 583 | if (this.__isDecelerating) { 584 | animate.stop(this.__isDecelerating); 585 | this.__isDecelerating = false; 586 | this.__interruptedAnimation = true; 587 | } 588 | 589 | // Stop animation 590 | if (this.__isAnimating) { 591 | animate.stop(this.__isAnimating); 592 | this.__isAnimating = false; 593 | this.__interruptedAnimation = true; 594 | } 595 | 596 | // Use center point when dealing with two fingers 597 | var currentTouchLeft, currentTouchTop; 598 | var isSingleTouch = touches.length === 1; 599 | if (isSingleTouch) { 600 | currentTouchLeft = touches[0].pageX; 601 | currentTouchTop = touches[0].pageY; 602 | } else { 603 | currentTouchLeft = Math.abs(touches[0].pageX + touches[1].pageX) / 2; 604 | currentTouchTop = Math.abs(touches[0].pageY + touches[1].pageY) / 2; 605 | } 606 | 607 | // Store initial positions 608 | this.__initialTouchLeft = currentTouchLeft; 609 | this.__initialTouchTop = currentTouchTop; 610 | 611 | // Store current zoom level 612 | this.__zoomLevelStart = this.__zoomLevel; 613 | 614 | // Store initial touch positions 615 | this.__lastTouchLeft = currentTouchLeft; 616 | this.__lastTouchTop = currentTouchTop; 617 | 618 | // Store initial move time stamp 619 | this.__lastTouchMove = timeStamp; 620 | 621 | // Reset initial scale 622 | this.__lastScale = 1; 623 | 624 | // Reset locking flags 625 | this.__enableScrollX = !isSingleTouch && this.options.scrollingX; 626 | this.__enableScrollY = !isSingleTouch && this.options.scrollingY; 627 | 628 | // Reset tracking flag 629 | this.__isTracking = true; 630 | 631 | // Reset deceleration complete flag 632 | this.__didDecelerationComplete = false; 633 | 634 | // Dragging starts directly with two fingers, otherwise lazy with an offset 635 | this.__isDragging = !isSingleTouch; 636 | 637 | // Some features are disabled in multi touch scenarios 638 | this.__isSingleTouch = isSingleTouch; 639 | 640 | // Clearing data structure 641 | this.__positions = []; 642 | }, 643 | 644 | 645 | /** 646 | * Touch move handler for scrolling support 647 | * @param {Number} [1.0] scale - .... 648 | */ 649 | doTouchMove : function (touches, timeStamp, scale) { 650 | // Array-like check is enough here 651 | if (touches.length === undefined) { 652 | throw new Error("Invalid touch list: " + touches); 653 | } 654 | 655 | if (timeStamp instanceof Date) { 656 | timeStamp = timeStamp.valueOf(); 657 | } 658 | if (typeof timeStamp !== "number") { 659 | throw new Error("Invalid timestamp value: " + timeStamp); 660 | } 661 | 662 | // Ignore event when tracking is not enabled (event might be outside of element) 663 | if (!this.__isTracking) { 664 | return; 665 | } 666 | 667 | var currentTouchLeft, currentTouchTop; 668 | 669 | // Compute move based around of center of fingers 670 | if (touches.length === 2) { 671 | currentTouchLeft = Math.abs(touches[0].pageX + touches[1].pageX) / 2; 672 | currentTouchTop = Math.abs(touches[0].pageY + touches[1].pageY) / 2; 673 | } else { 674 | currentTouchLeft = touches[0].pageX; 675 | currentTouchTop = touches[0].pageY; 676 | } 677 | 678 | var positions = this.__positions; 679 | 680 | // Are we already is dragging mode? 681 | if (this.__isDragging) { 682 | // Compute move distance 683 | var moveX = currentTouchLeft - this.__lastTouchLeft; 684 | var moveY = currentTouchTop - this.__lastTouchTop; 685 | 686 | // Read previous scroll position and zooming 687 | var scrollLeft = this.__scrollLeft; 688 | var scrollTop = this.__scrollTop; 689 | var level = this.__zoomLevel; 690 | 691 | // Work with scaling 692 | if (scale !== undefined && this.options.zooming) { 693 | var oldLevel = level; 694 | 695 | // Recompute level based on previous scale and new scale 696 | level = level / this.__lastScale * scale; 697 | 698 | // Limit level according to configuration 699 | level = Math.max(Math.min(level, this.options.maxZoom), this.options.minZoom); 700 | 701 | // Only do further compution when change happened 702 | if (oldLevel !== level) { 703 | // Compute relative event position to container 704 | var currentTouchLeftRel = currentTouchLeft - this.__clientLeft; 705 | var currentTouchTopRel = currentTouchTop - this.__clientTop; 706 | 707 | // Recompute left and top coordinates based on new zoom level 708 | scrollLeft = ((currentTouchLeftRel + scrollLeft) * level / oldLevel) - currentTouchLeftRel; 709 | scrollTop = ((currentTouchTopRel + scrollTop) * level / oldLevel) - currentTouchTopRel; 710 | 711 | // Recompute max scroll values 712 | this.__computeScrollMax(level); 713 | } 714 | } 715 | 716 | if (this.__enableScrollX) { 717 | scrollLeft -= moveX * this.options.speedMultiplier; 718 | var maxScrollLeft = this.__maxScrollLeft; 719 | 720 | if (scrollLeft > maxScrollLeft || scrollLeft < 0) { 721 | // Slow down on the edges 722 | if (this.options.bouncing) { 723 | scrollLeft += (moveX / 2 * this.options.speedMultiplier); 724 | } else if (scrollLeft > maxScrollLeft) { 725 | scrollLeft = maxScrollLeft; 726 | } else { 727 | scrollLeft = 0; 728 | } 729 | } 730 | } 731 | 732 | // Compute new vertical scroll position 733 | if (this.__enableScrollY) { 734 | scrollTop -= moveY * this.options.speedMultiplier; 735 | var maxScrollTop = this.__maxScrollTop; 736 | 737 | if (scrollTop > maxScrollTop || scrollTop < 0) { 738 | // Slow down on the edges 739 | if (this.options.bouncing) { 740 | scrollTop += (moveY / 2 * this.options.speedMultiplier); 741 | } else if (scrollTop > maxScrollTop) { 742 | scrollTop = maxScrollTop; 743 | } else { 744 | scrollTop = 0; 745 | } 746 | } 747 | } 748 | 749 | // Keep list from growing infinitely (holding min 10, max 20 measure points) 750 | if (positions.length > 60) { 751 | positions.splice(0, 30); 752 | } 753 | 754 | // Track scroll movement for decleration 755 | positions.push(scrollLeft, scrollTop, timeStamp); 756 | 757 | // Sync scroll position 758 | this.__publish(scrollLeft, scrollTop, level); 759 | 760 | // Otherwise figure out whether we are switching into dragging mode now. 761 | } else { 762 | var minimumTrackingForScroll = this.options.locking ? 3 : 0; 763 | var minimumTrackingForDrag = 5; 764 | 765 | var distanceX = Math.abs(currentTouchLeft - this.__initialTouchLeft); 766 | var distanceY = Math.abs(currentTouchTop - this.__initialTouchTop); 767 | 768 | this.__enableScrollX = this.options.scrollingX && distanceX >= minimumTrackingForScroll; 769 | this.__enableScrollY = this.options.scrollingY && distanceY >= minimumTrackingForScroll; 770 | 771 | positions.push(this.__scrollLeft, this.__scrollTop, timeStamp); 772 | 773 | this.__isDragging = (this.__enableScrollX || this.__enableScrollY) && (distanceX >= minimumTrackingForDrag || distanceY >= minimumTrackingForDrag); 774 | if (this.__isDragging) { 775 | this.__interruptedAnimation = false; 776 | } 777 | } 778 | 779 | // Update last touch positions and time stamp for next event 780 | this.__lastTouchLeft = currentTouchLeft; 781 | this.__lastTouchTop = currentTouchTop; 782 | this.__lastTouchMove = timeStamp; 783 | this.__lastScale = scale; 784 | }, 785 | 786 | 787 | /** 788 | * Touch end handler for scrolling support 789 | */ 790 | doTouchEnd : function (timeStamp) { 791 | if (timeStamp instanceof Date) { 792 | timeStamp = timeStamp.valueOf(); 793 | } 794 | if (typeof timeStamp !== "number") { 795 | throw new Error("Invalid timestamp value: " + timeStamp); 796 | } 797 | 798 | // Ignore event when tracking is not enabled (no touchstart event on element) 799 | // This is required as this listener ('touchmove') sits on the document and not on the element itself. 800 | if (!this.__isTracking) { 801 | return; 802 | } 803 | 804 | // Not touching anymore (when two finger hit the screen there are two touch end events) 805 | this.__isTracking = false; 806 | 807 | // Be sure to reset the dragging flag now. Here we also detect whether 808 | // the finger has moved fast enough to switch into a deceleration animation. 809 | if (this.__isDragging) { 810 | // Reset dragging flag 811 | this.__isDragging = false; 812 | 813 | // Start deceleration 814 | // Verify that the last move detected was in some relevant time frame 815 | if (this.__isSingleTouch && this.options.animating && (timeStamp - this.__lastTouchMove) <= 100) { 816 | // Then figure out what the scroll position was about 100ms ago 817 | var positions = this.__positions; 818 | var endPos = positions.length - 1; 819 | var startPos = endPos; 820 | 821 | // Move pointer to position measured 100ms ago 822 | for (var i = endPos; i > 0 && positions[i] > (this.__lastTouchMove - 100); i -= 3) { 823 | startPos = i; 824 | } 825 | 826 | // If start and stop position is identical in a 100ms timeframe, 827 | // we cannot compute any useful deceleration. 828 | if (startPos !== endPos) { 829 | // Compute relative movement between these two points 830 | var timeOffset = positions[endPos] - positions[startPos]; 831 | var movedLeft = this.__scrollLeft - positions[startPos - 2]; 832 | var movedTop = this.__scrollTop - positions[startPos - 1]; 833 | 834 | // Based on 50ms compute the movement to apply for each render step 835 | this.__decelerationVelocityX = movedLeft / timeOffset * (1000 / 60); 836 | this.__decelerationVelocityY = movedTop / timeOffset * (1000 / 60); 837 | 838 | // How much velocity is required to start the deceleration 839 | var minVelocityToStartDeceleration = this.options.paging || this.options.snapping ? 4 : 1; 840 | 841 | // Verify that we have enough velocity to start deceleration 842 | if (Math.abs(this.__decelerationVelocityX) > minVelocityToStartDeceleration || Math.abs(this.__decelerationVelocityY) > minVelocityToStartDeceleration) { 843 | this.__startDeceleration(timeStamp); 844 | } 845 | } else { 846 | this.options.scrollingComplete(); 847 | } 848 | } else if ((timeStamp - this.__lastTouchMove) > 100) { 849 | this.options.scrollingComplete(); 850 | } 851 | } 852 | 853 | // If this was a slower move it is per default non decelerated, but this 854 | // still means that we want snap back to the bounds which is done here. 855 | // This is placed outside the condition above to improve edge case stability 856 | // e.g. touchend fired without enabled dragging. This should normally do not 857 | // have modified the scroll positions or even showed the scrollbars though. 858 | if (!this.__isDecelerating) { 859 | if (this.__interruptedAnimation || this.__isDragging) { 860 | this.options.scrollingComplete(); 861 | } 862 | this.scrollTo(this.__scrollLeft, this.__scrollTop, true, this.__zoomLevel); 863 | } 864 | 865 | // Fully cleanup list 866 | this.__positions.length = 0; 867 | }, 868 | 869 | 870 | 871 | /* 872 | --------------------------------------------------------------------------- 873 | PRIVATE API 874 | --------------------------------------------------------------------------- 875 | */ 876 | 877 | /** 878 | * Applies the scroll position to the content element 879 | * 880 | * @param left {Number} Left scroll position 881 | * @param top {Number} Top scroll position 882 | * @param isAnimated {Boolean?false} Whether animation should be used to move to the new coordinates 883 | */ 884 | __publish : function (left, top, zoom, isAnimated) { 885 | // Remember whether we had an animation, then we try to continue 886 | // based on the current "drive" of the animation. 887 | var wasAnimating = this.__isAnimating; 888 | if (wasAnimating) { 889 | animate.stop(wasAnimating); 890 | this.__isAnimating = false; 891 | } 892 | 893 | if (isAnimated && this.options.animating) { 894 | // Keep scheduled positions for scrollBy/zoomBy functionality. 895 | this.__scheduledLeft = left; 896 | this.__scheduledTop = top; 897 | this.__scheduledZoom = zoom; 898 | 899 | var oldLeft = this.__scrollLeft; 900 | var oldTop = this.__scrollTop; 901 | var oldZoom = this.__zoomLevel; 902 | 903 | var diffLeft = left - oldLeft; 904 | var diffTop = top - oldTop; 905 | var diffZoom = zoom - oldZoom; 906 | 907 | var step = function (percent, now, render) { 908 | if (render) { 909 | this.__scrollLeft = oldLeft + (diffLeft * percent); 910 | this.__scrollTop = oldTop + (diffTop * percent); 911 | this.__zoomLevel = oldZoom + (diffZoom * percent); 912 | 913 | // Push values out 914 | if (this.__callback) { 915 | this.__callback(this.__scrollLeft, this.__scrollTop, this.__zoomLevel); 916 | } 917 | } 918 | }.bind(this); 919 | 920 | var verify = function (id) { 921 | return this.__isAnimating === id; 922 | }.bind(this); 923 | 924 | var completed = function (renderedFramesPerSecond, animationId, wasFinished) { 925 | if (animationId === this.__isAnimating) { 926 | this.__isAnimating = false; 927 | } 928 | if (this.__didDecelerationComplete || wasFinished) { 929 | this.options.scrollingComplete(); 930 | } 931 | 932 | if (this.options.zooming) { 933 | this.__computeScrollMax(); 934 | if (this.__zoomComplete) { 935 | this.__zoomComplete(); 936 | this.__zoomComplete = null; 937 | } 938 | } 939 | }.bind(this); 940 | 941 | // When continuing based on previous animation we choose an ease-out animation instead of ease-in-out 942 | this.__isAnimating = animate.start(step, verify, completed, this.options.animationDuration, wasAnimating ? easeOutCubic : easeInOutCubic); 943 | 944 | } else { 945 | this.__scheduledLeft = this.__scrollLeft = left; 946 | this.__scheduledTop = this.__scrollTop = top; 947 | this.__scheduledZoom = this.__zoomLevel = zoom; 948 | 949 | // Push values out 950 | if (this.__callback) { 951 | this.__callback(left, top, zoom); 952 | } 953 | 954 | // Fix max scroll ranges 955 | if (this.options.zooming) { 956 | this.__computeScrollMax(); 957 | if (this.__zoomComplete) { 958 | this.__zoomComplete(); 959 | this.__zoomComplete = null; 960 | } 961 | } 962 | } 963 | }, 964 | 965 | 966 | /** 967 | * Recomputes scroll minimum values based on client dimensions and content dimensions. 968 | */ 969 | __computeScrollMax : function (zoomLevel) { 970 | if (zoomLevel === undefined) { 971 | zoomLevel = this.__zoomLevel; 972 | } 973 | 974 | this.__maxScrollLeft = Math.max(this.__contentWidth*zoomLevel - this.__clientWidth, 0); 975 | this.__maxScrollTop = Math.max(this.__contentHeight*zoomLevel - this.__clientHeight, 0); 976 | }, 977 | 978 | 979 | 980 | /* 981 | --------------------------------------------------------------------------- 982 | ANIMATION (DECELERATION) SUPPORT 983 | --------------------------------------------------------------------------- 984 | */ 985 | 986 | /** 987 | * Called when a touch sequence end and the speed of the finger was high enough 988 | * to switch into deceleration mode. 989 | */ 990 | __startDeceleration : function (timeStamp) { 991 | if (this.options.paging) { 992 | var scrollLeft = Math.max(Math.min(this.__scrollLeft, this.__maxScrollLeft), 0); 993 | var scrollTop = Math.max(Math.min(this.__scrollTop, this.__maxScrollTop), 0); 994 | var clientWidth = this.__clientWidth; 995 | var clientHeight = this.__clientHeight; 996 | 997 | // We limit deceleration not to the min/max values of the allowed range, but to the size of the visible client area. 998 | // Each page should have exactly the size of the client area. 999 | this.__minDecelerationScrollLeft = Math.floor(scrollLeft / clientWidth) * clientWidth; 1000 | this.__minDecelerationScrollTop = Math.floor(scrollTop / clientHeight) * clientHeight; 1001 | this.__maxDecelerationScrollLeft = Math.ceil(scrollLeft / clientWidth) * clientWidth; 1002 | this.__maxDecelerationScrollTop = Math.ceil(scrollTop / clientHeight) * clientHeight; 1003 | } else { 1004 | this.__minDecelerationScrollLeft = 0; 1005 | this.__minDecelerationScrollTop = 0; 1006 | this.__maxDecelerationScrollLeft = this.__maxScrollLeft; 1007 | this.__maxDecelerationScrollTop = this.__maxScrollTop; 1008 | } 1009 | 1010 | // Wrap class method 1011 | var step = function (percent, now, render) { 1012 | this.__stepThroughDeceleration(render); 1013 | }.bind(this); 1014 | 1015 | // How much velocity is required to keep the deceleration running 1016 | var minVelocityToKeepDecelerating = this.options.snapping ? 4 : 0.1; 1017 | 1018 | // Detect whether it's still worth to continue animating steps 1019 | // If we are already slow enough to not being user perceivable anymore, we stop the whole process here. 1020 | var verify = function () { 1021 | var shouldContinue = Math.abs(this.__decelerationVelocityX) >= minVelocityToKeepDecelerating || Math.abs(this.__decelerationVelocityY) >= minVelocityToKeepDecelerating; 1022 | if (!shouldContinue) { 1023 | this.__didDecelerationComplete = true; 1024 | } 1025 | return shouldContinue; 1026 | }.bind(this); 1027 | 1028 | var completed = function (renderedFramesPerSecond, animationId, wasFinished) { 1029 | this.__isDecelerating = false; 1030 | if (this.__didDecelerationComplete) { 1031 | this.options.scrollingComplete(); 1032 | } 1033 | 1034 | // Animate to grid when snapping is active, otherwise just fix out-of-boundary positions 1035 | this.scrollTo(this.__scrollLeft, this.__scrollTop, this.options.snapping); 1036 | }.bind(this); 1037 | 1038 | // Start animation and switch on flag 1039 | this.__isDecelerating = animate.start(step, verify, completed); 1040 | }, 1041 | 1042 | 1043 | /** 1044 | * Called on every step of the animation 1045 | * 1046 | * @param inMemory {Boolean?false} Whether to not render the current step, but keep it in memory only. Used internally only! 1047 | */ 1048 | __stepThroughDeceleration : function (render) { 1049 | 1050 | // 1051 | // COMPUTE NEXT SCROLL POSITION 1052 | // 1053 | 1054 | // Add deceleration to scroll position 1055 | var scrollLeft = this.__scrollLeft + this.__decelerationVelocityX; 1056 | var scrollTop = this.__scrollTop + this.__decelerationVelocityY; 1057 | 1058 | 1059 | // 1060 | // HARD LIMIT SCROLL POSITION FOR NON BOUNCING MODE 1061 | // 1062 | 1063 | if (!this.options.bouncing) { 1064 | var scrollLeftFixed = Math.max(Math.min(this.__maxDecelerationScrollLeft, scrollLeft), this.__minDecelerationScrollLeft); 1065 | if (scrollLeftFixed !== scrollLeft) { 1066 | scrollLeft = scrollLeftFixed; 1067 | this.__decelerationVelocityX = 0; 1068 | } 1069 | 1070 | var scrollTopFixed = Math.max(Math.min(this.__maxDecelerationScrollTop, scrollTop), this.__minDecelerationScrollTop); 1071 | if (scrollTopFixed !== scrollTop) { 1072 | scrollTop = scrollTopFixed; 1073 | this.__decelerationVelocityY = 0; 1074 | } 1075 | } 1076 | 1077 | 1078 | // 1079 | // UPDATE SCROLL POSITION 1080 | // 1081 | 1082 | if (render) { 1083 | this.__publish(scrollLeft, scrollTop, this.__zoomLevel); 1084 | } else { 1085 | this.__scrollLeft = scrollLeft; 1086 | this.__scrollTop = scrollTop; 1087 | } 1088 | 1089 | 1090 | // 1091 | // SLOW DOWN 1092 | // 1093 | 1094 | // Slow down velocity on every iteration 1095 | if (!this.options.paging) { 1096 | // This is the factor applied to every iteration of the animation 1097 | // to slow down the process. This should emulate natural behavior where 1098 | // objects slow down when the initiator of the movement is removed 1099 | var frictionFactor = 0.95; 1100 | 1101 | this.__decelerationVelocityX *= frictionFactor; 1102 | this.__decelerationVelocityY *= frictionFactor; 1103 | } 1104 | 1105 | 1106 | // 1107 | // BOUNCING SUPPORT 1108 | // 1109 | 1110 | if (this.options.bouncing) { 1111 | var scrollOutsideX = 0; 1112 | var scrollOutsideY = 0; 1113 | 1114 | // This configures the amount of change applied to deceleration/acceleration when reaching boundaries 1115 | var penetrationDeceleration = this.options.penetrationDeceleration; 1116 | var penetrationAcceleration = this.options.penetrationAcceleration; 1117 | 1118 | // Check limits 1119 | if (scrollLeft < this.__minDecelerationScrollLeft) { 1120 | scrollOutsideX = this.__minDecelerationScrollLeft - scrollLeft; 1121 | } else if (scrollLeft > this.__maxDecelerationScrollLeft) { 1122 | scrollOutsideX = this.__maxDecelerationScrollLeft - scrollLeft; 1123 | } 1124 | 1125 | if (scrollTop < this.__minDecelerationScrollTop) { 1126 | scrollOutsideY = this.__minDecelerationScrollTop - scrollTop; 1127 | } else if (scrollTop > this.__maxDecelerationScrollTop) { 1128 | scrollOutsideY = this.__maxDecelerationScrollTop - scrollTop; 1129 | } 1130 | 1131 | // Slow down until slow enough, then flip back to snap position 1132 | if (scrollOutsideX !== 0) { 1133 | if (scrollOutsideX * this.__decelerationVelocityX <= 0) { 1134 | this.__decelerationVelocityX += scrollOutsideX * penetrationDeceleration; 1135 | } else { 1136 | this.__decelerationVelocityX = scrollOutsideX * penetrationAcceleration; 1137 | } 1138 | } 1139 | 1140 | if (scrollOutsideY !== 0) { 1141 | if (scrollOutsideY * this.__decelerationVelocityY <= 0) { 1142 | this.__decelerationVelocityY += scrollOutsideY * penetrationDeceleration; 1143 | } else { 1144 | this.__decelerationVelocityY = scrollOutsideY * penetrationAcceleration; 1145 | } 1146 | } 1147 | } 1148 | } 1149 | }; 1150 | 1151 | return Scroller; 1152 | })); 1153 | -------------------------------------------------------------------------------- /lib/animate.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Scroller 3 | * http://github.com/zynga/scroller 4 | * 5 | * Copyright 2011, Zynga Inc. 6 | * Licensed under the MIT License. 7 | * https://raw.github.com/zynga/scroller/master/MIT-LICENSE.txt 8 | * 9 | * Based on the work of: Unify Project (unify-project.org) 10 | * http://unify-project.org 11 | * Copyright 2011, Deutsche Telekom AG 12 | * License: MIT + Apache (V2) 13 | */ 14 | 15 | /** 16 | * Generic animation class with support for dropped frames both optional easing and duration. 17 | * 18 | * Optional duration is useful when the lifetime is defined by another condition than time 19 | * e.g. speed of an animating object, etc. 20 | * 21 | * Dropped frame logic allows to keep using the same updater logic independent from the actual 22 | * rendering. This eases a lot of cases where it might be pretty complex to break down a state 23 | * based on the pure time difference. 24 | */ 25 | (function (root, factory) { 26 | if (typeof define === 'function' && define.amd) { 27 | // AMD. Register as an anonymous module. 28 | define(['exports'], factory); 29 | } else if (typeof exports === 'object') { 30 | // CommonJS 31 | factory(exports); 32 | } else { 33 | // Browser globals 34 | factory((root.animate = {})); 35 | } 36 | }(this, function (exports) { 37 | var global = typeof window === 'undefined' ? this : window 38 | var time = Date.now || function () { 39 | return +new Date(); 40 | }; 41 | var desiredFrames = 60; 42 | var millisecondsPerSecond = 1000; 43 | var running = {}; 44 | var counter = 1; 45 | 46 | /** 47 | * A requestAnimationFrame wrapper / polyfill. 48 | * 49 | * @param callback {Function} The callback to be invoked before the next repaint. 50 | * @param root {HTMLElement} The root element for the repaint 51 | */ 52 | exports.requestAnimationFrame = (function () { 53 | // Check for request animation Frame support 54 | var requestFrame = global.requestAnimationFrame || global.webkitRequestAnimationFrame || global.mozRequestAnimationFrame || global.oRequestAnimationFrame; 55 | var isNative = !!requestFrame; 56 | 57 | if (requestFrame && !/requestAnimationFrame\(\)\s*\{\s*\[native code\]\s*\}/i.test(requestFrame.toString())) { 58 | isNative = false; 59 | } 60 | 61 | if (isNative) { 62 | return function (callback, root) { 63 | requestFrame(callback, root); 64 | }; 65 | } 66 | 67 | var TARGET_FPS = 60; 68 | var requests = {}; 69 | var requestCount = 0; 70 | var rafHandle = 1; 71 | var intervalHandle = null; 72 | var lastActive = +new Date(); 73 | 74 | return function (callback, root) { 75 | var callbackHandle = rafHandle++; 76 | 77 | // Store callback 78 | requests[callbackHandle] = callback; 79 | requestCount++; 80 | 81 | // Create timeout at first request 82 | if (intervalHandle === null) { 83 | 84 | intervalHandle = setInterval(function () { 85 | 86 | var time = +new Date(); 87 | var currentRequests = requests; 88 | 89 | // Reset data structure before executing callbacks 90 | requests = {}; 91 | requestCount = 0; 92 | 93 | for(var key in currentRequests) { 94 | if (currentRequests.hasOwnProperty(key)) { 95 | currentRequests[key](time); 96 | lastActive = time; 97 | } 98 | } 99 | 100 | // Disable the timeout when nothing happens for a certain 101 | // period of time 102 | if (time - lastActive > 2500) { 103 | clearInterval(intervalHandle); 104 | intervalHandle = null; 105 | } 106 | 107 | }, 1000 / TARGET_FPS); 108 | } 109 | 110 | return callbackHandle; 111 | }; 112 | 113 | })(); 114 | 115 | /** 116 | * Stops the given animation. 117 | * 118 | * @param id {Integer} Unique animation ID 119 | * @return {Boolean} Whether the animation was stopped (aka, was running before) 120 | */ 121 | exports.stop = function (id) { 122 | var cleared = (running[id] !== null); 123 | if (cleared) { 124 | running[id] = null; 125 | } 126 | 127 | return cleared; 128 | }; 129 | 130 | 131 | /** 132 | * Whether the given animation is still running. 133 | * 134 | * @param id {Integer} Unique animation ID 135 | * @return {Boolean} Whether the animation is still running 136 | */ 137 | exports.isRunning = function (id) { 138 | return running[id] !== null; 139 | }; 140 | 141 | 142 | /** 143 | * Start the animation. 144 | * 145 | * @param stepCallback {Function} Pointer to function which is executed on every step. 146 | * Signature of the method should be `function(percent, now, virtual) { return continueWithAnimation; }` 147 | * @param verifyCallback {Function} Executed before every animation step. 148 | * Signature of the method should be `function() { return continueWithAnimation; }` 149 | * @param completedCallback {Function} 150 | * Signature of the method should be `function(droppedFrames, finishedAnimation, optional wasFinished) {}` 151 | * @param duration {Integer} Milliseconds to run the animation 152 | * @param easingMethod {Function} Pointer to easing function 153 | * Signature of the method should be `function(percent) { return modifiedValue; }` 154 | * @param root {Element} Render root. Used for internal usage of requestAnimationFrame. 155 | * @return {Integer} Identifier of animation. Can be used to stop it any time. 156 | */ 157 | exports.start = function (stepCallback, verifyCallback, completedCallback, duration, easingMethod, root) { 158 | var start = time(); 159 | var lastFrame = start; 160 | var percent = 0; 161 | var dropCounter = 0; 162 | var id = counter++; 163 | 164 | // Compacting running db automatically every few new animations 165 | if (id % 20 === 0) { 166 | var newRunning = {}; 167 | for (var usedId in running) { 168 | newRunning[usedId] = true; 169 | } 170 | running = newRunning; 171 | } 172 | 173 | // This is the internal step method which is called every few milliseconds 174 | var step = function (virtual) { 175 | 176 | // Normalize virtual value 177 | var render = virtual !== true; 178 | 179 | // Get current time 180 | var now = time(); 181 | 182 | // Verification is executed before next animation step 183 | if (!running[id] || (verifyCallback && !verifyCallback(id))) { 184 | 185 | running[id] = null; 186 | completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, false); 187 | return; 188 | 189 | } 190 | 191 | // For the current rendering to apply let's update omitted steps in memory. 192 | // This is important to bring internal state variables up-to-date with progress in time. 193 | if (render) { 194 | 195 | var droppedFrames = Math.round((now - lastFrame) / (millisecondsPerSecond / desiredFrames)) - 1; 196 | for (var j = 0; j < Math.min(droppedFrames, 4); j++) { 197 | step(true); 198 | dropCounter++; 199 | } 200 | 201 | } 202 | 203 | // Compute percent value 204 | if (duration) { 205 | percent = (now - start) / duration; 206 | if (percent > 1) { 207 | percent = 1; 208 | } 209 | } 210 | 211 | // Execute step callback, then... 212 | var value = easingMethod ? easingMethod(percent) : percent; 213 | if ((stepCallback(value, now, render) === false || percent === 1) && render) { 214 | running[id] = null; 215 | completedCallback(desiredFrames - (dropCounter / ((now - start) / millisecondsPerSecond)), id, percent === 1 || duration === undefined); 216 | } else if (render) { 217 | lastFrame = now; 218 | exports.requestAnimationFrame(step, root); 219 | } 220 | }; 221 | 222 | // Mark as running 223 | running[id] = true; 224 | 225 | // Init first step 226 | exports.requestAnimationFrame(step, root); 227 | 228 | // Return unique animation ID 229 | return id; 230 | }; 231 | })); 232 | -------------------------------------------------------------------------------- /lib/numericEqual.js: -------------------------------------------------------------------------------- 1 | module.exports = function (lhs, rhs, epsilon) { 2 | if (epsilon === undefined) { 3 | epsilon = Math.pow(10, -14); 4 | } 5 | 6 | return Math.abs(lhs/rhs) - 1.0 < epsilon; 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scroller", 3 | "version": "0.0.3", 4 | "description": "Accelerated panning and zooming for HTML and Canvas", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "./node_modules/.bin/mocha test" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/popham/scroller.git" 15 | }, 16 | "keywords": [ 17 | "Scrolling", 18 | "Scroll", 19 | "Scroller", 20 | "Touch" 21 | ], 22 | "author": "Tim Popham", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/popham/scroller/issues" 26 | }, 27 | "homepage": "https://github.com/popham/scroller", 28 | "devDependencies": { 29 | "mocha": "^1.20.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/animation.js: -------------------------------------------------------------------------------- 1 | var Scroller = require('../lib/Scroller'); 2 | var assert = require('assert'); 3 | 4 | describe("Animation", function () { 5 | it("should scroll to the sought position", function (done) { 6 | var scroller = new Scroller(null, { animationDuration : 900 }); 7 | assert.equal(typeof scroller, "object"); 8 | 9 | scroller.setDimensions(1000, 600, 5000, 5000); 10 | scroller.scrollTo(300, 400, true); 11 | 12 | setTimeout(function() { 13 | var values = scroller.getValues(); 14 | assert.equal(values.left, 300); 15 | assert.equal(values.top, 400); 16 | assert.equal(values.zoom, 1); 17 | done(); 18 | }, 1000); 19 | }); 20 | 21 | it("should zoom to the sought state", function (done) { 22 | var scroller = new Scroller(null, { 23 | animationDuration : 900, 24 | zooming : true 25 | }); 26 | 27 | assert.equal(typeof scroller, "object"); 28 | scroller.setDimensions(1000, 600, 5000, 5000); 29 | scroller.zoomTo(2, true); 30 | 31 | setTimeout(function() { 32 | var values = scroller.getValues(); 33 | 34 | // zooming is centered automatically 35 | assert.equal(values.left, 500); 36 | assert.equal(values.top, 300); 37 | assert.equal(values.zoom, 2); 38 | 39 | done(); 40 | }, 1000); 41 | }); 42 | 43 | it("should compose scrolling and zooming", function(done) { 44 | var scroller = new Scroller(null, { 45 | animationDuration : 900, 46 | zooming : true 47 | }); 48 | 49 | assert.equal(typeof scroller, "object"); 50 | scroller.setDimensions(1000, 600, 5000, 5000); 51 | 52 | var max = scroller.getScrollMax(); 53 | assert.equal(max.left, 5000-1000); 54 | assert.equal(max.top, 5000-600); 55 | 56 | scroller.scrollTo(300, 400, true, 2); 57 | 58 | setTimeout(function() { 59 | var values = scroller.getValues(); 60 | 61 | assert.equal(values.left, 600); 62 | assert.equal(values.top, 800); 63 | assert.equal(values.zoom, 2); 64 | 65 | var max = scroller.getScrollMax(); 66 | assert.equal(max.left, 5000*2 - 1000); 67 | assert.equal(max.top, 5000*2 - 600); 68 | 69 | done(); 70 | }, 1000); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/animationInterrupt.js: -------------------------------------------------------------------------------- 1 | var Scroller = require('../lib/Scroller'); 2 | var assert = require('assert'); 3 | 4 | describe("Mixed animation and static movement", function () { 5 | it("should truncate animated scrolling with static scrolling", function (done) { 6 | var scroller = new Scroller(null, { animationDuration : 900 }); 7 | assert.equal(typeof scroller, "object"); 8 | 9 | scroller.setDimensions(1000, 600, 5000, 5000); 10 | scroller.scrollTo(300, 400, true); 11 | 12 | setTimeout(function() { 13 | scroller.scrollTo(500, 800); 14 | 15 | var values = scroller.getValues(); 16 | assert.equal(values.left, 500); 17 | assert.equal(values.top, 800); 18 | assert.equal(values.zoom, 1); 19 | 20 | done(); 21 | }, 500); 22 | }); 23 | 24 | it("should truncate animated scrolling with static zooming", function (done) { 25 | var scroller = new Scroller(null, { 26 | animationDuration : 900, 27 | zooming : true 28 | }); 29 | assert.equal(typeof scroller, "object"); 30 | 31 | scroller.setDimensions(1000, 600, 5000, 5000); 32 | scroller.scrollTo(300, 400, true); 33 | 34 | setTimeout(function() { 35 | scroller.zoomTo(2); 36 | 37 | setTimeout(function () { 38 | var values = scroller.getValues(); 39 | 40 | assert.notEqual(values.left, 300); 41 | assert.notEqual(values.top, 400); 42 | assert.equal(values.zoom, 2); 43 | 44 | done(); 45 | }, 1000); 46 | 47 | var values = scroller.getValues(); 48 | 49 | // Scroll position has not reached final position yet 50 | assert.notEqual(values.left, 300); 51 | assert.notEqual(values.top, 400); 52 | assert.equal(values.zoom, 2); 53 | 54 | // Scroll max has values based on final zoom 55 | var max = scroller.getScrollMax(); 56 | assert.equal(max.left, 5000*2 - 1000); 57 | assert.equal(max.top, 5000*2 - 600); 58 | }, 500); 59 | }); 60 | 61 | it("should truncate animated zooming with static zooming", function (done) { 62 | var scroller = new Scroller(null, { 63 | animationDuration : 900, 64 | zooming : true 65 | }); 66 | assert.equal(typeof scroller, "object"); 67 | 68 | scroller.setDimensions(1000, 600, 5000, 5000); 69 | scroller.zoomTo(2, true); 70 | 71 | setTimeout(function() { 72 | scroller.zoomTo(3); 73 | 74 | setTimeout(function () { 75 | var values = scroller.getValues(); 76 | assert.equal(values.zoom, 3); 77 | 78 | done(); 79 | }, 1000); 80 | 81 | var values = scroller.getValues(); 82 | assert.equal(values.zoom, 3); 83 | }, 500); 84 | }); 85 | 86 | it("should truncate animated zooming with static scrolling", function (done) { 87 | var scroller = new Scroller(null, { 88 | animationDuration : 900, 89 | zooming : true 90 | }); 91 | assert.equal(typeof scroller, "object"); 92 | 93 | scroller.setDimensions(1000, 600, 5000, 5000); 94 | scroller.zoomTo(2, true); 95 | 96 | setTimeout(function() { 97 | scroller.scrollTo(300, 400); 98 | 99 | setTimeout(function () { 100 | var values = scroller.getValues(); 101 | 102 | assert.equal(values.left, 300); 103 | assert.equal(values.top, 400); 104 | assert.notEqual(values.zoom, 2); 105 | 106 | var max = scroller.getScrollMax(); 107 | assert.notEqual(max.left, (5000 - 1000)*2); 108 | assert.notEqual(max.top, (5000 - 600)*2); 109 | 110 | done(); 111 | }, 1000); 112 | 113 | var values = scroller.getValues(); 114 | 115 | // Zoom level can not have reached final position yet 116 | assert.equal(values.left, 300); 117 | assert.equal(values.top, 400); 118 | assert.notEqual(values.zoom, 2); 119 | 120 | // Scroll max takes on different values because they are predicated 121 | // on current zoom 122 | var max = scroller.getScrollMax(); 123 | assert.notEqual(max.left, (5000 - 1000)*2); 124 | assert.notEqual(max.top, (5000 - 600)*2); 125 | }, 500); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /test/animationOverlap.js: -------------------------------------------------------------------------------- 1 | var Scroller = require('../lib/Scroller'); 2 | var assert = require('assert'); 3 | 4 | describe("Overlapping animation", function (done) { 5 | it("should compose two scrolls", function (done) { 6 | var scroller = new Scroller(null, { animationDuration : 900 }); 7 | assert.equal(typeof scroller, "object"); 8 | 9 | scroller.setDimensions(1000, 600, 5000, 5000); 10 | scroller.scrollTo(300, 400, true); 11 | 12 | setTimeout(function () { 13 | scroller.scrollTo(500, 800, true); 14 | 15 | setTimeout(function () { 16 | var values = scroller.getValues(); 17 | 18 | assert.equal(values.left, 500); 19 | assert.equal(values.top, 800); 20 | assert.equal(values.zoom, 1); 21 | 22 | done(); 23 | }, 1000); 24 | 25 | var values = scroller.getValues(); 26 | assert.notEqual(values.left, 500); 27 | assert.notEqual(values.top, 800); 28 | assert.equal(values.zoom, 1); 29 | }, 500); 30 | }); 31 | 32 | it("should interrupt scrolling with a zoom", function(done) { 33 | var scroller = new Scroller(null, { 34 | animationDuration : 900, 35 | zooming : true 36 | }); 37 | assert.equal(typeof scroller, "object"); 38 | 39 | scroller.setDimensions(1000, 600, 5000, 5000); 40 | scroller.scrollTo(300, 400, true); 41 | 42 | setTimeout(function() { 43 | scroller.zoomTo(2, true); 44 | 45 | setTimeout(function () { 46 | var values = scroller.getValues(); 47 | 48 | assert.notEqual(values.left, 300); 49 | assert.notEqual(values.top, 400); 50 | assert.equal(values.zoom, 2); 51 | 52 | done() 53 | }, 1000); 54 | 55 | var values = scroller.getValues(); 56 | 57 | // Scroll position has not finalized 58 | assert.notEqual(values.left, 300); 59 | assert.notEqual(values.top, 400); 60 | assert.notEqual(values.zoom, 2); 61 | 62 | // Scroll max values are based on final zoom 63 | var max = scroller.getScrollMax(); 64 | assert.equal(max.left, 5000*2 - 1000); 65 | assert.equal(max.top, 5000*2 - 600); 66 | }, 500); 67 | }); 68 | 69 | it("should compose two zooms", function(done) { 70 | var scroller = new Scroller(null, { 71 | animationDuration : 900, 72 | zooming : true 73 | }); 74 | assert.equal(typeof scroller, "object"); 75 | 76 | scroller.setDimensions(1000, 600, 5000, 5000); 77 | scroller.zoomTo(2, true); 78 | 79 | setTimeout(function () { 80 | scroller.zoomTo(3, true); 81 | 82 | setTimeout(function () { 83 | var values = scroller.getValues(); 84 | assert.equal(values.zoom, 3); 85 | done(); 86 | }, 1000); 87 | 88 | var values = scroller.getValues(); 89 | assert.notEqual(values.zoom, 2); 90 | }, 500); 91 | }); 92 | 93 | it("should interrupt a zoom with a scroll", function(done) { 94 | var scroller = new Scroller(null, { 95 | animationDuration : 900, 96 | zooming : true 97 | }); 98 | assert.equal(typeof scroller, "object"); 99 | 100 | scroller.setDimensions(1000, 600, 5000, 5000); 101 | scroller.zoomTo(2, true); 102 | 103 | setTimeout(function() { 104 | scroller.scrollTo(300, 400, true); 105 | setTimeout(function () { 106 | var values = scroller.getValues(); 107 | 108 | // Zoom level has not finalized 109 | assert.equal(values.left, 300); 110 | assert.equal(values.top, 400); 111 | assert.notEqual(values.zoom, 2); 112 | 113 | done(); 114 | }, 1000); 115 | 116 | var values = scroller.getValues(); 117 | 118 | // Still animated 119 | assert.notEqual(values.left, 300); 120 | assert.notEqual(values.top, 400); 121 | assert.notEqual(values.zoom, 2); 122 | 123 | // Scroll max has different values because it is not based on final 124 | // zoom, but current zoom 125 | var max = scroller.getScrollMax(); 126 | assert.equal(max.left, 5000*2 - 1000); 127 | assert.equal(max.top, 5000*2 - 600); 128 | }, 500); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /test/autoposition.js: -------------------------------------------------------------------------------- 1 | var Scroller = require('../lib/Scroller'); 2 | var assert = require('assert'); 3 | 4 | describe("Automatic positioning", function () { 5 | it("should snap to specified tiles", function() { 6 | var scroller = new Scroller(null, { snapping : true }); 7 | 8 | scroller.setDimensions(1000, 600, 5000, 5000); 9 | scroller.setSnapSize(50, 100); 10 | 11 | scroller.scrollTo(200, 400); 12 | var values = scroller.getValues(); 13 | assert.equal(values.left, 200); 14 | assert.equal(values.top, 400); 15 | 16 | scroller.scrollTo(237, 124); 17 | var values = scroller.getValues(); 18 | assert.equal(values.left, 250); 19 | assert.equal(values.top, 100); 20 | }); 21 | 22 | it("should paginate to the specified dimension", function() { 23 | var scroller = new Scroller(null, { paging : true }); 24 | 25 | scroller.setDimensions(1000, 600, 5000, 5000); 26 | 27 | scroller.scrollTo(1000, 600); 28 | var values = scroller.getValues(); 29 | assert.equal(values.left, 1000); 30 | assert.equal(values.top, 600); 31 | 32 | scroller.scrollTo(1400, 1100); 33 | var values = scroller.getValues(); 34 | assert.equal(values.left, 1000); 35 | assert.equal(values.top, 1200); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/boundaries.js: -------------------------------------------------------------------------------- 1 | var Scroller = require('../lib/Scroller'); 2 | var assert = require('assert'); 3 | 4 | describe("Boundaries", function () { 5 | it("should project scrolling into feasible window", function() { 6 | var scroller = new Scroller(); 7 | 8 | // Scroll without dimensions 9 | scroller.scrollTo(200, 300); 10 | var values = scroller.getValues(); 11 | assert.equal(values.left, 0); 12 | assert.equal(values.top, 0); 13 | 14 | // Setup 15 | scroller.setDimensions(1000, 600, 5000, 5000); 16 | 17 | // Scroll out of max 18 | scroller.scrollTo(10000, 10000); 19 | var values = scroller.getValues(); 20 | assert.equal(values.left, 4000); 21 | assert.equal(values.top, 4400); 22 | 23 | // Scroll out of min 24 | scroller.scrollTo(-30, -100); 25 | var values = scroller.getValues(); 26 | assert.equal(values.left, 0); 27 | assert.equal(values.top, 0); 28 | }); 29 | 30 | it("should project scrolling along a fixed axis to 0", function () { 31 | var scroller = new Scroller(null, { scrollingX : false }); 32 | 33 | scroller.setDimensions(1000, 600, 5000, 5000); 34 | scroller.scrollTo(300, 400); 35 | var values = scroller.getValues(); 36 | assert.equal(values.left, 0); 37 | assert.equal(values.top, 400); 38 | 39 | var scroller = new Scroller(null, { scrollingY : false }); 40 | 41 | scroller.setDimensions(1000, 600, 5000, 5000); 42 | scroller.scrollTo(300, 400); 43 | var values = scroller.getValues(); 44 | assert.equal(values.left, 300); 45 | assert.equal(values.top, 0); 46 | }); 47 | 48 | it("should constrain zooming to avoid leaving the feasible window", function() { 49 | var scroller = new Scroller(null, { zooming : true }); 50 | 51 | scroller.setDimensions(1000, 600, 5000, 5000); 52 | 53 | scroller.zoomTo(2); 54 | var values = scroller.getValues(); 55 | assert.equal(values.zoom, 2); 56 | 57 | scroller.zoomTo(20); 58 | var values = scroller.getValues(); 59 | assert.equal(values.zoom, 3); 60 | 61 | scroller.zoomTo(0.1); 62 | var values = scroller.getValues(); 63 | assert.equal(values.zoom, 0.5); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/events.js: -------------------------------------------------------------------------------- 1 | var Scroller = require('../lib/Scroller'); 2 | var assert = require('assert'); 3 | var numericEqual = require('../lib/numericEqual'); 4 | 5 | describe("Events", function () { 6 | it("should induce scrolling via Move and Acceleration", function () { 7 | var scroller = new Scroller(null, { animationDuration : 900 }); 8 | scroller.setDimensions(1000, 600, 5000, 5000); 9 | 10 | var now = 0; 11 | 12 | scroller.doTouchStart([{ 13 | pageX: 500, 14 | pageY: 700 15 | }], now+=40); 16 | 17 | scroller.doTouchMove([{ 18 | pageX: 490, 19 | pageY: 690 20 | }], now+=40); 21 | 22 | scroller.doTouchMove([{ 23 | pageX: 470, 24 | pageY: 670 25 | }], now+=40); 26 | 27 | scroller.doTouchEnd(now); 28 | 29 | var values = scroller.getValues(); 30 | assert.equal(values.left, 20); 31 | assert.equal(values.top, 20); 32 | 33 | setTimeout(function() { 34 | var values = scroller.getValues(); 35 | assert.equal(Math.round(values.left), 185); 36 | assert.equal(Math.round(values.top), 185); 37 | start(); 38 | }, 1000); 39 | }); 40 | 41 | it("should induce zooming via Wheel", function () { 42 | var scroller = new Scroller(null, { zooming : true }); 43 | scroller.setDimensions(1000, 600, 5000, 5000); 44 | 45 | var values = scroller.getValues(); 46 | assert.equal(values.left, 0); 47 | assert.equal(values.top, 0); 48 | assert.equal(values.zoom, 1); 49 | 50 | scroller.doMouseZoom(3, null, 0, 0); 51 | 52 | var values = scroller.getValues(); 53 | assert.equal(values.left, 0); 54 | assert.equal(values.top, 0); 55 | assert.ok(numericEqual(values.zoom, 0.97)); 56 | 57 | scroller.doMouseZoom(-3, null, 0, 0); 58 | 59 | var values = scroller.getValues(); 60 | assert.equal(values.left, 0); 61 | assert.equal(values.top, 0); 62 | assert.ok(numericEqual(values.zoom, 0.97*1.03)); 63 | 64 | // Reset 65 | scroller.zoomTo(1); 66 | scroller.scrollTo(300, 400); 67 | 68 | var coordinates = scroller.getPoint(300+200, 400+200); 69 | scroller.doMouseZoom(-3, null, 200, 200); 70 | scroller.doMouseZoom(-3, null, 200, 200); 71 | scroller.doMouseZoom(-3, null, 200, 200); 72 | scroller.doMouseZoom(-3, null, 200, 200); 73 | 74 | var k = 1.03 * 1.03 * 1.03 * 1.03; 75 | var newCoordinates = scroller.getPoint(k*(300+200), k*(400+200)); 76 | assert.ok(numericEqual(coordinates.left, newCoordinates.left)); 77 | assert.ok(numericEqual(coordinates.top, newCoordinates.top)); 78 | assert.ok(numericEqual(values.zoom, 1.03 * 1.03 * 1.03 * 1.03)); 79 | }); 80 | 81 | /* Unimplemented 82 | it("should induce zooming via Touch", function () { 83 | var scroller = new Scroller(null, { zooming : true }); 84 | scroller.setDimensions(1000, 600, 5000, 5000); 85 | 86 | var values = scroller.getValues(); 87 | assert.equal(values.left, 0); 88 | assert.equal(values.top, 0); 89 | assert.equal(values.zoom, 1); 90 | 91 | var now = 0; 92 | 93 | var first = { 94 | pageX: 250, 95 | pageY: 300 96 | }; 97 | 98 | var second = { 99 | pageX: 350, 100 | pageY: 400 101 | }; 102 | 103 | // Connect first finger 104 | scroller.doTouchStart([first], now+=20); 105 | 106 | // Connect second finger 107 | scroller.doTouchStart([first, second], now+=20); 108 | 109 | // Move fingers by 20px to middle (equal movement) 110 | first.pageX = 270; 111 | first.pageY = 320; 112 | second.pageX = 330; 113 | second.pageY = 380; 114 | 115 | scroller.doTouchMove([first, second], now+=20); 116 | 117 | scroller.doTouchEnd(now); 118 | 119 | var values = scroller.getValues(); 120 | assert.equal(values.left, 0); 121 | assert.equal(values.top, 0); 122 | assert.equal(values.zoom, 1); 123 | }); 124 | */ 125 | }); 126 | -------------------------------------------------------------------------------- /test/initialization.js: -------------------------------------------------------------------------------- 1 | var Scroller = require('../lib/Scroller'); 2 | var assert = require('assert'); 3 | 4 | describe("Initialization", function () { 5 | it("should construct scroller objects", function() { 6 | var scroller1 = new Scroller(); 7 | assert.equal(typeof scroller1, "object"); 8 | 9 | var scroller2 = new Scroller(function(left, top, zoom) {}); 10 | assert.equal(typeof scroller2, "object"); 11 | 12 | var scroller3 = new Scroller(null, { 13 | scrollingY: false 14 | }); 15 | assert.equal(typeof scroller3, "object"); 16 | 17 | var scroller4 = new Scroller(function(left, top, zoom) {}, { 18 | scrollingY: false 19 | }); 20 | assert.equal(typeof scroller4, "object"); 21 | }); 22 | 23 | it("should accept dimensions", function() { 24 | var scroller = new Scroller(); 25 | assert.equal(typeof scroller, "object"); 26 | scroller.setDimensions(1000, 600, 5000, 5000); 27 | }); 28 | 29 | it("should recall accepted dimensions", function() { 30 | var scroller = new Scroller(); 31 | assert.equal(typeof scroller, "object"); 32 | scroller.setDimensions(1000, 600, 5000, 5000); 33 | var values = scroller.getValues(); 34 | assert.equal(typeof values, "object"); 35 | assert.equal(values.left, 0); 36 | assert.equal(values.top, 0); 37 | assert.equal(values.zoom, 1); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/movement.js: -------------------------------------------------------------------------------- 1 | var Scroller = require('../lib/Scroller'); 2 | var assert = require('assert'); 3 | 4 | describe("Movement", function () { 5 | it("should follow scrolling", function() { 6 | var scroller = new Scroller(); 7 | assert.equal(typeof scroller, "object"); 8 | scroller.setDimensions(1000, 600, 5000, 5000); 9 | scroller.scrollTo(300, 500); 10 | 11 | var values = scroller.getValues(); 12 | assert.equal(typeof values, "object"); 13 | assert.equal(values.left, 300); 14 | assert.equal(values.top, 500); 15 | assert.equal(values.zoom, 1); 16 | }); 17 | 18 | it("should follow zooming", function() { 19 | var scroller = new Scroller(null, { zooming : true }); 20 | assert.equal(typeof scroller, "object"); 21 | scroller.setDimensions(1000, 600, 5000, 5000); 22 | scroller.zoomTo(2.45); 23 | 24 | var values = scroller.getValues(); 25 | assert.equal(typeof values, "object"); 26 | assert.equal(values.zoom, 2.45); 27 | }); 28 | 29 | it("should follow composed zooming and scrolling", function() { 30 | var scroller = new Scroller(null, { zooming : true }); 31 | assert.equal(typeof scroller, "object"); 32 | scroller.setDimensions(1000, 600, 5000, 5000); 33 | scroller.zoomTo(1.7); 34 | scroller.scrollTo(300, 500); 35 | 36 | var values = scroller.getValues(); 37 | assert.equal(typeof values, "object"); 38 | assert.equal(values.left, 300); 39 | assert.equal(values.top, 500); 40 | assert.equal(values.zoom, 1.7); 41 | }); 42 | 43 | it("should follow composed scrolling and zooming", function () { 44 | var scroller = new Scroller(null, { zooming : true }); 45 | assert.equal(typeof scroller, "object"); 46 | scroller.setDimensions(1000, 600, 5000, 5000); 47 | scroller.scrollTo(300, 500); 48 | var coordinates = scroller.getPoint(1200, 800); 49 | 50 | scroller.zoomTo(2.0, false, 1200-300, 800-500); 51 | var newCoordinates = scroller.getPoint(2400, 1600); 52 | 53 | assert.equal(newCoordinates.left, coordinates.left); 54 | assert.equal(newCoordinates.top, coordinates.top); 55 | }); 56 | 57 | it("should follow composed scrolling and zooming without an explicit zoom origin", function() { 58 | var scroller = new Scroller(null, { zooming : true }); 59 | assert.equal(typeof scroller, "object"); 60 | scroller.setDimensions(1000, 600, 5000, 5000); 61 | scroller.scrollTo(300, 500); 62 | scroller.zoomTo(1.7); 63 | 64 | var originLeft = 1000 / 2; 65 | var originTop = 600 / 2; 66 | 67 | // Compute center zooming 68 | var newLeft = ((originLeft + 300) * 1.7) - originLeft; 69 | var newTop = ((originTop + 500) * 1.7) - originTop; 70 | 71 | var values = scroller.getValues(); 72 | assert.equal(typeof values, "object"); 73 | assert.equal(values.left, newLeft); 74 | assert.equal(values.top, newTop); 75 | assert.equal(values.zoom, 1.7); 76 | }); 77 | 78 | it("should compose scrolling, zooming, and then scrolling", function() { 79 | var scroller = new Scroller(null, { zooming : true }); 80 | assert.equal(typeof scroller, "object"); 81 | scroller.setDimensions(1000, 600, 5000, 5000); 82 | scroller.scrollTo(300, 500); 83 | scroller.zoomTo(1.7); 84 | scroller.scrollTo(500, 700); 85 | 86 | var values = scroller.getValues(); 87 | assert.equal(typeof values, "object"); 88 | assert.equal(values.left, 500); 89 | assert.equal(values.top, 700); 90 | assert.equal(values.zoom, 1.7); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/relative.js: -------------------------------------------------------------------------------- 1 | var Scroller = require('../lib/Scroller'); 2 | var assert = require('assert'); 3 | 4 | describe("Relative movement", function () { 5 | it("should zoom relative to current zoom", function() { 6 | var scroller = new Scroller(null, { zooming : true }); 7 | 8 | scroller.zoomBy(1.5); 9 | var values = scroller.getValues(); 10 | assert.equal(values.zoom, 1.5); 11 | 12 | scroller.zoomBy(1.2); 13 | var values = scroller.getValues(); 14 | assert.equal(values.zoom, 1.5 * 1.2); 15 | }); 16 | 17 | it("should scroll relative to current position", function() { 18 | var scroller = new Scroller(); 19 | scroller.setDimensions(1000, 600, 5000, 5000); 20 | 21 | scroller.scrollBy(200, 300); 22 | var values = scroller.getValues(); 23 | assert.equal(values.left, 200); 24 | assert.equal(values.top, 300); 25 | 26 | scroller.scrollBy(300, 400); 27 | var values = scroller.getValues(); 28 | assert.equal(values.left, 500); 29 | assert.equal(values.top, 700); 30 | }); 31 | }); 32 | --------------------------------------------------------------------------------