2 // Copyright Kamil Pękala http://github.com/kamilkp
3 // Angular Virtual Scroll Repeat v1.0.0-rc5 2014/08/01
6 (function(window, angular){
8 /* jshint eqnull:true */
12 // vsRepeat directive stands for Virtual Scroll Repeat. It turns a standard ngRepeated set of elements in a scrollable container
13 // into a component, where the user thinks he has all the elements rendered and all he needs to do is scroll (without any kind of
14 // pagination - which most users loath) and at the same time the browser isn't overloaded by that many elements/angular bindings etc.
15 // The directive renders only so many elements that can fit into current container's clientHeight/clientWidth.
18 // - current version only supports an Array as a right-hand-side object for ngRepeat
19 // - all rendered elements must have the same height/width or the sizes of the elements must be known up front
22 // In order to use the vsRepeat directive you need to place a vs-repeat attribute on a direct parent of an element with ng-repeat
25 // <div ng-repeat="item in someArray">
30 // You can also measure the single element's height/width (including all paddings and margins), and then speficy it as a value
31 // of the attribute 'vs-repeat'. This can be used if one wants to override the automatically computed element size.
33 // <div vs-repeat="50"> <!-- the specified element height is 50px -->
34 // <div ng-repeat="item in someArray">
41 // - the vsRepeat directive must be applied to a direct parent of an element with ngRepeat
42 // - the value of vsRepeat attribute is the single element's height/width measured in pixels. If none provided, the directive
43 // will compute it automatically
45 // OPTIONAL PARAMETERS (attributes):
46 // vs-scroll-parent="selector" - selector to the scrollable container. The directive will look for a closest parent matching
47 // he given selector (defaults to the current element)
48 // vs-horizontal - stack repeated elements horizontally instead of vertically
49 // vs-offset-before="value" - top/left offset in pixels (defaults to 0)
50 // vs-offset-after="value" - bottom/right offset in pixels (defaults to 0)
51 // vs-excess="value" - an integer number representing the number of elements to be rendered outside of the current container's viewport
53 // vs-size-property - a property name of the items in collection that is a number denoting the element size (in pixels)
54 // vs-autoresize - use this attribute without vs-size-property and without specifying element's size. The automatically computed element style will
55 // readjust upon window resize if the size is dependable on the viewport size
58 // - 'vsRepeatTrigger' - an event the directive listens for to manually trigger reinitialization
59 // - 'vsRepeatReinitialized' - an event the directive emits upon reinitialization done
61 var isMacOS = navigator.appVersion.indexOf('Mac') != -1,
62 wheelEventName = typeof window.onwheel !== 'undefined' ? 'wheel' : typeof window.onmousewheel !== 'undefined' ? 'mousewheel' : 'DOMMouseScroll',
63 dde = document.documentElement,
64 matchingFunction = dde.matches ? 'matches' :
65 dde.matchesSelector ? 'matchesSelector' :
66 dde.webkitMatches ? 'webkitMatches' :
67 dde.webkitMatchesSelector ? 'webkitMatchesSelector' :
68 dde.msMatches ? 'msMatches' :
69 dde.msMatchesSelector ? 'msMatchesSelector' :
70 dde.mozMatches ? 'mozMatches' :
71 dde.mozMatchesSelector ? 'mozMatchesSelector' : null;
73 var closestElement = angular.element.prototype.closest || function (selector){
74 var el = this[0].parentNode;
75 while(el !== document.documentElement && el != null && !el[matchingFunction](selector)){
79 if(el && el[matchingFunction](selector))
80 return angular.element(el);
82 return angular.element();
85 angular.module('vs-repeat', []).directive('vsRepeat', ['$compile', function($compile){
89 require: '?^vsRepeat',
90 controller: ['$scope', function($scope){
91 this.$scrollParent = $scope.$scrollParent;
92 this.$fillElement = $scope.$fillElement;
94 compile: function($element, $attrs){
95 var ngRepeatChild = $element.children().eq(0),
96 ngRepeatExpression = ngRepeatChild.attr('ng-repeat'),
97 childCloneHtml = ngRepeatChild[0].outerHTML,
98 expressionMatches = /^\s*(\S+)\s+in\s+([\S\s]+?)(track\s+by\s+\S+)?$/.exec(ngRepeatExpression),
99 lhs = expressionMatches[1],
100 rhs = expressionMatches[2],
101 rhsSuffix = expressionMatches[3],
102 collectionName = '$vs_collection',
103 attributesDictionary = {
104 'vsRepeat': 'elementSize',
105 'vsOffsetBefore': 'offsetBefore',
106 'vsOffsetAfter': 'offsetAfter',
111 if(!window.getComputedStyle || window.getComputedStyle($element[0]).position !== 'absolute')
112 $element.css('position', 'relative');
114 pre: function($scope, $element, $attrs, $ctrl){
115 var childClone = angular.element(childCloneHtml),
116 originalCollection = [],
118 $$horizontal = typeof $attrs.vsHorizontal !== "undefined",
121 autoSize = !$attrs.vsRepeat,
122 sizesPropertyExists = !!$attrs.vsSizeProperty,
123 $scrollParent = $attrs.vsScrollParent ? closestElement.call($element, $attrs.vsScrollParent) : $element,
124 positioningPropertyTransform = $$horizontal ? 'translateX' : 'translateY',
125 positioningProperty = $$horizontal ? 'left' : 'top',
127 clientSize = $$horizontal ? 'clientWidth' : 'clientHeight',
128 offsetSize = $$horizontal ? 'offsetWidth' : 'offsetHeight',
129 scrollPos = $$horizontal ? 'scrollLeft' : 'scrollTop';
131 if($scrollParent.length === 0) throw 'Specified scroll parent selector did not match any element';
132 $scope.$scrollParent = $scrollParent;
134 if(sizesPropertyExists) $scope.sizesCumulative = [];
137 $scope.elementSize = $scrollParent[0][clientSize] || 50;
138 $scope.offsetBefore = 0;
139 $scope.offsetAfter = 0;
142 Object.keys(attributesDictionary).forEach(function(key){
144 $attrs.$observe(key, function(value){
145 $scope[attributesDictionary[key]] = +value;
152 $scope.$watchCollection(rhs, function(coll){
153 originalCollection = coll || [];
158 if(!originalCollection || originalCollection.length < 1){
159 $scope[collectionName] = [];
161 resizeFillElement(0);
162 $scope.sizesCumulative = [0];
166 originalLength = originalCollection.length;
167 if(sizesPropertyExists){
168 $scope.sizes = originalCollection.map(function(item){
169 return item[$attrs.vsSizeProperty];
172 $scope.sizesCumulative = $scope.sizes.map(function(size){
177 $scope.sizesCumulative.push(sum);
185 function setAutoSize(){
187 $scope.$$postDigest(function(){
188 if($element[0].offsetHeight || $element[0].offsetWidth){ // element is visible
189 var children = $element.children(),
191 while(i < children.length){
192 if(children[i].attributes['ng-repeat'] != null){
193 if(children[i][offsetSize]){
194 $scope.elementSize = children[i][offsetSize];
197 if($scope.$root && !$scope.$root.$$phase)
206 var dereg = $scope.$watch(function(){
207 if($element[0].offsetHeight || $element[0].offsetWidth){
217 childClone.attr('ng-repeat', lhs + ' in ' + collectionName + (rhsSuffix ? ' ' + rhsSuffix : ''))
218 .addClass('vs-repeat-repeated-element');
220 var offsetCalculationString = sizesPropertyExists ?
221 '(sizesCumulative[$index + startIndex] + offsetBefore)' :
222 '(($index + startIndex) * elementSize + offsetBefore)';
224 if(typeof document.documentElement.style.transform !== "undefined"){ // browser supports transform css property
225 childClone.attr('ng-style', '{ "transform": "' + positioningPropertyTransform + '(" + ' + offsetCalculationString + ' + "px)"}');
227 else if(typeof document.documentElement.style.webkitTransform !== "undefined"){ // browser supports -webkit-transform css property
228 childClone.attr('ng-style', '{ "-webkit-transform": "' + positioningPropertyTransform + '(" + ' + offsetCalculationString + ' + "px)"}');
231 childClone.attr('ng-style', '{' + positioningProperty + ': ' + offsetCalculationString + ' + "px"}');
234 $compile(childClone)($scope);
235 $element.append(childClone);
237 $fillElement = angular.element('<div class="vs-repeat-fill-element"></div>')
239 'position':'relative',
240 'min-height': '100%',
243 $element.append($fillElement);
244 $compile($fillElement)($scope);
245 $scope.$fillElement = $fillElement;
249 $wheelHelper = angular.element('<div class="vs-repeat-wheel-helper"></div>')
250 .on(wheelEventName, function(e){
253 if(e.originalEvent) e = e.originalEvent;
254 $scrollParent[0].scrollLeft += (e.deltaX || -e.wheelDeltaX);
255 $scrollParent[0].scrollTop += (e.deltaY || -e.wheelDeltaY);
256 }).on('mousemove', function(e){
257 if(_prevMouse.x !== e.clientX || _prevMouse.y !== e.clientY)
258 angular.element(this).css('display', 'none');
263 }).css('display', 'none');
264 $fillElement.append($wheelHelper);
267 $scope.startIndex = 0;
270 $scrollParent.on('scroll', function scrollHandler(e){
271 if(updateInnerCollection())
276 $scrollParent.on(wheelEventName, wheelHandler);
278 function wheelHandler(e){
279 var elem = e.currentTarget;
280 if(elem.scrollWidth > elem.clientWidth || elem.scrollHeight > elem.clientHeight)
281 $wheelHelper.css('display', 'block');
284 function onWindowResize(){
285 if(typeof $attrs.vsAutoresize !== 'undefined'){
288 if($scope.$root && !$scope.$root.$$phase)
291 if(updateInnerCollection())
295 angular.element(window).on('resize', onWindowResize);
296 $scope.$on('$destroy', function(){
297 angular.element(window).off('resize', onWindowResize);
300 $scope.$on('vsRepeatTrigger', refresh);
301 $scope.$on('vsRepeatResize', function(){
308 function reinitialize(){
309 _prevStartIndex = void 0;
310 _prevEndIndex = void 0;
311 updateInnerCollection();
312 resizeFillElement(sizesPropertyExists ?
313 $scope.sizesCumulative[originalLength] :
314 $scope.elementSize*originalLength
316 $scope.$emit('vsRepeatReinitialized');
319 function resizeFillElement(size){
322 'width': $scope.offsetBefore + size + $scope.offsetAfter + 'px',
325 if($ctrl && $ctrl.$fillElement){
326 var referenceElement = $ctrl.$fillElement[0].parentNode.querySelector('[ng-repeat]');
328 $ctrl.$fillElement.css({
329 'width': referenceElement.scrollWidth + 'px'
335 'height': $scope.offsetBefore + size + $scope.offsetAfter + 'px',
338 if($ctrl && $ctrl.$fillElement){
339 referenceElement = $ctrl.$fillElement[0].parentNode.querySelector('[ng-repeat]');
341 $ctrl.$fillElement.css({
342 'height': referenceElement.scrollHeight + 'px'
349 function reinitOnClientHeightChange(){
350 var ch = $scrollParent[0][clientSize];
351 if(ch !== _prevClientSize){
353 if($scope.$root && !$scope.$root.$$phase)
356 _prevClientSize = ch;
359 $scope.$watch(function(){
360 if(typeof window.requestAnimationFrame === "function")
361 window.requestAnimationFrame(reinitOnClientHeightChange);
363 reinitOnClientHeightChange();
366 function updateInnerCollection(){
367 if(sizesPropertyExists){
368 $scope.startIndex = 0;
369 while($scope.sizesCumulative[$scope.startIndex] < $scrollParent[0][scrollPos] - $scope.offsetBefore)
371 if($scope.startIndex > 0) $scope.startIndex--;
373 $scope.endIndex = $scope.startIndex;
374 while($scope.sizesCumulative[$scope.endIndex] < $scrollParent[0][scrollPos] - $scope.offsetBefore + $scrollParent[0][clientSize])
378 $scope.startIndex = Math.max(
380 ($scrollParent[0][scrollPos] - $scope.offsetBefore) / $scope.elementSize + $scope.excess/2
385 $scope.endIndex = Math.min(
386 $scope.startIndex + Math.ceil(
387 $scrollParent[0][clientSize] / $scope.elementSize
394 var digestRequired = $scope.startIndex !== _prevStartIndex || $scope.endIndex !== _prevEndIndex;
397 $scope[collectionName] = originalCollection.slice($scope.startIndex, $scope.endIndex);
399 _prevStartIndex = $scope.startIndex;
400 _prevEndIndex = $scope.endIndex;
402 return digestRequired;
410 angular.element(document.head).append([
412 '.vs-repeat-wheel-helper{' +
413 'position: absolute;' +
419 'background: rgba(0, 0, 0, 0);' +
421 '.vs-repeat-repeated-element{' +
422 'position: absolute;' +
427 })(window, window.angular);