123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647 |
- // FIXME:
- // * fully make use of transitions in glide routines
- define(["dojo/_base/declare", "dojo/on", "./util/touch", "./util/has-css3", "put-selector/put", "xstyle/css!./css/TouchScroll.css"],
- function(declare, on, touchUtil, has, put){
- var
- calcTimerRes = 50, // ms between drag velocity measurements
- glideTimerRes = 30, // ms between glide animation ticks
- current = {}, // records info for widget(s) currently being scrolled
- previous = {}, // records info for widget(s) that were in the middle of being scrolled when someone decided to scroll again
- glideThreshold = 1, // speed (in px) below which to stop glide - TODO: remove
- scrollbarAdjustment = 8, // number of px to adjust scrollbar dimension calculations
- // RegExps for parsing relevant x/y from translate and matrix values:
- translateRx = /^translate(?:3d)?\((-?\d+)(?:\.\d*)?(?:px)?, (-?\d+)/,
- matrixRx = /^matrix\(1, 0, 0, 1, (-?\d+)(?:\.\d*)?(?:px)?, (-?\d+)/,
- // store has-features we need, for computing property/function names:
- hasTransitions = has("css-transitions"),
- hasTransitionEnd = has("transitionend"),
- hasTransforms = has("css-transforms"),
- hasTransforms3d = has("css-transforms3d"),
- // and declare vars to store info on the properties/functions we'll need
- cssPrefix, transitionPrefix, transformProp, translatePrefix, translateSuffix;
-
- if(hasTransforms3d){
- translatePrefix = "translate3d(";
- translateSuffix = ",0)";
- }else if(hasTransforms){
- translatePrefix = "translate(";
- translateSuffix = ")";
- }
-
- if(!hasTransitions || !translatePrefix){
- console.warn("CSS3 features unavailable for touch scroll effects.");
- return function(){};
- }
-
- // figure out strings for use later in events
- transformProp = hasTransforms3d || hasTransforms;
- transformProp = transformProp === true ? "transform" : transformProp + "Transform";
- transitionPrefix = hasTransitions === true ? "transition" :
- hasTransitions + "Transition";
- cssPrefix = hasTransforms === true ? "" :
- "-" + hasTransforms.toLowerCase() + "-";
-
- function showScrollbars(widget, curr){
- // Handles displaying of X/Y scrollbars as appropriate when a touchstart
- // occurs.
-
- var node = widget.touchNode,
- parentNode = node.parentNode,
- adjustedParentWidth = parentNode.offsetWidth - scrollbarAdjustment,
- adjustedParentHeight = parentNode.offsetHeight - scrollbarAdjustment,
- // Also populate scroll/offset properties on curr for reuse,
- // to avoid having to repeatedly hit the DOM.
- scrollWidth = curr.scrollWidth = node.scrollWidth,
- scrollHeight = curr.scrollHeight = node.scrollHeight,
- parentWidth = curr.parentWidth = parentNode.offsetWidth,
- parentHeight = curr.parentHeight = parentNode.offsetHeight,
- scrollbarNode;
-
- if(scrollWidth > parentWidth){
- if(!widget._scrollbarXNode){
- scrollbarNode = put(parentNode, "div.touchscroll-x");
- }
- scrollbarNode = widget._scrollbarXNode =
- widget._scrollbarXNode || put(scrollbarNode, "div.touchscroll-bar");
- scrollbarNode.style.width =
- adjustedParentWidth * adjustedParentWidth / scrollWidth + "px";
- scrollbarNode.style.left = node.offsetLeft + "px";
- put(parentNode, ".touchscroll-scrollable-x");
- curr.scrollableX = true;
- }else{
- put(parentNode, "!touchscroll-scrollable-x");
- }
- if(scrollHeight > parentHeight){
- if(!widget._scrollbarYNode){
- scrollbarNode = put(parentNode, "div.touchscroll-y");
- }
- scrollbarNode = widget._scrollbarYNode =
- widget._scrollbarYNode || put(scrollbarNode, "div.touchscroll-bar");
- scrollbarNode.style.height =
- adjustedParentHeight * adjustedParentHeight / scrollHeight + "px";
- scrollbarNode.style.top = node.offsetTop + "px";
- put(parentNode, ".touchscroll-scrollable-y");
- curr.scrollableY = true;
- }else{
- put(parentNode, "!touchscroll-scrollable-y");
- }
- put(parentNode, "!touchscroll-fadeout");
- }
-
- function scroll(widget, options){
- // Handles updating of scroll position (from touchmove or glide).
- var node = widget.touchNode,
- curr = current[widget.id],
- pos, hasX, hasY, x, y;
-
- if(typeof options !== "object"){
- // Allow x, y to be passed directly w/o extra object creation.
- // (e.g. from ontouchmove)
- x = options;
- y = arguments[2];
- options = arguments[3];
- hasX = hasY = true;
- }else{
- hasX = "x" in options;
- hasY = "y" in options;
-
- // If either x or y weren't specified, pass through the current value.
- if(!hasX || !hasY){
- pos = widget.getScrollPosition();
- }
- x = hasX ? options.x : pos.x;
- y = hasY ? options.y : pos.y;
- }
-
- // Update transform on touchNode
- node.style[transformProp] =
- translatePrefix + -x + "px," + -y + "px" + translateSuffix;
-
- // Update scrollbar positions
- if(curr && hasX && widget._scrollbarXNode){
- widget._scrollbarXNode.style[transformProp] = translatePrefix +
- (x * curr.parentWidth / curr.scrollWidth) + "px,0" + translateSuffix;
- }
- if(curr && hasY && widget._scrollbarYNode){
- widget._scrollbarYNode.style[transformProp] = translatePrefix + "0," +
- (y * curr.parentHeight / curr.scrollHeight) + "px" + translateSuffix;
- }
-
- // Emit a scroll event that can be captured by handlers, passing along
- // scroll information in the event itself (since we already have the info,
- // and it'd be difficult to get from the node).
- on.emit(widget.touchNode.parentNode, "scroll", {
- scrollLeft: x,
- scrollTop: y
- });
- }
-
- function getScrollStyle(widget){
- // Returns match object for current scroll position based on transform.
- if(current[widget.id]){
- // Mid-transition: determine current X/Y from computed values.
- return matrixRx.exec(window.getComputedStyle(widget.touchNode)[transformProp]);
- }
- // Otherwise, determine current X/Y from applied style.
- return translateRx.exec(widget.touchNode.style[transformProp]);
- }
-
- function resetEffects(options){
- // Function to cut glide/bounce short, called in context of an object
- // from the current hash; attached only when a glide or bounce occurs.
- // Called on touchstart, when touch scrolling is canceled, when
- // momentum/bounce finishes, and by scrollTo on instances (in case it's
- // called directly during a glide/bounce).
-
- var widget = this.widget,
- nodes = [this.node, widget._scrollbarXNode, widget._scrollbarYNode],
- i = nodes.length;
-
- // Clear glide timer.
- if(this.timer){
- clearTimeout(this.timer);
- this.timer = null;
- }
-
- // Clear transition handlers, as we're about to cut it short.
- if(this.transitionHandler){
- // Unhook any existing transitionend handler, since we'll be
- // canceling the transition.
- this.transitionHandler.remove();
- }
-
- // Clear transition duration on main node and scrollbars.
- while(i--){
- if(nodes[i]){ nodes[i].style[transitionPrefix + "Duration"] = "0"; }
- }
-
- // Fade out scrollbars unless indicated otherwise (e.g. re-touch).
- if(!options || !options.preserveScrollbars){
- put(this.node.parentNode, ".touchscroll-fadeout");
- }
-
- // Remove this method so it can't be called again.
- delete this.resetEffects;
- }
-
- // functions for handling touch events on node to be scrolled
-
- function ontouchstart(evt){
- var widget = evt.widget,
- node = widget.touchNode,
- id = widget.id,
- posX = 0,
- posY = 0,
- touch, match, curr;
-
- // Check touches count (which hasn't counted this event yet);
- // ignore touch events on inappropriate number of contact points.
- if(touchUtil.countCurrentTouches(evt, node) !== widget.touchesToScroll){
- return;
- }
-
- match = getScrollStyle(widget);
- if(match){
- posX = +match[1];
- posY = +match[2];
- }
- if((curr = current[id])){
- // stop any active glide or bounce, since it's been re-touched
- if(curr.resetEffects){
- curr.resetEffects({ preserveScrollbars: true });
- }
-
- node.style[transformProp] =
- translatePrefix + posX + "px," + posY + "px" + translateSuffix;
-
- previous[id] = curr;
- }
-
- touch = evt.targetTouches[0];
- curr = current[id] = {
- widget: widget,
- node: node,
- // Subtract touch coords now, then add back later, so that translation
- // goes further negative when moving upwards.
- startX: posX - touch.pageX,
- startY: posY - touch.pageY,
- // Initialize lastX/Y, in case of a fast flick (< 1 full calc cycle).
- lastX: posX,
- lastY: posY,
- // Also store original pageX/Y for threshold check.
- pageX: touch.pageX,
- pageY: touch.pageY,
- tickFunc: function(){ calcTick(id); }
- };
- curr.timer = setTimeout(curr.tickFunc, calcTimerRes);
- }
- function ontouchmove(evt){
- var widget = evt.widget,
- id = widget.id,
- touchesToScroll = widget.touchesToScroll,
- curr = current[id],
- activeTouches, targetTouches, touch, nx, ny, minX, minY, i;
-
- // Ignore touchmove events with inappropriate number of contact points.
- if(!curr || (activeTouches = touchUtil.countCurrentTouches(evt, widget.touchNode)) !== touchesToScroll){
- // Also cancel touch scrolling if there are too many contact points.
- if(activeTouches > touchesToScroll){
- widget.cancelTouchScroll();
- }
- return;
- }
-
- targetTouches = evt.targetTouches;
- touch = targetTouches[0];
-
- // Show touch scrollbars on first sign of drag.
- if(!curr.scrollbarsShown){
- if(previous[id] || (
- Math.abs(touch.pageX - curr.pageX) > widget.scrollThreshold ||
- Math.abs(touch.pageY - curr.pageY) > widget.scrollThreshold)){
- showScrollbars(widget, curr);
- curr.scrollbarsShown = true;
-
- // Add flag to involved touches to provide indication to other handlers.
- for(i = targetTouches.length; i--;){
- targetTouches[i].touchScrolled = true;
- }
- }
- }
-
- if(curr.scrollbarsShown && (curr.scrollableX || curr.scrollableY)){
- // If area can be scrolled, prevent default behavior and perform scroll.
- evt.preventDefault();
-
- nx = curr.scrollableX ? curr.startX + touch.pageX : 0;
- ny = curr.scrollableY ? curr.startY + touch.pageY : 0;
-
- minX = curr.scrollableX ? -(curr.scrollWidth - curr.parentWidth) : 0;
- minY = curr.scrollableY ? -(curr.scrollHeight - curr.parentHeight) : 0;
-
- // If dragged beyond edge, halve the distance between.
- if(nx > 0){
- nx = nx / 2;
- }else if(nx < minX){
- nx = minX - (minX - nx) / 2;
- }
- if(ny > 0){
- ny = ny / 2;
- }else if(ny < minY){
- ny = minY - (minY - ny) / 2;
- }
-
- scroll(widget, -nx, -ny); // call scroll with positive coordinates
- }
- }
- function ontouchend(evt){
- var widget = evt.widget,
- id = widget.id,
- curr = current[id];
-
- if(!curr || touchUtil.countCurrentTouches(evt, widget.touchNode) != widget.touchesToScroll - 1){
- return;
- }
- startGlide(id);
- }
-
- // glide-related functions
-
- function calcTick(id){
- // Calculates current speed of touch drag
- var curr = current[id],
- node, match, x, y;
-
- if(!curr){ return; } // no currently-scrolling widget; abort
-
- node = curr.node;
- match = translateRx.exec(node.style[transformProp]);
-
- if(match){
- x = +match[1];
- y = +match[2];
-
- // If previous reference point already exists, calculate velocity
- curr.velX = x - curr.lastX;
- curr.velY = y - curr.lastY;
-
- // set previous reference point for future iteration or calculation
- curr.lastX = x;
- curr.lastY = y;
- } else {
- curr.lastX = curr.lastY = 0;
- }
- curr.timer = setTimeout(curr.tickFunc, calcTimerRes);
- }
-
- function bounce(id, lastX, lastY){
- // Function called when a scroll ends, to handle rubber-banding beyond edges.
- var curr = current[id],
- widget = curr.widget,
- node = curr.node,
- scrollbarNode,
- x = curr.scrollableX ?
- Math.max(Math.min(0, lastX), -(curr.scrollWidth - curr.parentWidth)) :
- lastX,
- y = curr.scrollableY ?
- Math.max(Math.min(0, lastY), -(curr.scrollHeight - curr.parentHeight)) :
- lastY;
-
- function end(){
- // Performs reset operations upon end of scroll process.
-
- // Since transitions have run, delete transitionHandler up-front
- // (since it auto-removed itself anyway), then let
- // resetEffects do the rest of its usual job.
- delete curr.transitionHandler;
- curr.resetEffects();
- delete current[id];
- }
-
- // Timeout will have been cleared before bounce call, so remove timer.
- delete curr.timer;
-
- if (x != lastX || y != lastY){
- curr.transitionHandler = on.once(node, hasTransitionEnd, end);
- node.style[transitionPrefix + "Duration"] = widget.bounceDuration + "ms";
- node.style[transformProp] =
- translatePrefix + x + "px," + y + "px" + translateSuffix;
-
- // Also handle transitions for scrollbars.
- if(x != lastX && curr.scrollableX){
- scrollbarNode = curr.widget._scrollbarXNode;
- scrollbarNode.style[transitionPrefix + "Duration"] =
- widget.bounceDuration + "ms";
- if(lastX > x){
- // Further left; bounce back right
- scrollbarNode.style[transformProp] =
- translatePrefix + "0,0" + translateSuffix;
- }else{
- // Further right; bounce back left
- scrollbarNode.style[transformProp] =
- translatePrefix +
- (scrollbarNode.parentNode.offsetWidth - scrollbarNode.offsetWidth) +
- "px,0" + translateSuffix;
- }
- }
- if(y != lastY && curr.scrollableY){
- scrollbarNode = curr.widget._scrollbarYNode;
- scrollbarNode.style[transitionPrefix + "Duration"] =
- widget.bounceDuration + "ms";
- if(lastY > y){
- // Above top; bounce back down
- scrollbarNode.style[transformProp] =
- translatePrefix + "0,0" + translateSuffix;
- }else{
- // Below bottom; bounce back up
- scrollbarNode.style[transformProp] =
- translatePrefix + "0," +
- (scrollbarNode.parentNode.offsetHeight - scrollbarNode.offsetHeight) +
- "px" + translateSuffix;
- }
- }
- }else{
- end(); // no rubber-banding necessary; just reset
- }
- }
-
- function startGlide(id){
- // starts glide operation when drag ends
- var curr = current[id],
- prev = previous[id],
- match, posX, posY,
- INERTIA_ACCELERATION = 1.15;
-
- delete previous[id];
-
- if(curr.timer){ clearTimeout(curr.timer); }
-
- // Enable usage of resetEffects during glide or bounce.
- curr.resetEffects = resetEffects;
-
- // calculate velocity based on time and displacement since last tick
- match = translateRx.exec(curr.node.style[transformProp]);
- if(match){
- posX = +match[1];
- posY = +match[2];
- } else {
- posX = posY = 0;
- }
-
- // If there is no glide to perform (no exit velocity), or if we are
- // beyond boundaries on all applicable edges, immediately bounce back.
- if((!curr.velX && !curr.velY) ||
- ((posX >= 0 || posX <= -(curr.scrollWidth - curr.parentWidth)) &&
- (posY >= 0 || posY <= -(curr.scrollHeight - curr.parentHeight)))){
- bounce(id, posX, posY);
- return;
- }
-
- function sameSign(a, b){
- return ((a.velX <= 0 && b.velX <= 0) || (a.velX >= 0 && b.velX >= 0)) &&
- ((a.velY <= 0 && b.velY <= 0) || (a.velY >= 0 && b.velY >= 0));
- }
-
- if(prev && (prev.velX || prev.velY) && sameSign(curr, prev)){
- curr.velX = (curr.velX + prev.velX) * INERTIA_ACCELERATION;
- curr.velY = (curr.velY + prev.velY) * INERTIA_ACCELERATION;
- }
-
- // update lastX/Y with current position, for glide calculations
- curr.lastX = posX;
- curr.lastY = posY;
- curr.calcFunc = function(){ calcGlide(id); };
- curr.timer = setTimeout(curr.calcFunc, glideTimerRes);
- }
- function calcGlide(id){
- // performs glide and decelerates according to widget's glideDecel method
- var curr = current[id],
- node, parentNode, widget, i,
- nx, ny, nvx, nvy, // old/new coords and new velocities
- BOUNCE_DECELERATION_AMOUNT = 6;
-
- if(!curr){ return; }
-
- node = curr.node;
- parentNode = node.parentNode;
- widget = curr.widget;
- nvx = widget.glideDecel(curr.velX);
- nvy = widget.glideDecel(curr.velY);
-
- if(Math.abs(nvx) >= glideThreshold || Math.abs(nvy) >= glideThreshold){
- // still above stop threshold; update transformation
- nx = curr.lastX + nvx;
- ny = curr.lastY + nvy;
-
- // If glide has traveled beyond any edges, institute rubber-band effect
- // by further decelerating.
- if(nx > 0 || nx < -(curr.scrollWidth - curr.parentWidth)){
- for(i = BOUNCE_DECELERATION_AMOUNT; i--;){
- nvx = widget.glideDecel(nvx);
- }
- }
- if(ny > 0 || ny < -(curr.scrollHeight - curr.parentHeight)){
- for(i = BOUNCE_DECELERATION_AMOUNT; i--;){
- nvy = widget.glideDecel(nvy);
- }
- }
-
- // still scrollable; update offsets/velocities and schedule next tick
- scroll(widget, -nx, -ny); // call scroll with positive coordinates
- // update information
- curr.lastX = nx;
- curr.lastY = ny;
- curr.velX = nvx;
- curr.velY = nvy;
- curr.timer = setTimeout(curr.calcFunc, glideTimerRes);
- }else{
- bounce(id, curr.lastX, curr.lastY);
- }
- }
-
- return declare(null, {
- // touchesToScroll: Number
- // Number of touches to require on the component's touch target node
- // in order to trigger scrolling behavior.
- touchesToScroll: 1,
-
- // touchNode: DOMNode?
- // Node upon which scroll behavior will be based; transformations will be
- // applied to this node, and events and some DOM/styles will be applied
- // to its *parent*. If not specified, defaults to containerNode.
- touchNode: null,
-
- // scrollThreshold: Number
- // Minimum number of pixels to wait for user to scroll (in any direction)
- // before initiating scroll.
- scrollThreshold: 10,
-
- // bounceDuration: Number
- // Number of milliseconds which "rubber-banding" transitions
- // (i.e. bouncing back from beyond edges) should take.
- bounceDuration: 300,
-
- postCreate: function(){
- this._initTouch();
- this.inherited(arguments);
- },
-
- _initTouch: function(){
- var node = this.touchNode = this.touchNode || this.containerNode,
- widget = this,
- parentNode;
-
- if(!node || !node.parentNode){
- // Bail out if we have no touchNode or containerNode, or if we don't
- // seem to have a parent node to work with.
- console.warn("TouchScroll requires a nested node upon which to operate.");
- return;
- }
-
- parentNode = node.parentNode;
-
- // Set overflow to hidden in order to prevent any native scroll logic.
- parentNode.style.overflow = "hidden";
-
- node.style[transitionPrefix + "Property"] = cssPrefix + "transform";
- node.style[transitionPrefix + "TimingFunction"] =
- "cubic-bezier(0.33, 0.66, 0.66, 1)";
-
- function cancelTouchScroll(){
- widget.cancelTouchScroll();
- }
-
- function wrapHandler(func){
- return function(evt){
- evt.widget = widget;
- evt.cancelTouchScroll = cancelTouchScroll;
- func.call(this, evt);
- };
- }
-
- this._touchScrollListeners = [
- on(parentNode, "touchstart", wrapHandler(ontouchstart)),
- on(parentNode, "touchmove", wrapHandler(ontouchmove)),
- on(parentNode, "touchend,touchcancel", wrapHandler(ontouchend))
- ];
- },
-
- destroy: function(){
- var i = this._touchScrollListeners.length;
- while(i--){
- this._touchScrollListeners[i].remove();
- }
- delete current[this.id];
-
- this.inherited(arguments);
- },
-
- scrollTo: function(options){
- // summary:
- // Scrolls the widget to a specific position.
- // options: Object
- // Object containing target x and/or y position to scroll to
- // (if unspecified, scroll in that direction will be preserved).
- // Also supports the following other options:
- // * preserveMomentum: if true, will not reset any active
- // momentum or bounce on the widget
-
- var curr = current[this.id],
- touchNode = this.touchNode,
- parentNode = touchNode.parentNode;
-
- if(!options.preserveMomentum && curr && curr.resetEffects){
- // Stop any glide or bounce occurring before scrolling.
- curr.resetEffects();
- }
-
- // Constrain coordinates within scrollable boundaries.
- if(options.x){
- options.x = Math.max(0, Math.min(options.x,
- touchNode.scrollWidth - parentNode.offsetWidth));
- }
- if(options.y){
- options.y = Math.max(0, Math.min(options.y,
- touchNode.scrollHeight - parentNode.offsetHeight));
- }
-
- scroll(this, options);
- },
-
- getScrollPosition: function(){
- // summary:
- // Determines current translation from computed style
- // (if mid-transition), or applied style.
- var match = getScrollStyle(this);
- return match ? { x: -match[1], y: -match[2] } : { x: 0, y: 0 };
- },
-
- cancelTouchScroll: function(){
- // summary:
- // Removes any existing scroll information for this component from the
- // current map, effectively canceling any TouchScroll behavior for
- // that particular touch gesture.
-
- var curr = current[this.id];
- if(!curr){ return; }
-
- if(curr.resetEffects){ curr.resetEffects(); }
- else{
- if(curr.timer){ clearTimeout(curr.timer); }
- put(curr.node.parentNode, ".touchscroll-fadeout");
- }
-
- delete current[this.id];
- },
-
- glideDecel: function(n){
- // summary:
- // Deceleration algorithm. Given a number representing velocity,
- // returns a new velocity to impose for the next "tick".
- // (Don't forget that velocity can be positive or negative!)
- return n * 0.9; // Number
- }
- });
- });
|