// viewport helper // -------------------------------------------- // // * **class:** viewport // * **version:** 1.0 // * **modified:** 06/26/2013 // * **author:** glen cheney // * **dependencies:** jquery 1.7+, sony settings, throttle/debounce // // *notes:* // // if you need to be notified when an element is scrolled into view, use this module. // this module keeps track of all elements that want to be watched and caches their offsets // and dimensions in order to keep scrolling as smooth as possible. // // *example usage:* // // viewport.add({ // element: document.getelementbyid('some-wrapper'), // threshold: '50%', // enter: function( element ) { // console.log('the top of "element" is 50% in view'); // }, // leave: function() { // console.log('bottom of element has left the viewport'); // } // }); // // *viewport.add parameters:* // // * `element` is a dom element and `callback` is a function. `this` in the callback is the element. // * using an options object, a `threshold` can be set. // it is either an integer value from the bottom of the window, a string percentage, or a float // between 0 and 1 which represents the percent. // (function($) { 'use strict'; var instance = null; var $window = $(window); var viewportitem = function(options) { var self = this; // get defaults $.extend(self, viewportitem.options, options, viewportitem.settings); // the whole point is to have a callback function. // don't do anything if it's not given if (!$.isfunction(self.enter)) { throw new typeerror('viewport.add :: no `enter` function provided in viewport options.'); } // threshold can be a percentage. parse it. if ((typeof self.threshold === 'string' && self.threshold.indexof('%') > -1)) { self.isthresholdpercentage = true; self.threshold = parsefloat(self.threshold) / 100; } else if (self.threshold < 1 && self.threshold > 0) { self.isthresholdpercentage = true; } self.hasleavecallback = $.isfunction(self.leave); self.$element = $(self.element); // cache element's offsets and dimensions self.update(); }; viewportitem.prototype.update = function() { var self = this; self.offset = self.$element.offset(); self.height = self.$element.height(); self.width = self.$element.width(); }; viewportitem.options = { threshold: 200, delay: 0 }; viewportitem.settings = { triggered: false, isthresholdpercentage: false }; var viewport = function() { this.init(); }; viewport.prototype = { init: function() { var self = this; self.list = []; self.lastscrolly = 0; self.windowheight = $window.height(); self.windowwidth = $window.width(); self.throttletime = 100; self.onresize(); self.bindevents(); // what's nice here is that raf won't execute until the user is on this tab, // so if they open the page in a new tab which they aren't looking at, // this will execute when they come back to that tab self.willprocessnextframe = true; requestanimationframe(function() { self.setscrolltop(); self.process(); self.willprocessnextframe = false; }); }, bindevents: function() { var self = this, refresh; // updates offsets after a zero timeout refresh = function() { settimeout(function refreshwithdelay() { self.refresh(); }, 0); }; // listen for global resize $window.on('resize.viewport', $.proxy(self.onresize, self)); // throttle scrolling because it doesn't need to be super accurate $window.on('scroll.viewport', $.throttle(self.throttletime, $.proxy(self.onscroll, self))); self.hasactivehandlers = true; }, unbindevents: function() { $window.off('.viewport'); this.hasactivehandlers = false; }, maybeunbindevents: function() { var self = this; // not currently watching anything, unbind events if (!self.list.length) { self.unbindevents(); } }, add: function(viewportitem) { var self = this; self.list.push(viewportitem); // event handlers are removed if a callback is triggered and the // watch list is empty. because modules are instantiated asynchronously, // another module could potentially add itself to the watch list when the events // have been unbound. // check here if events have been unbound and bind them again if they have if (!self.hasactivehandlers) { self.bindevents(); } if (!self.willprocessnextframe) { self.willprocessnextframe = true; requestanimationframe(function() { self.willprocessnextframe = false; self.process(); }); } }, savedimensions: function() { var self = this; $.each(self.list, function(i, viewportitem) { viewportitem.update(); }); // self.documentheight self.windowheight = $window.height(); self.windowwidth = $window.width(); }, // throttled scroll event onscroll: function() { var self = this; // no point in doing anything if there aren't any viewports to watch if (!self.list.length) { return; } // save the new scroll top self.setscrolltop(); self.process(); }, // debounced resize event onresize: function() { this.refresh(); }, refresh: function() { // no point in doing anything if there aren't any viewports to watch if (!this.list.length) { return; } // update offsets and width/height for each viewport item this.savedimensions(); }, isinviewport: function(viewportitem) { var self = this, offset = viewportitem.offset, threshold = viewportitem.threshold, percentage = threshold, st = self.lastscrolly, istopinview; if (viewportitem.isthresholdpercentage) { threshold = 0; } // other checks could be added here in the future istopinview = self.istopinview(st, self.windowheight, offset.top, viewportitem.height, threshold); // if the top isn't in view with zero threshold, // don't bother checking if it's at a percent of the window if (istopinview && viewportitem.isthresholdpercentage) { istopinview = self.istoppastpercent(st, self.windowheight, offset.top, viewportitem.height, percentage); } return istopinview; }, // if the top of the element (plus the threshold) is past the viewport's top // and the top of the element (plus the threshold) is not past the viewport's bottom. // then the top is in view. istopinview: function(viewporttop, viewportheight, elementtop, elementheight, threshold) { var viewportbottom = viewporttop + viewportheight; return (elementtop + threshold) >= viewporttop && (elementtop + threshold) < viewportbottom; }, istoppastpercent: function(viewporttop, viewportheight, elementtop, elementheight, percentage) { var viewportbottom = viewporttop + viewportheight, distfromviewportbottomtoelementtop = viewportbottom - elementtop, percentfrombottom = distfromviewportbottomtoelementtop / viewportheight; return percentfrombottom >= percentage; }, isoutofviewport: function(viewport, side) { var self = this, offset = viewport.offset, st = self.lastscrolly, bool; if (side === 'bottom') { bool = !self.isbottominview(st, self.windowheight, offset.top, viewport.height); } return bool; }, isbottominview: function(viewporttop, viewportheight, elementtop, elementheight) { var viewportbottom = viewporttop + viewportheight, elementbottom = elementtop + elementheight; return elementbottom > viewporttop && elementbottom <= viewportbottom; }, triggerenter: function(viewportitem) { var self = this; // queue up the callback with the delay. default is 0 settimeout(function() { viewportitem.enter.call(viewportitem.element, viewportitem); }, viewportitem.delay); if ($.isfunction(viewportitem.leave)) { viewportitem.triggered = true; // if the leave property is not a function, // the module no longer needs to watch it, so remove from list // however, the list may have been modified already in this loop, so find the // index of the viewport item instead of using the loop index. } else { self.list.splice($.inarray(viewportitem, self.list), 1); } // if there are no more, unbind from scroll and resize events self.maybeunbindevents(); }, triggerleave: function(viewportitem) { // var self = this; // queue up the callback with the delay. default is 0 settimeout(function() { viewportitem.leave.call(viewportitem.element, viewportitem); }, viewportitem.delay); viewportitem.triggered = false; }, setscrolltop: function() { // save the new scroll top this.lastscrolly = $window.scrolltop(); }, process: function() { var self = this, // the list can possibly be modified mid loop, // so the loop needs a copy of the variable which won't be modified list = $.extend([], self.list); $.each(list, function(i, viewportitem) { var isinviewport = self.isinviewport(viewportitem), isbottomoutofview = viewportitem.hasleavecallback && self.isoutofviewport(viewportitem, 'bottom'); // if the enter callback hasn't been triggerd and it's in the viewport, // trigger the enter callback if (!viewportitem.triggered && isinviewport) { return self.triggerenter(viewportitem); } // this viewport has already come into view once and now it is out of view // it's not in view, the bottom is out of view, the list item's enter has been triggered if (!isinviewport && isbottomoutofview && viewportitem.triggered) { return self.triggerleave(viewportitem); } }); } }; viewport.add = function(options) { var instance = viewport.getinstance(); return instance.add(new viewportitem(options)); }; viewport.refresh = function() { viewport.getinstance().refresh(); }; viewport.getinstance = function() { if (!instance) { instance = new viewport(); } return instance; }; window.viewport = viewport; }(jquery));