From bb866da3d7e539c8923bd6914c0a4e44f0f4d400 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 24 Feb 2015 12:13:00 -0700 Subject: [PATCH] refactor(collection-repeat): performance --- js/angular/controller/scrollController.js | 1 + js/angular/directive/collectionRepeat.js | 1149 ++++-- .../service/collectionRepeatDataSource.js | 169 - js/angular/service/collectionRepeatManager.js | 366 -- js/views/scrollView.js | 19 +- package.json | 1 + scss/_util.scss | 11 + test/html/collection-repeat/basic-grid.html | 45 + test/html/collection-repeat/basic-list.html | 42 + test/html/collection-repeat/contacts.html | 3113 +++++++++++++++++ .../horizontal-image-grid.html | 30 + test/html/list-fit.html | 84 - .../directive/collectionRepeat.unit.js | 364 +- .../service/collectionDataSource.unit.js | 262 -- .../service/collectionRepeatManager.unit.js | 501 --- 15 files changed, 4338 insertions(+), 1819 deletions(-) delete mode 100644 js/angular/service/collectionRepeatDataSource.js delete mode 100644 js/angular/service/collectionRepeatManager.js create mode 100644 test/html/collection-repeat/basic-grid.html create mode 100644 test/html/collection-repeat/basic-list.html create mode 100644 test/html/collection-repeat/contacts.html create mode 100644 test/html/collection-repeat/horizontal-image-grid.html delete mode 100644 test/html/list-fit.html delete mode 100644 test/unit/angular/service/collectionDataSource.unit.js delete mode 100644 test/unit/angular/service/collectionRepeatManager.unit.js diff --git a/js/angular/controller/scrollController.js b/js/angular/controller/scrollController.js index b84689253f..c9efbde5a4 100644 --- a/js/angular/controller/scrollController.js +++ b/js/angular/controller/scrollController.js @@ -85,6 +85,7 @@ function($scope, $timeout(function() { scrollView && scrollView.run && scrollView.run(); + $element.triggerHandler('scroll.init'); }); self.getScrollView = function() { diff --git a/js/angular/directive/collectionRepeat.js b/js/angular/directive/collectionRepeat.js index 88d5bf62af..38e9b8fb40 100644 --- a/js/angular/directive/collectionRepeat.js +++ b/js/angular/directive/collectionRepeat.js @@ -1,309 +1,902 @@ + /** * @ngdoc directive - * @module ionic - * @name collectionRepeat * @restrict A + * @name collectionRepeat + * @module ionic + * @description * @codepen mFygh * @description - * `collection-repeat` is a directive that allows you to render lists with - * thousands of items in them, and experience little to no performance penalty. + * `collection-repeat` allows an app to show huge lists of items much more performantly than + * `ng-repeat`. * - * Demo: + * It renders into the DOM only as many items as are currently visible. * - * The directive renders onto the screen only the items that should be currently visible. - * So if you have 1,000 items in your list but only ten fit on your screen, - * collection-repeat will only render into the DOM the ten that are in the current - * scroll position. + * This means that on a phone screen that can fit eight items, only the eight items matching + * the current scroll position will be rendered. * - * Here are a few things to keep in mind while using collection-repeat: + * **Basics**: * - * 1. The data supplied to collection-repeat must be an array. - * 2. You must explicitly tell the directive what size your items will be in the DOM, using directive attributes. Pixel amounts or percentages are allowed (see usage examples below). - * 3. Each collection-repeat list will take up all of its parent scrollView's space. - * If you wish to have multiple lists on one page, put each list within its own - * {@link ionic.directive:ionScroll ionScroll} container. - * 4. You should not use the ng-show and ng-hide directives on your ion-content/ion-scroll elements that - * have a collection-repeat inside. ng-show and ng-hide apply the `display: none` css rule to the content's - * style, causing the scrollView to read the width and height of the content as 0. Resultingly, - * collection-repeat will render elements that have just been un-hidden incorrectly. + * - The data given to collection-repeat must be an array. + * - If the `item-height` and `item-width` attributes are not supplied, it will be assumed that + * every item in the list's dimensions are the same as the first item's dimensions. * + * **Performance Tips**: + * + * - The iOS webview has a performance bottleneck when switching out `` attributes. + * To increase performance of images on iOS, cache your images in advance and, + * if possible, lower the number of unique images. Check out [this codepen](). * * @usage - * - * #### Basic Usage (single rows of items) - * - * Notice two things here: we use ng-style to set the height of the item to match - * what the repeater thinks our item height is. Additionally, we add a css rule - * to make our item stretch to fit the full screen (since it will be absolutely - * positioned). - * - * ```html - * - *
- *
- * {% raw %}{{item}}{% endraw %} - *
- *
- *
- * ``` - * ```js - * function ContentCtrl($scope) { - * $scope.items = []; - * for (var i = 0; i < 1000; i++) { - * $scope.items.push('Item ' + i); - * } - * - * $scope.getItemHeight = function(item, index) { - * //Make evenly indexed items be 10px taller, for the sake of example - * return (index % 2) === 0 ? 50 : 60; - * }; - * } - * ``` - * - * #### Grid Usage (three items per row) - * + * #### Basic Item List (codepen) * ```html * - *
- * - *
+ * + * {% raw %}{{item}}{% endraw %} + * *
* ``` - * This example shows a 3 by 3 matrix that fits on the screen (3 rows and 3 colums). * - * @param {expression} collection-repeat The expression indicating how to enumerate a collection. These - * formats are currently supported: + * #### Grid of Images (codepen) + * ```html + * + * + * + * ``` * - * * `variable in expression` – where variable is the user defined loop variable and `expression` - * is a scope expression giving the collection to enumerate. - * - * For example: `album in artist.albums`. - * - * * `variable in expression track by tracking_expression` – You can also provide an optional tracking function - * which can be used to associate the objects in the collection with the DOM elements. If no tracking function - * is specified the collection-repeat associates elements by identity in the collection. It is an error to have - * more than one tracking function to resolve to the same key. (This would mean that two distinct objects are - * mapped to the same DOM element, which is not possible.) Filters should be applied to the expression, - * before specifying a tracking expression. - * - * For example: `item in items` is equivalent to `item in items track by $id(item)'. This implies that the DOM elements - * will be associated by item identity in the array. - * - * For example: `item in items track by $id(item)`. A built in `$id()` function can be used to assign a unique - * `$$hashKey` property to each item in the array. This property is then used as a key to associated DOM elements - * with the corresponding item in the array by identity. Moving the same object in array would move the DOM - * element in the same way in the DOM. - * - * For example: `item in items track by item.id` is a typical pattern when the items come from the database. In this - * case the object identity does not matter. Two objects are considered equivalent as long as their `id` - * property is same. - * - * For example: `item in items | filter:searchText track by item.id` is a pattern that might be used to apply a filter - * to items in conjunction with a tracking expression. - * - * @param {expression} collection-item-width The width of the repeated element. Can be a number (in pixels) or a percentage. - * @param {expression} collection-item-height The height of the repeated element. Can be a number (in pixels), or a percentage. - * @param {number=} collection-buffer-size The number of rows (or columns in a vertical scroll view) to load above and below the visible items. Default 2. This is good to set higher if you have lots of images to preload. Warning: the larger the buffer size, the worse performance will be. After ten or so you will see a difference. - * @param {boolean=} collection-refresh-images Whether to force images to refresh their `src` when an item's element is recycled. If provided, this stops problems with images still showing their old src when item's elements are recycled. - * If set to true, this comes with a small performance loss. Default false. + * #### Horizontal Scroller, Dynamic Item Width (codepen) + * ```html + * + * + * + * ``` * + * @param {expression} collection-repeat The expression indicating how to enumerate a collection, + * of the format `variable in expression` – where variable is the user defined loop variable + * and `expression` is a scope expression giving the collection to enumerate. + * For example: `album in artist.albums` or `album in artist.albums | orderBy:'name'`. + * @param {expression=} item-width The width of the repeated element. The expression must return + * a number (pixels) or a percentage. Defaults to the width of the first item in the list. + * @param {expression=} item-height The height of the repeated element. The expression must return + * a number (pixels) or a percentage. Defaults to the height of the first item in the list. + * @param {number=} item-render-buffer The number of items to load before and after the visible + * items in the list. Default 10. This is good to set higher if you have lots of images to preload. + * @param {boolean=} force-refresh-images Force images to refresh as you scroll. This fixes a problem + * where, when an element is interchanged as scrolling, its image will still have the old src + * while the new src loads. Setting this to true comes with a small performance loss. */ -var COLLECTION_REPEAT_SCROLLVIEW_XY_ERROR = "Cannot create a collection-repeat within a scrollView that is scrollable on both x and y axis. Choose either x direction or y direction."; -var COLLECTION_REPEAT_ATTR_HEIGHT_ERROR = "collection-repeat expected attribute collection-item-height to be a an expression that returns a number (in pixels) or percentage."; -var COLLECTION_REPEAT_ATTR_WIDTH_ERROR = "collection-repeat expected attribute collection-item-width to be a an expression that returns a number (in pixels) or percentage."; -var COLLECTION_REPEAT_ATTR_REPEAT_ERROR = "collection-repeat expected expression in form of '_item_ in _collection_[ track by _id_]' but got '%'"; IonicModule -.directive('collectionRepeat', [ - '$collectionRepeatManager', - '$collectionDataSource', - '$parse', -function($collectionRepeatManager, $collectionDataSource, $parse) { +.directive('collectionRepeat', CollectionRepeatDirective) +.factory('$ionicCollectionManager', RepeatManagerFactory); + +var ONE_PX_TRANSPARENT_IMG_SRC = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; +var WIDTH_HEIGHT_REGEX = /height:.*?px;\s*width:.*?px/; +var DEFAULT_RENDER_BUFFER = 10; + +CollectionRepeatDirective.$inject = ['$ionicCollectionManager', '$parse', '$window']; +function CollectionRepeatDirective($ionicCollectionManager, $parse, $window) { return { + restrict: 'A', priority: 1000, transclude: 'element', - terminal: true, $$tlb: true, - require: ['^$ionicScroll', '^?ionNavView'], - controller: [function() {}], - link: function($scope, $element, $attr, ctrls, $transclude) { - var scrollCtrl = ctrls[0]; - var navViewCtrl = ctrls[1]; - var wrap = jqLite('
'); - $element.parent()[0].insertBefore(wrap[0], $element[0]); - wrap.append($element); - - var scrollView = scrollCtrl.scrollView; - if (scrollView.options.scrollingX && scrollView.options.scrollingY) { - throw new Error(COLLECTION_REPEAT_SCROLLVIEW_XY_ERROR); - } - - var isVertical = !!scrollView.options.scrollingY; - if (isVertical && !$attr.collectionItemHeight) { - throw new Error(COLLECTION_REPEAT_ATTR_HEIGHT_ERROR); - } else if (!isVertical && !$attr.collectionItemWidth) { - throw new Error(COLLECTION_REPEAT_ATTR_WIDTH_ERROR); - } - - var heightParsed = $parse($attr.collectionItemHeight || '"100%"'); - var widthParsed = $parse($attr.collectionItemWidth || '"100%"'); - - var heightGetter = function(scope, locals) { - var result = heightParsed(scope, locals); - if (isString(result) && result.indexOf('%') > -1) { - return Math.floor(parseInt(result) / 100 * scrollView.__clientHeight); - } - return parseInt(result); - }; - var widthGetter = function(scope, locals) { - var result = widthParsed(scope, locals); - if (isString(result) && result.indexOf('%') > -1) { - return Math.floor(parseInt(result) / 100 * scrollView.__clientWidth); - } - return parseInt(result); - }; - - var match = $attr.collectionRepeat.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/); - if (!match) { - throw new Error(COLLECTION_REPEAT_ATTR_REPEAT_ERROR - .replace('%', $attr.collectionRepeat)); - } - var keyExpr = match[1]; - var listExpr = match[2]; - var trackByExpr = match[3]; - - var dataSource = new $collectionDataSource({ - scope: $scope, - transcludeFn: $transclude, - transcludeParent: $element.parent(), - keyExpr: keyExpr, - listExpr: listExpr, - trackByExpr: trackByExpr, - heightGetter: heightGetter, - widthGetter: widthGetter, - shouldRefreshImages: angular.isDefined($attr.collectionRefreshImages) && - $attr.collectionRefreshImages !== 'false' - }); - var collectionRepeatManager = new $collectionRepeatManager({ - dataSource: dataSource, - element: scrollCtrl.$element, - scrollView: scrollCtrl.scrollView, - bufferSize: parseInt($attr.collectionBufferSize) - }); - - var listExprParsed = $parse(listExpr); - $scope.$watchCollection(listExprParsed, function(value) { - if (value && !angular.isArray(value)) { - throw new Error("collection-repeat expects an array to repeat over, but instead got '" + typeof value + "'."); - } - rerender(value); - }); - - // Find every sibling before and after the repeated items, and pass them - // to the dataSource - var scrollViewContent = scrollCtrl.scrollView.__content; - function rerender(value) { - var beforeSiblings = []; - var afterSiblings = []; - var collectionRepeatNode = $element[0]; - var before = true; - var children = scrollViewContent.children; - var width, height, el, child; - - // Loop through all of the children of scrollViewContent. Put every child BEFORE - // the collectionRepeatNode in `beforeSiblings`, and all of the children AFTER - // the collectionRepeatNode in `afterSiblings`. - for (var i = 0; (child = children[i]); i++) { - if (child.hasAttribute('collection-repeat-ignore')) continue; - if (child.contains(collectionRepeatNode)) { - // Once we reach the collectionRepeatNode, we're now counting siblings AFTER the repeater. - before = false; - continue; - } - if ( (width = child.offsetWidth) && (height = child.offsetHeight) ) { - (before ? beforeSiblings : afterSiblings).push({ - width: width, - height: height, - element: (el = jqLite(child)), - scope: el.isolateScope() || el.scope(), - isOutside: true - }); - } - } - - scrollView.resize(); - dataSource.setData(value, beforeSiblings, afterSiblings); - collectionRepeatManager.resize(); - } - - var requiresRerender; - var lastDim = {}; - var newDim = {}; - function rerenderOnResize() { - newDim = { - width: scrollViewContent.clientWidth, - height: scrollViewContent.clientHeight - }; - if (!angular.equals(lastDim, newDim) && !$scope.$$disconnected) { - rerender(listExprParsed($scope)); - } - requiresRerender = (!newDim.width && !newDim.height); - lastDim = newDim; - } - - function viewEnter() { - if (requiresRerender) { - rerenderOnResize(); - } - } - - scrollCtrl.$element.on('scroll.resize', rerenderOnResize); - ionic.on('resize', rerenderOnResize, window); - var deregisterViewListener; - if (navViewCtrl) { - deregisterViewListener = navViewCtrl.scope.$on('$ionicView.afterEnter', viewEnter); - } - - $scope.$on('$destroy', function() { - collectionRepeatManager.destroy(); - dataSource.destroy(); - ionic.off('resize', rerenderOnResize, window); - (deregisterViewListener || noop)(); - }); - } + require: '^$ionicScroll', + link: postLink }; -}]) -.directive({ - ngSrc: collectionRepeatSrcDirective('ngSrc', 'src'), - ngSrcset: collectionRepeatSrcDirective('ngSrcset', 'srcset'), - ngHref: collectionRepeatSrcDirective('ngHref', 'href') -}); -// Fix for #1674 -// Problem: if an ngSrc or ngHref expression evaluates to a falsy value, it will -// not erase the previous truthy value of the href. -// In collectionRepeat, we re-use elements from before. So if the ngHref expression -// evaluates to truthy for item 1 and then falsy for item 2, if an element changes -// from representing item 1 to representing item 2, item 2 will still have -// item 1's href value. -// Solution: erase the href or src attribute if ngHref/ngSrc are falsy. -function collectionRepeatSrcDirective(ngAttrName, attrName) { - return [function() { - return { - priority: '99', // it needs to run after the attributes are interpolated - link: function(scope, element, attr) { - attr.$observe(ngAttrName, function(value) { - if (!value) { - element[0].removeAttribute(attrName); - } + function postLink(scope, element, attr, scrollCtrl, transclude) { + + var scrollView = scrollCtrl.scrollView; + var node = element[0]; + var containerNode = angular.element('
')[0]; + node.parentNode.replaceChild(containerNode, node); + + if (scrollView.options.scrollingX && scrollView.options.scrollingY) { + throw new Error("collection-repeat expected a parent x or y scrollView, not " + + "an xy scrollView."); + } + + var match = attr.collectionRepeat.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/); + if (!match) { + throw new Error("collection-repeat expected expression in form of '_item_ in " + + "_collection_[ track by _id_]' but got '" + attr.collectionRepeat + "'."); + } + var keyExpr = match[1]; + var listExpr = match[2]; + var heightData = {}; + var widthData = {}; + var computedStyleDimensions = {}; + var repeatManager; + + // attr.collectionBufferSize is deprecated + var renderBufferExpr = attr.itemRenderBuffer || attr.collectionBufferSize; + var renderBuffer = angular.isDefined(renderBufferExpr) ? + parseInt(renderBufferExpr) : + DEFAULT_RENDER_BUFFER; + + // attr.collectionItemHeight is deprecated + var heightExpr = attr.itemHeight || attr.collectionItemHeight; + // attr.collectionItemWidth is deprecated + var widthExpr = attr.itemWidth || attr.collectionItemWidth; + + //Height and width have four 'modes': + //1) Computed Mode + // - Nothing is supplied, so we getComputedStyle() on one element in the list and use + // that width and height value for the width and height of every item. This is re-computed + // every resize. + //2) Constant Mode, Static Integer + // - The user provides a constant number for width or height, in pixels. We parse it, + // store it on the `value` field, and it never changes + //3) Constant Mode, Percent + // - The user provides a percent string for width or height. The getter for percent is + // stored on the `getValue()` field, and is re-evaluated once every resize. The result + // is stored on the `value` field. + //4) Dynamic Mode + // - The user provides a dynamic expression for the width or height. This is re-evaluated + // for every item, stored on the `.getValue()` field. + if (!heightExpr && !widthExpr) { + heightData.computed = widthData.computed = true; + } else { + if (heightExpr) { + parseDimensionAttr(heightExpr, heightData); + } else { + heightData.computed = true; + } + if (!widthExpr) widthExpr = '"100%"'; + parseDimensionAttr(widthExpr, widthData); + } + + var afterItemsContainer = angular.element( + scrollView.__content.querySelector('.collection-repeat-after-container') + ); + if (!afterItemsContainer.length) { + var elementIsAfterRepeater = false; + var afterNodes = [].filter.call(scrollView.__content.childNodes, function(node) { + if (node.contains(containerNode)) { + elementIsAfterRepeater = true; + return false; + } + return elementIsAfterRepeater; + }); + afterItemsContainer = angular.element(''); + if (scrollView.options.scrollingX) { + afterItemsContainer.addClass('horizontal'); + } + afterItemsContainer.append(afterNodes); + scrollView.__content.appendChild(afterItemsContainer[0]); + } + + scrollCtrl.$element.one('scroll.init', refreshDimensions); + + var onWindowResize = ionic.animationFrameThrottle(validateResize); + angular.element($window).on('resize', onWindowResize); + + scope.$on('$destroy', function() { + angular.element($window).off('resize', onWindowResize); + scrollCtrl.$element && scrollCtrl.$element.off('scroll.init', refreshDimensions); + + computedStyleNode && computedStyleNode.parentNode && + computedStyleNode.parentNode.removeChild(computedStyleNode); + computedStyleScope && computedStyleScope.$destroy(); + computedStyleScope = computedStyleNode = null; + + repeatManager && repeatManager.destroy(); + repeatManager = null; + }); + scope.$on('$ionic.reconnectScope', function() { + if (refreshDimensions.queued) refreshDimensions(); + }); + + // Make sure this resize actually changed the size of the screen + function validateResize() { + var h = window.innerHeight || screen.height, w = window.innerWidth || screen.width; + if (validateResize.height !== h || validateResize.width !== w) { + refreshDimensions(); + } + validateResize.height = h; + validateResize.width = w; + } + function refreshDimensions() { + // If we're disconnected, don't refresh the dimensions. But mark that we need to once + // the scope reconnects. + if (scope.$$disconnected) return (refreshDimensions.queued = true); + refreshDimensions.queued = false; + + if (heightData.computed || widthData.computed) { + computeStyleDimensions(); + } + + if (heightData.computed) { + heightData.value = computedStyleDimensions.height; + } else if (!heightData.dynamic && heightData.getValue) { + // If it's a constant with a getter (eg percent), we just refresh .value after resize + heightData.value = heightData.getValue(); + } + if (widthData.computed) { + widthData.value = computedStyleDimensions.width; + } else if (!widthData.dynamic && widthData.getValue) { + // If it's a constant with a getter (eg percent), we just refresh .value after resize + widthData.value = widthData.getValue(); + } + // Dynamic dimensions aren't updated on resize. Since they're already dynamic anyway, + // .getValue() will be used. + + if (!repeatManager) { + repeatManager = new $ionicCollectionManager({ + afterItemsNode: afterItemsContainer[0], + containerNode: containerNode, + heightData: heightData, + widthData: widthData, + forceRefreshImages: !!(isDefined(attr.forceRefreshImages) && attr.forceRefreshImages !== 'false'), + keyExpression: keyExpr, + listExpression: listExpr, + renderBuffer: renderBuffer, + scope: scope, + scrollView: scrollCtrl.scrollView, + transclude: transclude, }); } - }; - }]; + repeatManager.refreshLayout(); + } + + function parseDimensionAttr(attrValue, dimensionData) { + if (!attrValue) return; + + var parsedValue; + // Try to just parse the plain attr value + try { + parsedValue = $parse(attrValue); + } catch(e) { + // If the parse fails and the value has `px` or `%` in it, surround the attr in + // quotes, to attempt to let the user provide a simple `attr="100%"` or `attr="100px"` + if (attrValue.indexOf('%') !== -1 || attrValue.indexOf('px') !== -1) { + attrValue = '"' + attrValue + '"'; + } + parsedValue = $parse(attrValue); + } + + dimensionData.attrValue = attrValue; + + // If it's a constant, it's either a percent or just a constant pixel number. + if (parsedValue.constant) { + var intValue = parseInt(parsedValue()); + + // For percents, store the percent getter on .getValue() + if (attrValue.indexOf('%') > -1) { + var decimalValue = intValue / 100; + dimensionData.getValue = dimensionData === heightData ? + function() { return Math.floor(decimalValue * scrollView.__clientHeight); } : + function() { return Math.floor(decimalValue * scrollView.__clientWidth); }; + } else { + // For static constants, just store the static constant. + dimensionData.value = intValue; + } + + } else { + dimensionData.dynamic = true; + dimensionData.getValue = dimensionData === heightData ? + function heightGetter(scope, locals) { + var result = parsedValue(scope, locals); + if (result.charAt && result.charAt(result.length - 1) === '%') + return Math.floor(parseInt(result) / 100 * scrollView.__clientHeight); + return parseInt(result); + } : + function widthGetter(scope, locals) { + var result = parsedValue(scope, locals); + if (result.charAt && result.charAt(result.length - 1) === '%') + return Math.floor(parseInt(result) / 100 * scrollView.__clientWidth); + return parseInt(result); + }; + } + } + + var computedStyleNode; + var computedStyleScope; + function computeStyleDimensions() { + if (!computedStyleNode) { + transclude(computedStyleScope = scope.$new(), function(clone) { + clone[0].removeAttribute('collection-repeat'); // remove absolute position styling + computedStyleNode = clone[0]; + }); + } + containerNode.appendChild(computedStyleNode); + + var style = $window.getComputedStyle(computedStyleNode); + computedStyleDimensions.width = parseInt(style.width); + computedStyleDimensions.height = parseInt(style.height); + + containerNode.removeChild(computedStyleNode); + } + + } + } + +RepeatManagerFactory.$inject = ['$rootScope', '$window', '$$rAF']; +function RepeatManagerFactory($rootScope, $window, $$rAF) { + var EMPTY_DIMENSION = { primaryPos: 0, secondaryPos: 0, primarySize: 0, secondarySize: 0 }; + + return function RepeatController(options) { + var afterItemsNode = options.afterItemsNode; + var containerNode = options.containerNode; + var forceRefreshImages = options.forceRefreshImages; + var heightData = options.heightData; + var widthData = options.widthData; + var keyExpression = options.keyExpression; + var listExpression = options.listExpression; + var renderBuffer = options.renderBuffer; + var scope = options.scope; + var scrollView = options.scrollView; + var transclude = options.transclude; + + var data = []; + + var getterLocals = {}; + var heightFn = heightData.getValue || function() { return heightData.value; }; + var heightGetter = function(index, value) { + getterLocals[keyExpression] = value; + getterLocals.$index = index; + return heightFn(scope, getterLocals); + }; + + var widthFn = widthData.getValue || function() { return widthData.value; }; + var widthGetter = function(index, value) { + getterLocals[keyExpression] = value; + getterLocals.$index = index; + return widthFn(scope, getterLocals); + }; + + var isVertical = !!scrollView.options.scrollingY; + + // We say it's a grid view if we're either dynamic or not 100% width + var isGridView = isVertical ? + (widthData.dynamic || widthData.value !== scrollView.__clientWidth) : + (heightData.dynamic || heightData.value !== scrollView.__clientHeight); + + var isStaticView = !heightData.dynamic && !widthData.dynamic; + + var PRIMARY = 'PRIMARY'; + var SECONDARY = 'SECONDARY'; + var TRANSLATE_TEMPLATE_STR = isVertical ? + 'translate3d(SECONDARYpx,PRIMARYpx,0)' : + 'translate3d(PRIMARYpx,SECONDARYpx,0)'; + var WIDTH_HEIGHT_TEMPLATE_STR = isVertical ? + 'height: PRIMARYpx; width: SECONDARYpx;' : + 'height: SECONDARYpx; width: PRIMARYpx;'; + + var estimatedHeight; + var estimatedWidth; + + var repeaterBeforeSize = 0; + var repeaterAfterSize = 0; + + var renderStartIndex = -1; + var renderEndIndex = -1; + var renderAfterBoundary = -1; + var renderBeforeBoundary = -1; + + var itemsPool = []; + var itemsLeaving = []; + var itemsEntering = []; + var itemsShownMap = {}; + var nextItemId = 0; + var estimatedItemsAcross; + + // view is a mix of list/grid methods + static/dynamic methods. + // See bottom for implementations. Available methods: + // + // getEstimatedPrimaryPos(i), getEstimatedSecondaryPos(i), getEstimatedIndex(scrollTop), + // calculateDimensions(toIndex), getDimensions(index), + // updateRenderRange(scrollTop, scrollValueEnd), onRefreshLayout(), onRefreshData() + var view = isVertical ? new VerticalViewType() : new HorizontalViewType(); + (isGridView ? GridViewType : ListViewType).call(view); + (isStaticView ? StaticViewType : DynamicViewType).call(view); + + var isLayoutReady = false; + var isDataReady = false; + this.refreshLayout = function(itemsAfterRepeater) { + estimatedHeight = heightGetter(0, data[0]); + estimatedWidth = widthGetter(0, data[0]); + + // Get the size of every element AFTER the repeater. We have to get the margin before and + // after the first/last element to fix a browser bug with getComputedStyle() not counting + // the first/last child's margins into height. + var style = getComputedStyle(afterItemsNode); + var firstStyle = getComputedStyle(afterItemsNode.firstElementChild); + var lastStyle = getComputedStyle(afterItemsNode.lastElementChild); + repeaterAfterSize = (parseInt(style[isVertical ? 'height' : 'width']) || 0) + + (firstStyle && parseInt(firstStyle[isVertical ? 'marginTop' : 'marginLeft']) || 0) + + (lastStyle && parseInt(lastStyle[isVertical ? 'marginBottom' : 'marginRight']) || 0); + + // Get the offsetTop of the repeater. + repeaterBeforeSize = 0; + var current = containerNode; + do { + repeaterBeforeSize += current[isVertical ? 'offsetTop' : 'offsetLeft']; + } while ( scrollView.__content.contains(current = current.offsetParent) ); + + (view.onRefreshLayout || angular.noop)(); + view.refreshDirection(); + + // Create the pool of items for reuse, setting the size to (estimatedItemsOnScreen) * 2, + // plus the size of the renderBuffer. + if (!isLayoutReady) { + var poolSize = 2 * view.scrollPrimarySize / + view.estimatedPrimarySize * view.estimatedItemsAcross + (renderBuffer * 2); + for (var i = 0; i < poolSize; i++) { + itemsPool.push(new RepeatItem()); + } + } + + isLayoutReady = true; + if (isLayoutReady && isDataReady) { + forceRerender(); + } + }; + + this.refreshData = function(newData) { + newData || (newData = []); + + if (!angular.isArray(newData)) { + throw new Error("collection-repeat expected an array for '" + listExpression + "', " + + "but got a " + typeof value); + } + + data = newData; + (view.onRefreshData || angular.noop)(); + + isDataReady = true; + if (isLayoutReady && isDataReady) { + forceRerender(); + } + }; + + var unwatch = scope.$watchCollection(listExpression, angular.bind(this, this.refreshData)); + this.destroy = function() { + render.destroyed = true; + unwatch(); + + scrollView.__calback = scrollView.__$callback; + itemsPool.forEach(function(item) { + item.scope.$destroy(); + item.scope = item.element = item.node = item.images = null; + }); + itemsPool.length = itemsEntering.length = itemsLeaving.length = 0; + itemsShownMap = {}; + + (view.onDestroy || angular.noop)(); + }; + + scrollView.options[isVertical ? 'getContentHeight' : 'getContentWidth'] = + angular.bind(view, view.getContentSize); + + scrollView.__$callback = scrollView.__callback; + scrollView.__callback = function(transformLeft, transformTop, zoom, wasResize) { + var scrollValue = view.getScrollValue(); + if (renderStartIndex === -1 || + scrollValue + view.scrollPrimarySize > renderAfterBoundary || + scrollValue < renderBeforeBoundary) { + render(); + } + scrollView.__$callback(transformLeft, transformTop, zoom, wasResize); + }; + + + function forceRerender() { + return render(true); + } + function render(forceRerender) { + if (render.destroyed) return; + var i; + var item; + var dim; + var scope; + var scrollValue = view.getScrollValue(); + var scrollValueEnd = scrollValue + view.scrollPrimarySize; + + view.updateRenderRange(scrollValue, scrollValueEnd); + + renderStartIndex = Math.max(0, renderStartIndex - renderBuffer); + renderEndIndex = Math.min(data.length - 1, renderEndIndex + renderBuffer); + + for (i in itemsShownMap) { + if (i < renderStartIndex || i > renderEndIndex) { + item = itemsShownMap[i]; + delete itemsShownMap[i]; + itemsLeaving.push(item); + item.isShown = false; + } + } + + // Render indicies that aren't shown yet + // + // NOTE(ajoslin): this may sound crazy, but calling any other functions during this render + // loop will often push the render time over the edge from less than one frame to over + // one frame, causing visible jank. + // DON'T call any other functions inside this loop unless it's vital. + for (i = renderStartIndex; i <= renderEndIndex; i++) { + // If the item at this index is already shown, skip + if (i >= data.length || itemsShownMap[i] && !forceRerender) continue; + + item = itemsShownMap[i] || (itemsShownMap[i] = getNextItem()); + itemsEntering.push(item); + item.isShown = true; + + scope = item.scope; + scope.$index = i; + scope[keyExpression] = data[i]; + scope.$first = (i === 0); + scope.$last = (i === (data.length - 1)); + scope.$middle = !(scope.$first || scope.$last); + scope.$odd = !(scope.$even = (i&1) === 0); + + if (scope.$$disconnected) ionic.Utils.reconnectScope(item.scope); + + dim = view.getDimensions(i); + if (item.secondaryPos !== dim.secondaryPos || item.primaryPos !== dim.primaryPos) { + item.node.style[ionic.CSS.TRANSFORM] = TRANSLATE_TEMPLATE_STR + .replace(PRIMARY, (item.primaryPos = dim.primaryPos)) + .replace(SECONDARY, (item.secondaryPos = dim.secondaryPos)); + } + if (item.secondarySize !== dim.secondarySize || item.primarySize !== dim.primarySize) { + item.node.style.cssText = item.node.style.cssText + .replace(WIDTH_HEIGHT_REGEX, WIDTH_HEIGHT_TEMPLATE_STR + .replace(PRIMARY, 1 + (item.primarySize = dim.primarySize)) + .replace(SECONDARY, (item.secondarySize = dim.secondarySize)) + ); + } + + } + + // If we reach the end of the list, render the afterItemsNode - this contains all the + // elements the developer placed after the collection-repeat + if (renderEndIndex === data.length - 1) { + dim = view.getDimensions(data.length - 1) || EMPTY_DIMENSION; + afterItemsNode.style[ionic.CSS.TRANSFORM] = TRANSLATE_TEMPLATE_STR + .replace(PRIMARY, dim.primaryPos + dim.primarySize) + .replace(SECONDARY, 0); + } + + while (itemsLeaving.length) { + item = itemsLeaving.pop(); + ionic.Utils.disconnectScope(item.scope); + itemsPool.push(item); + item.node.style[ionic.CSS.TRANSFORM] = 'translate3d(-9999px,-9999px,0)'; + item.primaryPos = item.secondaryPos = null; + } + + if (forceRefreshImages) { + for (i = 0, ii = itemsEntering.length; i < ii && (item = itemsEntering[i]); i++) { + if (!item.images) continue; + for (var j = 0, jj = item.images.length, img; j < jj && (img = item.images[j]); j++) { + var src = img.src; + img.src = ONE_PX_TRANSPARENT_IMG_SRC; + img.src = src; + } + } + } + if (forceRerender) { + var rootScopePhase = $rootScope.$$phase; + while (itemsEntering.length) { + item = itemsEntering.pop(); + if (!rootScopePhase) item.scope.$digest(); + } + } else { + digestEnteringItems(); + } + } + + function getNextItem() { + if (itemsLeaving.length) + return itemsLeaving.pop(); + else if (itemsPool.length) + return itemsPool.shift(); + return new RepeatItem(); + } + + function digestEnteringItems() { + var item; + var scope; + var len; + if (digestEnteringItems.running) return; + digestEnteringItems.running = true; + + $$rAF(function process() { + if( (len = itemsEntering.length) ) { + var count = Math.floor(len / 1.5) || 1; + var rootScopePhase = $rootScope.$$phase; + while (count && itemsEntering.length) { + item = itemsEntering.pop(); + if (item.isShown) { + count--; + if (!$rootScope.$$phase) item.scope.$digest(); + } + } + $$rAF(process); + } else { + digestEnteringItems.running = false; + } + }); + } + + function RepeatItem() { + var self = this; + this.scope = scope.$new(); + this.id = 'item_'+ (nextItemId++); + transclude(this.scope, function(clone) { + self.element = clone; + self.element.data('$$collectionRepeatItem', self); + // TODO destroy + self.node = clone[0]; + // Batch style setting to lower repaints + self.node.style[ionic.CSS.TRANSFORM] = 'translate3d(-9999px,-9999px,0)'; + self.node.style.cssText += ' height: 0px; width: 0px;'; + ionic.Utils.disconnectScope(self.scope); + containerNode.appendChild(self.node); + self.images = clone[0].getElementsByTagName('img'); + }); + } + + function VerticalViewType() { + this.getItemPrimarySize = heightGetter; + this.getItemSecondarySize = widthGetter; + + this.getScrollValue = function() { + return Math.max(0, Math.min(scrollView.__scrollTop - repeaterBeforeSize, + scrollView.__maxScrollTop - repeaterBeforeSize - repeaterAfterSize)); + }; + + this.refreshDirection = function() { + this.scrollPrimarySize = scrollView.__clientHeight; + this.scrollSecondarySize = scrollView.__clientWidth; + + this.estimatedPrimarySize = estimatedHeight; + this.estimatedSecondarySize = estimatedWidth; + this.estimatedItemsAcross = isGridView && + Math.floor(scrollView.__clientWidth / estimatedWidth) || + 1; + }; + } + function HorizontalViewType() { + this.getItemPrimarySize = widthGetter; + this.getItemSecondarySize = heightGetter; + + this.getScrollValue = function() { + return Math.max(0, Math.min(scrollView.__scrollLeft - repeaterBeforeSize, + scrollView.__maxScrollLeft - repeaterBeforeSize - repeaterAfterSize)); + }; + + this.refreshDirection = function() { + this.scrollPrimarySize = scrollView.__clientWidth; + this.scrollSecondarySize = scrollView.__clientHeight; + + this.estimatedPrimarySize = estimatedWidth; + this.estimatedSecondarySize = estimatedHeight; + this.estimatedItemsAcross = isGridView && + Math.floor(scrollView.__clientHeight / estimatedHeight) || + 1; + }; + } + + function GridViewType() { + this.getEstimatedSecondaryPos = function(index) { + return (index % this.estimatedItemsAcross) * this.estimatedPrimarySize; + }; + this.getEstimatedPrimaryPos = function(index) { + return Math.floor(index / this.estimatedItemsAcross) * this.estimatedPrimarySize; + }; + this.getEstimatedIndex = function(scrollValue) { + return Math.floor(scrollValue / this.estimatedPrimarySize) * + this.estimatedItemsAcross; + }; + } + + function ListViewType() { + this.getEstimatedSecondaryPos = function() { + return 0; + }; + this.getEstimatedPrimaryPos = function(index) { + return index * this.estimatedPrimarySize; + }; + this.getEstimatedIndex = function(scrollValue) { + return Math.floor((scrollValue) / this.estimatedPrimarySize); + }; + } + + function StaticViewType() { + this.getContentSize = function() { + return this.getEstimatedPrimaryPos(data.length - 1) + this.estimatedPrimarySize + + repeaterBeforeSize + repeaterAfterSize; + }; + // static view always returns the same object for getDimensions, to avoid memory allocation + // while scrolling. This could be dangerous if this was a public function, but it's not. + // Only we use it. + var dim = {}; + this.getDimensions = function(index) { + dim.primaryPos = this.getEstimatedPrimaryPos(index); + dim.secondaryPos = this.getEstimatedSecondaryPos(index); + dim.primarySize = this.estimatedPrimarySize; + dim.secondarySize = this.estimatedSecondarySize; + return dim; + }; + this.updateRenderRange = function(scrollValue, scrollValueEnd) { + renderStartIndex = Math.max(0, this.getEstimatedIndex(scrollValue)); + + // Make sure the renderEndIndex takes into account all the items on the row + renderEndIndex = Math.min(data.length - 1, + this.getEstimatedIndex(scrollValueEnd) + this.estimatedItemsAcross - 1); + + renderBeforeBoundary = Math.max(0, + this.getEstimatedPrimaryPos(renderStartIndex)); + renderAfterBoundary = this.getEstimatedPrimaryPos(renderEndIndex) + + this.estimatedPrimarySize; + }; + } + + function DynamicViewType() { + var self = this; + var scrollViewSetDimensions = isVertical ? + function() { + scrollView.setDimensions(null, null, null, self.getContentSize(), true); + } : + function() { + scrollView.setDimensions(null, null, self.getContentSize(), null, true); + }; + var debouncedScrollViewSetDimensions = ionic.debounce(scrollViewSetDimensions, 25, true); + var calculateDimensions = isGridView ? calculateDimensionsGrid : calculateDimensionsList; + var dimensionsIndex; + var dimensions = []; + + // Get the dimensions at index. {width, height, left, top}. + // We start with no dimensions calculated, then any time dimensions are asked for at an + // index we calculate dimensions up to there. + function calculateDimensionsList(toIndex) { + var i, prevDimension, dim; + for (i = Math.max(0, dimensionsIndex); i <= toIndex && (dim = dimensions[i]); i++) { + prevDimension = dimensions[i - 1] || EMPTY_DIMENSION; + dim.primarySize = self.getItemPrimarySize(i, data[i]); + dim.secondarySize = self.scrollSecondarySize; + dim.primaryPos = prevDimension.primaryPos + prevDimension.primarySize; + dim.secondaryPos = 0; + } + } + function calculateDimensionsGrid(toIndex) { + var i, prevDimension, dim; + for (i = Math.max(dimensionsIndex, 0); i <= toIndex && (dim = dimensions[i]); i++) { + prevDimension = dimensions[i - 1] || EMPTY_DIMENSION; + dim.secondarySize = Math.min( + self.getItemSecondarySize(i, data[i]), + self.scrollSecondarySize + ); + dim.secondaryPos = prevDimension.secondaryPos + prevDimension.secondarySize; + + if (i === 0 || dim.secondaryPos + dim.secondarySize > self.scrollSecondarySize) { + dim.rowStartIndex = i; + dim.secondaryPos = 0; + dim.primarySize = self.getItemPrimarySize(i, data[i]); + dim.primaryPos = prevDimension.primaryPos + prevDimension.primarySize; + } else { + dim.rowStartIndex = prevDimension.rowStartIndex; + dim.primarySize = prevDimension.primarySize; + dim.primaryPos = prevDimension.primaryPos; + } + } + } + + this.getContentSize = function() { + var dim = dimensions[dimensionsIndex] || EMPTY_DIMENSION; + return ((dim.primaryPos + dim.primarySize) || 0) + + this.getEstimatedPrimaryPos(data.length - dimensionsIndex - 1) + + repeaterBeforeSize + repeaterAfterSize; + }; + this.onDestroy = function() { + dimensions.length = 0; + }; + + this.onRefreshData = function() { + // Make sure dimensions has as many items as data.length. + // This is to be sure we don't have to allocate objects while scrolling. + for (i = dimensions.length, len = data.length; i < len; i++) { + dimensions.push({}); + } + dimensionsIndex = -1; + }; + this.onRefreshLayout = function() { + dimensionsIndex = -1; + }; + this.getDimensions = function(index) { + index = Math.min(index, data.length - 1); + + if (dimensionsIndex < index) { + // Once we start asking for dimensions near the end of the list, go ahead and calculate + // everything. This is to make sure when the user gets to the end of the list, the + // scroll height of the list is 100% accurate (not estimated anymore). + if (index > data.length * 0.9) { + calculateDimensions(data.length - 1); + dimensionsIndex = data.length - 1; + scrollViewSetDimensions(); + } else { + calculateDimensions(index); + dimensionsIndex = index; + debouncedScrollViewSetDimensions(); + } + + } + return dimensions[index]; + }; + + var oldRenderStartIndex = -1; + var oldScrollValue = -1; + this.updateRenderRange = function(scrollValue, scrollValueEnd) { + var i; + var len; + var dim; + + // Calculate more dimensions than we estimate we'll need, to be sure. + this.getDimensions( this.getEstimatedIndex(scrollValueEnd) * 2 ); + + // -- Calculate renderStartIndex + // base case: start at 0 + if (oldRenderStartIndex === -1 || scrollValue === 0) { + i = 0; + // scrolling down + } else if (scrollValue >= oldScrollValue) { + for (i = oldRenderStartIndex, len = data.length; i < len; i++) { + if ((dim = this.getDimensions(i)) && dim.primaryPos + dim.primarySize >= scrollValue) { + break; + } + } + // scrolling up + } else { + for (i = oldRenderStartIndex; i >= 0; i--) { + if ((dim = this.getDimensions(i)) && dim.primaryPos <= scrollValue) { + // when grid view, make sure the render starts at the beginning of a row. + i = isGridView ? dim.rowStartIndex : i; + break; + } + } + } + + renderStartIndex = Math.min(Math.max(0, i), data.length - 1); + renderBeforeBoundary = renderStartIndex !== -1 ? this.getDimensions(renderStartIndex).primaryPos : -1; + + // -- Calculate renderEndIndex + var lastRowDim; + for (i = renderStartIndex + 1, len = data.length; i < len; i++) { + if ((dim = this.getDimensions(i)) && dim.primaryPos + dim.primarySize > scrollValueEnd) { + + // Go all the way to the end of the row if we're in a grid + if (isGridView) { + lastRowDim = dim; + while (i < len - 1 && + (dim = this.getDimensions(i + 1)).primaryPos === lastRowDim.primaryPos) { + i++; + } + } + break; + } + } + + renderEndIndex = Math.min(i, data.length - 1); + renderAfterBoundary = renderEndIndex !== -1 ? + ((dim = this.getDimensions(renderEndIndex)).primaryPos + dim.primarySize) : + -1; + + oldScrollValue = scrollValue; + oldRenderStartIndex = renderStartIndex; + }; + + } + + + }; + +} + + diff --git a/js/angular/service/collectionRepeatDataSource.js b/js/angular/service/collectionRepeatDataSource.js deleted file mode 100644 index 803aba374c..0000000000 --- a/js/angular/service/collectionRepeatDataSource.js +++ /dev/null @@ -1,169 +0,0 @@ -IonicModule -.factory('$collectionDataSource', [ - '$cacheFactory', - '$parse', - '$rootScope', -function($cacheFactory, $parse, $rootScope) { - var ONE_PX_TRANSPARENT_IMG_SRC = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - function hideWithTransform(element) { - element.css(ionic.CSS.TRANSFORM, 'translate3d(-2000px,-2000px,0)'); - } - - function CollectionRepeatDataSource(options) { - var self = this; - this.scope = options.scope; - this.transcludeFn = options.transcludeFn; - this.transcludeParent = options.transcludeParent; - this.element = options.element; - this.shouldRefreshImages = options.shouldRefreshImages; - - this.keyExpr = options.keyExpr; - this.listExpr = options.listExpr; - this.trackByExpr = options.trackByExpr; - - this.heightGetter = options.heightGetter; - this.widthGetter = options.widthGetter; - - this.dimensions = []; - this.data = []; - - this.attachedItems = {}; - this.BACKUP_ITEMS_LENGTH = 20; - this.backupItemsArray = []; - } - CollectionRepeatDataSource.prototype = { - setup: function() { - if (this.isSetup) return; - this.isSetup = true; - for (var i = 0; i < this.BACKUP_ITEMS_LENGTH; i++) { - this.detachItem(this.createItem()); - } - }, - destroy: function() { - this.dimensions.length = 0; - this.data = null; - this.backupItemsArray.length = 0; - this.attachedItems = {}; - }, - calculateDataDimensions: function() { - var locals = {}; - this.dimensions = this.data.map(function(value, index) { - locals[this.keyExpr] = value; - locals.$index = index; - return { - width: this.widthGetter(this.scope, locals), - height: this.heightGetter(this.scope, locals) - }; - }, this); - this.dimensions = this.beforeSiblings.concat(this.dimensions).concat(this.afterSiblings); - this.dataStartIndex = this.beforeSiblings.length; - }, - createItem: function() { - var item = {}; - - item.scope = this.scope.$new(); - this.transcludeFn(item.scope, function(el) { - item.element = el; - item.images = el[0].getElementsByTagName('img'); - }); - this.transcludeParent.append(item.element); - - return item; - }, - getItem: function(index) { - var item; - if ( (item = this.attachedItems[index]) ) { - //do nothing, the item is good - } else if ( (item = this.backupItemsArray.pop()) ) { - ionic.Utils.reconnectScope(item.scope); - } else { - item = this.createItem(); - } - return item; - }, - attachItemAtIndex: function(index) { - if (index < this.dataStartIndex) { - return this.beforeSiblings[index]; - } - // Subtract so we start at the beginning of this.data, after - // this.beforeSiblings. - index -= this.dataStartIndex; - if (index > this.data.length - 1) { - return this.afterSiblings[index - this.data.length]; - } - - var item = this.getItem(index); - var value = this.data[index]; - - if (item.index !== index || item.scope[this.keyExpr] !== value) { - item.index = item.scope.$index = index; - item.scope[this.keyExpr] = value; - item.scope.$first = (index === 0); - item.scope.$last = (index === (this.getLength() - 1)); - item.scope.$middle = !(item.scope.$first || item.scope.$last); - item.scope.$odd = !(item.scope.$even = (index&1) === 0); - - //We changed the scope, so digest if needed - if (!$rootScope.$$phase) { - item.scope.$digest(); - this.shouldRefreshImages && refreshImages(item.images); - } - } - this.attachedItems[index] = item; - - return item; - }, - destroyItem: function(item) { - item.element.remove(); - item.scope.$destroy(); - item.scope = null; - item.element = null; - }, - detachItem: function(item) { - delete this.attachedItems[item.index]; - - //If it's an outside item, only hide it. These items aren't part of collection - //repeat's list, only sit outside - if (item.isOutside) { - hideWithTransform(item.element); - // If we are at the limit of backup items, just get rid of the this element - } else if (this.backupItemsArray.length >= this.BACKUP_ITEMS_LENGTH) { - this.destroyItem(item); - // Otherwise, add it to our backup items - } else { - this.backupItemsArray.push(item); - hideWithTransform(item.element); - //Don't .$destroy(), just stop watchers and events firing - ionic.Utils.disconnectScope(item.scope); - } - - }, - getLength: function() { - return this.dimensions && this.dimensions.length || 0; - }, - setData: function(value, beforeSiblings, afterSiblings) { - this.data = value || []; - this.beforeSiblings = beforeSiblings || []; - this.afterSiblings = afterSiblings || []; - this.calculateDataDimensions(); - - this.afterSiblings.forEach(function(item) { - item.element.css({position: 'absolute', top: '0', left: '0' }); - hideWithTransform(item.element); - }); - }, - }; - - return CollectionRepeatDataSource; - - function refreshImages(imgNodes) { - var i, len, img, src; - for (i = 0, len = imgNodes.length; i < len; i++) { - img = imgNodes[i]; - src = img.src; - img.src = ONE_PX_TRANSPARENT_IMG_SRC; - img.src = src; - } - } -}]); - diff --git a/js/angular/service/collectionRepeatManager.js b/js/angular/service/collectionRepeatManager.js deleted file mode 100644 index 3a5dceca7e..0000000000 --- a/js/angular/service/collectionRepeatManager.js +++ /dev/null @@ -1,366 +0,0 @@ - -IonicModule -.factory('$collectionRepeatManager', [ - '$rootScope', - '$timeout', -function($rootScope, $timeout) { - /** - * Vocabulary: "primary" and "secondary" size/direction/position mean - * "y" and "x" for vertical scrolling, or "x" and "y" for horizontal scrolling. - */ - function CollectionRepeatManager(options) { - var self = this; - this.dataSource = options.dataSource; - this.element = options.element; - this.scrollView = options.scrollView; - - this.bufferSize = options.bufferSize || 2; - this.bufferItems = Math.max(this.bufferSize * 10, 50); - - this.isVertical = !!this.scrollView.options.scrollingY; - this.renderedItems = {}; - this.dimensions = []; - this.setCurrentIndex(0); - - //Override scrollview's render callback - this.scrollView.__$callback = this.scrollView.__callback; - this.scrollView.__callback = angular.bind(this, this.renderScroll); - - function getViewportSize() { return self.viewportSize; } - //Set getters and setters to match whether this scrollview is vertical or not - if (this.isVertical) { - this.scrollView.options.getContentHeight = getViewportSize; - - this.scrollValue = function() { - return this.scrollView.__scrollTop; - }; - this.scrollMaxValue = function() { - return this.scrollView.__maxScrollTop; - }; - this.scrollSize = function() { - return this.scrollView.__clientHeight; - }; - this.secondaryScrollSize = function() { - return this.scrollView.__clientWidth; - }; - this.transformString = function(y, x) { - return 'translate3d(' + x + 'px,' + y + 'px,0)'; - }; - this.primaryDimension = function(dim) { - return dim.height; - }; - this.secondaryDimension = function(dim) { - return dim.width; - }; - } else { - this.scrollView.options.getContentWidth = getViewportSize; - - this.scrollValue = function() { - return this.scrollView.__scrollLeft; - }; - this.scrollMaxValue = function() { - return this.scrollView.__maxScrollLeft; - }; - this.scrollSize = function() { - return this.scrollView.__clientWidth; - }; - this.secondaryScrollSize = function() { - return this.scrollView.__clientHeight; - }; - this.transformString = function(x, y) { - return 'translate3d(' + x + 'px,' + y + 'px,0)'; - }; - this.primaryDimension = function(dim) { - return dim.width; - }; - this.secondaryDimension = function(dim) { - return dim.height; - }; - } - } - - - CollectionRepeatManager.prototype = { - destroy: function() { - this.renderedItems = {}; - this.render = noop; - this.calculateDimensions = noop; - this.dimensions = []; - }, - - /* - * Pre-calculate the position of all items in the data list. - * Do this using the provided width and height (primarySize and secondarySize) - * provided by the dataSource. - */ - calculateDimensions: function() { - /* - * For the sake of explanations below, we're going to pretend we are scrolling - * vertically: Items are laid out with primarySize being height, - * secondarySize being width. - */ - var self = this; - var primaryPos = 0; - var secondaryPos = 0; - var secondaryScrollSize = self.secondaryScrollSize(); - var previousItem; - var i, len; - - // Skip past every beforeSibling, we want our list to start after those. - for (i = 0, len = (self.dataSource.beforeSiblings || []).length; i < len; i++) { - calculateSize(self.dataSource.beforeSiblings[i]); - } - var beforeSize = primaryPos + (previousItem && previousItem.primarySize || 0); - - primaryPos = secondaryPos = 0; - previousItem = null; - - var dimensions = []; - for (i = 0, len = self.dataSource.dimensions.length; i < len; i++) { - dimensions.push( calculateSize(self.dataSource.dimensions[i]) ); - } - var totalSize = primaryPos + (previousItem && previousItem.primarySize || 0); - - return { - beforeSize: beforeSize, - totalSize: totalSize, - dimensions: dimensions - }; - - function calculateSize(dim) { - - //Each dimension is an object {width: Number, height: Number} provided by - //the dataSource - var rect = { - //Get the height out of the dimension object - primarySize: self.primaryDimension(dim), - //Max out the item's width to the width of the scrollview - secondarySize: Math.min(self.secondaryDimension(dim), secondaryScrollSize) - }; - - //If self isn't the first item - if (previousItem) { - //Move the item's x position over by the width of the previous item - secondaryPos += previousItem.secondarySize; - //If the y position is the same as the previous item and - //the x position is bigger than the scroller's width - if (previousItem.primaryPos === primaryPos && - secondaryPos + rect.secondarySize > secondaryScrollSize) { - //Then go to the next row, with x position 0 - secondaryPos = 0; - primaryPos += previousItem.primarySize; - } - } - - rect.primaryPos = primaryPos; - rect.secondaryPos = secondaryPos; - - previousItem = rect; - return rect; - } - }, - - resize: function() { - var result = this.calculateDimensions(); - this.dimensions = result.dimensions; - this.viewportSize = result.totalSize; - this.beforeSize = result.beforeSize; - this.setCurrentIndex(0); - this.render(true); - this.dataSource.transcludeParent[0].style.height = result.totalSize + "px"; - this.dataSource.setup(); - }, - /* - * setCurrentIndex sets the index in the list that matches the scroller's position. - * Also save the position in the scroller for next and previous items (if they exist) - */ - setCurrentIndex: function(index, height) { - var currentPos = (this.dimensions[index] || {}).primaryPos || 0; - this.currentIndex = index; - - this.hasPrevIndex = index > 0; - if (this.hasPrevIndex) { - this.previousPos = Math.max( - currentPos - this.dimensions[index - 1].primarySize, - this.dimensions[index - 1].primaryPos - ); - } - this.hasNextIndex = index + 1 < this.dataSource.getLength(); - if (this.hasNextIndex) { - this.nextPos = Math.min( - currentPos + this.dimensions[index + 1].primarySize, - this.dimensions[index + 1].primaryPos - ); - } - }, - /** - * override the scroller's render callback to check if we need to - * re-render our collection - */ - renderScroll: ionic.animationFrameThrottle(function(transformLeft, transformTop, zoom, wasResize) { - if (this.isVertical) { - this.renderIfNeeded(transformTop); - } else { - this.renderIfNeeded(transformLeft); - } - return this.scrollView.__$callback(transformLeft, transformTop, zoom, wasResize); - }), - - renderIfNeeded: function(scrollPos) { - if ((this.hasNextIndex && scrollPos >= this.nextPos) || - (this.hasPrevIndex && scrollPos < this.previousPos)) { - // Math.abs(transformPos - this.lastRenderScrollValue) > 100) { - this.render(); - } - }, - /* - * getIndexForScrollValue: Given the most recent data index and a new scrollValue, - * find the data index that matches that scrollValue. - * - * Strategy (if we are scrolling down): keep going forward in the dimensions list, - * starting at the given index, until an item with height matching the new scrollValue - * is found. - * - * This is a while loop. In the worst case it will have to go through the whole list - * (eg to scroll from top to bottom). The most common case is to scroll - * down 1-3 items at a time. - * - * While this is not as efficient as it could be, optimizing it gives no noticeable - * benefit. We would have to use a new memory-intensive data structure for dimensions - * to fully optimize it. - */ - getIndexForScrollValue: function(i, scrollValue) { - var dimensions = this.dimensions; - var rect; - //Scrolling up - if (scrollValue <= dimensions[i].primaryPos) { - while ( (rect = dimensions[i - 1]) && rect.primaryPos > scrollValue) { - i--; - } - //Scrolling down - } else { - while ((rect = dimensions[i + 1]) && rect.primaryPos < scrollValue) { - i++; - } - } - return i; - }, - - /* - * render: Figure out the scroll position, the index matching it, and then tell - * the data source to render the correct items into the DOM. - */ - render: function(shouldRedrawAll) { - var i; - var self = this; - var isOutOfBounds = (this.currentIndex >= this.dataSource.getLength()); - // We want to remove all the items and redraw everything if we're out of bounds - // or a flag is passed in. - if (isOutOfBounds || shouldRedrawAll) { - for (i in this.renderedItems) { - this.removeItem(i); - } - // Just don't render anything if we're out of bounds - if (isOutOfBounds) return; - } - - var rect; - // The bottom of the viewport - var scrollValue = this.scrollValue(); - var viewportBottom = scrollValue + this.scrollSize(); - - // Get the new start index for scrolling, based on the current scrollValue and - // the most recent known index - var startIndex = this.getIndexForScrollValue(this.currentIndex, scrollValue); - - // Add two extra rows above the visible area - renderStartIndex = this.addRowsToIndex(startIndex, -this.bufferSize); - - // Keep rendering items, adding them until we are past the end of the visible scroll area - i = renderStartIndex; - while ((rect = this.dimensions[i]) && (rect.primaryPos - rect.primarySize < viewportBottom) && - this.dimensions[i + 1]) { - i++; - } - - var renderEndIndex = this.addRowsToIndex(i, this.bufferSize); - - for (i = renderStartIndex; i <= renderEndIndex; i++) { - rect = this.dimensions[i]; - self.renderItem(i, rect.primaryPos - self.beforeSize, rect.secondaryPos); - } - - // Remove any items that were rendered and aren't visible anymore - for (i in this.renderedItems) { - if (i < renderStartIndex || i > renderEndIndex) this.removeItem(i); - } - - this.setCurrentIndex(startIndex); - }, - renderItem: function(dataIndex, primaryPos, secondaryPos) { - // Attach an item, and set its transform position to the required value - var item = this.dataSource.attachItemAtIndex(dataIndex); - var itemDimensions = this.dimensions[dataIndex] || {}; - //console.log(dataIndex, item); - if (item && item.element) { - if (item.primaryPos !== primaryPos || item.secondaryPos !== secondaryPos) { - item.element.css(ionic.CSS.TRANSFORM, this.transformString( - primaryPos, secondaryPos - )); - item.primaryPos = primaryPos; - item.secondaryPos = secondaryPos; - } - - var width = this.isVertical ? itemDimensions.secondarySize : itemDimensions.primarySize; - var height = this.isVertical ? itemDimensions.primarySize : itemDimensions.secondarySize; - if (item.cssWidth !== width) { - item.element[0].style.width = width + 'px'; - item.cssWidth = width; - } - if (item.cssHeight !== height) { - item.element[0].style.height = height + 'px'; - item.cssHeight = height; - } - // Save the item in rendered items - this.renderedItems[dataIndex] = item; - } else { - // If an item at this index doesn't exist anymore, be sure to delete - // it from rendered items - delete this.renderedItems[dataIndex]; - } - }, - removeItem: function(dataIndex) { - // Detach a given item - var item = this.renderedItems[dataIndex]; - if (item) { - item.primaryPos = item.secondaryPos = null; - this.dataSource.detachItem(item); - delete this.renderedItems[dataIndex]; - } - }, - /* - * Given an index, how many items do we have to change to get `rowDelta` number of rows up or down? - * Eg if we are at index 0 and there are 2 items on the first row and 3 items on the second row, - * to move forward two rows we have to go to index 5. - * In that case, addRowsToIndex(dim, 0, 2) == 5. - */ - addRowsToIndex: function(index, rowDelta) { - var dimensions = this.dimensions; - var direction = rowDelta > 0 ? 1 : -1; - var rect; - var positionOfRow; - rowDelta = Math.abs(rowDelta); - do { - positionOfRow = dimensions[index] && dimensions[index].primaryPos; - while ((rect = dimensions[index]) && rect.primaryPos === positionOfRow && - dimensions[index + direction]) { - index += direction; - } - } while (rowDelta--); - return index; - } - }; - - return CollectionRepeatManager; -}]); - diff --git a/js/views/scrollView.js b/js/views/scrollView.js index 31b29e8fd2..f0cf73f2d8 100644 --- a/js/views/scrollView.js +++ b/js/views/scrollView.js @@ -1179,7 +1179,7 @@ ionic.views.Scroll = ionic.views.View.inherit({ this.__fadeScrollbars('out'); }, - resize: function() { + resize: function(continueScrolling) { var self = this; if (!self.__container || !self.options) return; @@ -1189,7 +1189,8 @@ ionic.views.Scroll = ionic.views.View.inherit({ self.__container.clientWidth, self.__container.clientHeight, self.options.getContentWidth(), - self.options.getContentHeight() + self.options.getContentHeight(), + continueScrolling ); }, /* @@ -1282,7 +1283,7 @@ ionic.views.Scroll = ionic.views.View.inherit({ * @param contentWidth {Integer} Outer width of inner element * @param contentHeight {Integer} Outer height of inner element */ - setDimensions: function(clientWidth, clientHeight, contentWidth, contentHeight) { + setDimensions: function(clientWidth, clientHeight, contentWidth, contentHeight, continueScrolling) { var self = this; if (!clientWidth && !clientHeight && !contentWidth && !contentHeight) { @@ -1312,7 +1313,9 @@ ionic.views.Scroll = ionic.views.View.inherit({ self.__resizeScrollbars(); // Refresh scroll position - self.scrollTo(self.__scrollLeft, self.__scrollTop, true, null, true); + if (!continueScrolling) { + self.scrollTo(self.__scrollLeft, self.__scrollTop, true, null, true); + } }, @@ -2174,15 +2177,11 @@ ionic.views.Scroll = ionic.views.View.inherit({ clearTimeout(self.__sizerTimeout); var sizer = function() { - self.resize(); - - // if ((self.options.scrollingX && !self.__maxScrollLeft) || (self.options.scrollingY && !self.__maxScrollTop)) { - // //self.__sizerTimeout = setTimeout(sizer, 1000); - // } + self.resize(true); }; sizer(); - self.__sizerTimeout = setTimeout(sizer, 1000); + self.__sizerTimeout = setTimeout(sizer, 500); }, /* diff --git a/package.json b/package.json index 3fd9b4c379..94c449f9ad 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "htmlparser2": "3.7.0", "ircb": "^0.3.1", "jasmine-node": "^1.14.3", + "js-string-escape": "^1.0.0", "js-yaml": "^3.0.2", "jshint-summary": "^0.3.0", "karma": "^0.12.23", diff --git a/scss/_util.scss b/scss/_util.scss index 273c6a8258..eb85c0d626 100644 --- a/scss/_util.scss +++ b/scss/_util.scss @@ -270,5 +270,16 @@ [collection-repeat] { /* Position is set by transforms */ left: 0 !important; + top: 0 !important; position: absolute !important; + z-index: 1; +} +.collection-repeat-container { + position: relative; +} +.collection-repeat-after-container { + z-index: 0; + &.horizontal { + display: inline-block; + } } diff --git a/test/html/collection-repeat/basic-grid.html b/test/html/collection-repeat/basic-grid.html new file mode 100644 index 0000000000..c9afbc411d --- /dev/null +++ b/test/html/collection-repeat/basic-grid.html @@ -0,0 +1,45 @@ + + + + + + + Sample UL + + + + + + + + +

Basic Grid: Dynamic Dimensions

+
+ + + +

{{item.text}}

+
+
+
+ + + + diff --git a/test/html/collection-repeat/basic-list.html b/test/html/collection-repeat/basic-list.html new file mode 100644 index 0000000000..c735f186f2 --- /dev/null +++ b/test/html/collection-repeat/basic-list.html @@ -0,0 +1,42 @@ + + + + + + + Sample UL + + + + + + + + +

Basic List: Static Dimensions

+
+ + + +

{{item.text}}

+
+
+
+ + + + diff --git a/test/html/collection-repeat/contacts.html b/test/html/collection-repeat/contacts.html new file mode 100644 index 0000000000..1bf60864d8 --- /dev/null +++ b/test/html/collection-repeat/contacts.html @@ -0,0 +1,3113 @@ + + + + + + Collection-Repeat: Early Preview + + + + + + + + +

3000 Contacts B

+ + + +
+ + + + +

stuff before

+ + HELLO +
interim
+ WHATS UP +
+ + + diff --git a/test/html/collection-repeat/horizontal-image-grid.html b/test/html/collection-repeat/horizontal-image-grid.html new file mode 100644 index 0000000000..ecf82bc02e --- /dev/null +++ b/test/html/collection-repeat/horizontal-image-grid.html @@ -0,0 +1,30 @@ + + + + + + + + Sample UL + + + + + + + + + + + + diff --git a/test/html/list-fit.html b/test/html/list-fit.html deleted file mode 100644 index a34fdf72e2..0000000000 --- a/test/html/list-fit.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - Sample UL - - - - - - - -

Hi

- - Bottom - - - MODAL - -
- - -

- Hi, I'm some text before the list. -

-
- Hi, I'm a card before the list. -
- - - -

{{item.text}}

- DEL -
-
- -
- Hi, I'm a card after the list. -
-
- -