feat(collectionRepeat): other children of ion-content element fit in

Closes #1920. Closes #1866. Closes #1380.
This commit is contained in:
Andrew
2014-08-06 10:31:17 -06:00
parent c0b6426625
commit 7ddb57e60b
12 changed files with 220 additions and 147 deletions

View File

@@ -22,13 +22,10 @@
* Pixel amounts or percentages are allowed (see below).
* 3. The elements rendered will be absolutely positioned: be sure to let your CSS work with
* this (see below).
* 4. Keep the HTML of your repeated elements as simple as possible.
* The more complicated your elements, the more likely it is that the on-demand compilation will cause
* some jerkiness in the user's scrolling.
* 6. Each collection-repeat list will take up all of its parent scrollView's space.
* 4. 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.
* 7. You should not use the ng-show and ng-hide directives on your ion-content/ion-scroll elements that
* 5. 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.
@@ -154,6 +151,10 @@ function($collectionRepeatManager, $collectionDataSource, $parse) {
require: '^$ionicScroll',
controller: [function(){}],
link: function($scope, $element, $attr, scrollCtrl, $transclude) {
var wrap = jqLite('<div style="position:relative;">');
$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);
@@ -216,9 +217,32 @@ function($collectionRepeatManager, $collectionDataSource, $parse) {
rerender(value);
});
var scrollViewContent = scrollCtrl.scrollView.__content;
function rerender(value) {
var beforeSiblings = [];
var afterSiblings = [];
var before = true;
forEach(scrollViewContent.children, function(node, i) {
if ( ionic.DomUtil.elementIsDescendant($element[0], node, scrollViewContent) ) {
before = false;
} else {
var width = node.offsetWidth;
var height = node.offsetHeight;
if (width && height) {
var element = jqLite(node);
(before ? beforeSiblings : afterSiblings).push({
width: node.offsetWidth,
height: node.offsetHeight,
element: element,
scope: element.isolateScope() || element.scope(),
isOutside: true
});
}
}
});
scrollView.resize();
dataSource.setData(value);
dataSource.setData(value, beforeSiblings, afterSiblings);
collectionRepeatManager.resize();
}
function onWindowResize() {
@@ -237,7 +261,7 @@ function($collectionRepeatManager, $collectionDataSource, $parse) {
}]);
// Fix for #1674
// Problem: if an ngSrc or ngHref expression evaluates to a falsy value, it will
// 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
@@ -248,13 +272,13 @@ function collectionRepeatSrcDirective(ngAttrName, attrName) {
return [function() {
return {
priority: '99', // it needs to run after the attributes are interpolated
require: '^?collectionRepeat',
link: function(scope, element, attr, collectionRepeatCtrl) {
if (!collectionRepeatCtrl) return;
attr.$observe(ngAttrName, function(value) {
if (!value) {
element.removeAttr(attrName);
}
element[0][attr] = '';
setTimeout(function() {
element[0][attr] = value;
});
});
}
};

View File

@@ -60,24 +60,19 @@ IonicModule
.directive('ionInfiniteScroll', ['$timeout', function($timeout) {
function calculateMaxValue(distance, maximum, isPercent) {
return isPercent ?
maximum * (1 - parseInt(distance,10) / 100) :
maximum - parseInt(distance, 10);
maximum * (1 - parseFloat(distance,10) / 100) :
maximum - parseFloat(distance, 10);
}
return {
restrict: 'E',
require: ['^$ionicScroll', 'ionInfiniteScroll'],
template:
'<div class="scroll-infinite">' +
'<div class="scroll-infinite-content">' +
'<i class="icon {{icon()}} icon-refreshing"></i>' +
'</div>' +
'</div>',
template: '<i class="icon {{icon()}} icon-refreshing"></i>',
scope: true,
controller: ['$scope', '$attrs', function($scope, $attrs) {
this.isLoading = false;
this.scrollView = null; //given by link function
this.getMaxScroll = function() {
var distance = ($attrs.distance || '1%').trim();
var distance = ($attrs.distance || '2.5%').trim();
var isPercent = distance.indexOf('%') !== -1;
var maxValues = this.scrollView.getScrollMax();
return {
@@ -109,6 +104,7 @@ IonicModule
$element[0].classList.remove('active');
$timeout(function() {
scrollView.resize();
checkBounds();
}, 0, false);
infiniteScrollCtrl.isLoading = false;
};

View File

@@ -4,12 +4,16 @@ IonicModule
'$parse',
'$rootScope',
function($cacheFactory, $parse, $rootScope) {
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.keyExpr = options.keyExpr;
this.listExpr = options.listExpr;
@@ -61,6 +65,8 @@ function($cacheFactory, $parse, $rootScope) {
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 = {};
@@ -87,6 +93,13 @@ function($cacheFactory, $parse, $rootScope) {
},
attachItemAtIndex: function(index) {
var value = this.data[index];
if (index < this.dataStartIndex) {
return this.beforeSiblings[index];
} else if (index > this.data.length) {
return this.afterSiblings[index - this.data.length - this.dataStartIndex];
}
var hash = this.itemHashGetter(index, value);
var item = this.getItem(hash);
@@ -118,23 +131,36 @@ function($cacheFactory, $parse, $rootScope) {
detachItem: function(item) {
delete this.attachedItems[item.hash];
//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
if (this.backupItemsArray.length >= this.BACKUP_ITEMS_LENGTH) {
} else if (this.backupItemsArray.length >= this.BACKUP_ITEMS_LENGTH) {
this.destroyItem(item);
// Otherwise, add it to our backup items
} else {
this.backupItemsArray.push(item);
item.element.css(ionic.CSS.TRANSFORM, 'translate3d(-2000px,-2000px,0)');
hideWithTransform(item.element);
//Don't .$destroy(), just stop watchers and events firing
disconnectScope(item.scope);
}
},
getLength: function() {
return this.data && this.data.length || 0;
return this.dimensions && this.dimensions.length || 0;
},
setData: function(value) {
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);
});
},
};

View File

@@ -100,7 +100,24 @@ function($rootScope, $timeout) {
var secondaryScrollSize = this.secondaryScrollSize();
var previousItem;
return this.dataSource.dimensions.map(function(dim) {
this.dataSource.beforeSiblings && this.dataSource.beforeSiblings.forEach(calculateSize, this);
var beforeSize = primaryPos + (previousItem ? previousItem.primarySize : 0);
primaryPos = secondaryPos = 0;
previousItem = null;
var dimensions = this.dataSource.dimensions.map(calculateSize, this);
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 = {
@@ -129,12 +146,13 @@ function($rootScope, $timeout) {
previousItem = rect;
return rect;
}, this);
}
},
resize: function() {
this.dimensions = this.calculateDimensions();
var lastItem = this.dimensions[this.dimensions.length - 1];
this.viewportSize = lastItem ? lastItem.primaryPos + lastItem.primarySize : 0;
var result = this.calculateDimensions();
this.dimensions = result.dimensions;
this.viewportSize = result.totalSize;
this.beforeSize = result.beforeSize;
this.setCurrentIndex(0);
this.render(true);
if (!this.dataSource.backupItemsArray.length) {
@@ -219,6 +237,7 @@ function($rootScope, $timeout) {
* the data source to render the correct items into the DOM.
*/
render: function(shouldRedrawAll) {
var self = this;
var i;
var isOutOfBounds = ( this.currentIndex >= this.dataSource.getLength() );
// We want to remove all the items and redraw everything if we're out of bounds
@@ -258,10 +277,12 @@ function($rootScope, $timeout) {
// 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 < scrollSizeEnd)) {
this.renderItem(i, rect.primaryPos, rect.secondaryPos);
i++;
doRender(i++);
}
var renderEndIndex = i - 1;
//Add two more items at the end
doRender(i++);
doRender(i);
var renderEndIndex = i;
// Remove any items that were rendered and aren't visible anymore
for (i in this.renderedItems) {
@@ -271,6 +292,17 @@ function($rootScope, $timeout) {
}
this.setCurrentIndex(startIndex);
function doRender(dataIndex) {
var rect = self.dimensions[dataIndex];
if (!rect) {
}else if (dataIndex < self.dataSource.dataStartIndex) {
// do nothing
} else {
self.renderItem(dataIndex, rect.primaryPos - self.beforeSize, rect.secondaryPos);
}
}
},
renderItem: function(dataIndex, primaryPos, secondaryPos) {
// Attach an item, and set its transform position to the required value
@@ -302,6 +334,15 @@ function($rootScope, $timeout) {
}
};
var exceptions = {'renderScroll':1, 'renderIfNeeded':1};
forEach(CollectionRepeatManager.prototype, function(method, key) {
if (exceptions[key]) return;
CollectionRepeatManager.prototype[key] = function() {
console.log(key + '(', arguments, ')');
return method.apply(this, arguments);
};
});
return CollectionRepeatManager;
}]);

View File

@@ -210,6 +210,15 @@
});
},
elementIsDescendant: function(el, parent, stopAt) {
var current = el;
do {
if (current === parent) return true;
current = current.parentNode;
} while (current && current !== stopAt);
return false;
},
/**
* @ngdoc method
* @name ionic.DomUtil#getParentWithClass

View File

@@ -245,36 +245,27 @@ body.grade-c {
}
}
.scroll-refresher-content {
position: absolute;
bottom: 15px;
left: 0;
width: 100%;
color: $scroll-refresh-icon-color;
text-align: center;
font-size: 30px;
}
// Infinite scroll
ion-infinite-scroll .scroll-infinite {
position: relative;
overflow: hidden;
margin-top: -70px;
ion-infinite-scroll {
height: 60px;
}
.scroll-infinite-content {
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
color: #666666;
text-align: center;
font-size: 30px; }
opacity: 0;
display: block;
ion-infinite-scroll.active .scroll-infinite {
margin-top: -30px;
@include transition(opacity 0.25s);
@include display-flex();
@include flex-direction(row);
@include justify-content(center);
@include align-items(center);
.icon {
color: #666666;
font-size: 30px;
color: $scroll-refresh-icon-color;
}
&.active {
opacity: 1;
}
}
.overflow-scroll {

View File

@@ -26,12 +26,11 @@
<script>
angular.module('ionicApp', ['ionic'])
.controller('MyCtrl', ['$scope', function($scope) {
.controller('MyCtrl', ['$scope', '$timeout', function($scope, $timeout) {
$scope.data = { items: [] };
var fetchItems = function() {
console.log('Pushing item');
$scope.data.items.push({
title: 'Item ' + $scope.data.items.length
});
@@ -40,7 +39,7 @@
};
$scope.onInfinite = function() {
fetchItems();
$timeout(fetchItems, 2000);
};
for(var i = 0; i < 10; i++) {

View File

@@ -18,65 +18,57 @@
</a>
</ion-header-bar>
<ion-content>
<p>
Hi, I'm some text before the list.
</p>
<div class="card full">
Hi, I'm a card before the list.
</div>
<ion-list>
<ion-item
class="item-avatar-left item-icon-right"
ng-click="alert(item)"
collection-repeat="item in items"
collection-item-height="$index % 10 === 0 ? 500 : 85"
collection-item-height="85"
collection-item-width="'100%'"
style="position: absolute; left: 0; right: 0;">
<img ng-src="{{item.image}}">
ng-style="{height: '85px'}"
style="left: 0; right: 0;">
<h2>{{item.text}}</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis porttitor diam urna, vitae consectetur lectus aliquet quis.</p>
<i class="icon" style="color:red; font-size: 30px;" ng-class="['ion-ios7-person','ion-person','ion-android-contact','ion-android-social-user','ion-person-stalker'][$index % 5]"></i>
</ion-item>
</ion-list>
<ion-infinite-scroll on-infinite="loadMore()"></ion-infinite-scroll>
<div class="card full">
Hi, I'm a card after the list.
</div>
</ion-content>
<script>
var dataUris = {};
function convertImgToBase64(url, callback, outputFormat){
var canvas = document.createElement('CANVAS'),
ctx = canvas.getContext('2d'),
img = new Image;
img.crossOrigin = 'Anonymous';
img.onload = function(){
var dataURL;
canvas.height = img.height;
canvas.width = img.width;
ctx.drawImage(img,0,0);
dataURL = canvas.toDataURL(outputFormat || 'image/png');
callback.call(this, dataURL);
canvas = null;
};
img.src = url;
}
function MainCtrl($scope, $ionicScrollDelegate, $timeout, $q, $ionicLoading) {
var images = [];
$ionicLoading.show({
template: 'Loading images...'
});
var deferred;
for (var i = 0; i < 5; i++) {
deferred = $q.defer();
convertImgToBase64('http://placekitten.com/'+(40+(10*i))+'/'+(40+(10*i)), deferred.resolve);
images.push(deferred.promise);
$scope.items = [];
function addImage() {
var i = $scope.items.length;
$scope.items.push({
text: 'Item ' + i,
image: 'http://placekitten.com/'+(100+50%i)+'/'+(100+50%i)
});
}
$q.all(images).then(function(dataUrls) {
$scope.items = [];
for (var item = 0; item < 5000; item++) {
$scope.items.push({
text: 'Item ' + item,
image: dataUrls[item % 5]
});
}
$timeout($ionicLoading.hide, 200);
});
for (var i = 0; i < 20; i++) addImage();
$scope.scrollBottom = $ionicScrollDelegate.scrollBottom;
$scope.loadMore = function() {
$timeout(function() {
var n = 1 + Math.floor(4*Math.random());
for (var i = 0; i < n; i++) addImage();
$scope.$broadcast('scroll.infiniteScrollComplete');
}, 1500);
};
}
</script>
<style>
.full {
left: 0;
right: 0;
}
</body>
</html>

View File

@@ -106,16 +106,16 @@ describe('collectionRepeat directive', function() {
it('should error if list is not an array and is truthy', function() {
var el = setup('collection-repeat="item in items" collection-item-height="50"');
expect(function() {
expect(function() {
el.scope().$apply('items = "string"');
}).toThrow();
expect(function() {
expect(function() {
el.scope().$apply('items = 123');
}).toThrow();
expect(function() {
expect(function() {
el.scope().$apply('items = {}');
}).toThrow();
expect(function() {
expect(function() {
el.scope().$apply('items = []');
}).not.toThrow();
});
@@ -128,11 +128,11 @@ describe('collectionRepeat directive', function() {
repeatManager.resize.reset();
el.scope().$apply('items = [ 1,2,3 ]');
expect(dataSource.setData).toHaveBeenCalledWith(el.scope().items);
expect(dataSource.setData).toHaveBeenCalledWith(el.scope().items, [], []);
expect(repeatManager.resize.callCount).toBe(1);
expect(scrollView.resize.callCount).toBe(1);
el.scope().$apply('items = null');
expect(dataSource.setData).toHaveBeenCalledWith(null);
expect(dataSource.setData).toHaveBeenCalledWith(null, [], []);
expect(repeatManager.resize.callCount).toBe(2);
expect(scrollView.resize.callCount).toBe(2);
});
@@ -147,7 +147,7 @@ describe('collectionRepeat directive', function() {
el.scope().items = [1,2,3];
ionic.trigger('resize', { target: window });
expect(dataSource.setData).toHaveBeenCalledWith(el.scope().items);
expect(dataSource.setData).toHaveBeenCalledWith(el.scope().items, [], []);
expect(repeatManager.resize.callCount).toBe(1);
expect(scrollView.resize.callCount).toBe(1);
});

View File

@@ -98,11 +98,11 @@ describe('ionicInfiniteScroll directive', function() {
].forEach(function(opts) {
describe('with scrollingX='+opts.scrollingX+', scrollingY='+opts.scrollingY, function() {
it('should default to 1%', function() {
it('should default to 2.5%', function() {
var el = setup('', {}, opts);
expect(ctrl.getMaxScroll()).toEqual({
left: opts.scrollingX ? scrollLeftMaxValue * 0.99 : -1,
top: opts.scrollingY ? scrollTopMaxValue * 0.99 : -1
left: opts.scrollingX ? scrollLeftMaxValue * 0.975 : -1,
top: opts.scrollingY ? scrollTopMaxValue * 0.975 : -1
});
});

View File

@@ -21,6 +21,9 @@ describe('$collectionDataSource service', function() {
cb( $compile(template || '<div>')(scope) );
};
dataSource = new $collectionDataSource(options);
dataSource.dataStartIndex = 0;
dataSource.beforeSiblings = [];
dataSource.afterSiblings = [];
});
return dataSource;
}
@@ -148,6 +151,7 @@ describe('$collectionDataSource service', function() {
keyExpr: 'value'
});
source.data = ['a', 'b', 'c'];
source.dimensions = ['a','b','c'];
spyOn(source, 'getItem').andCallFake(function() {
return { scope: $rootScope.$new() };
});
@@ -242,15 +246,15 @@ describe('$collectionDataSource service', function() {
describe('.getLength()', function() {
it('should return 0 by default', function() {
var source = setup();
source.data = null;
source.dimensions = null;
expect(source.getLength()).toBe(0);
});
it('should return data length', function() {
it('should return dimensions length', function() {
var source = setup();
source.data = [1,2,3];
source.dimensions = [1,2,3];
expect(source.getLength()).toBe(3);
source.data = [];
source.dimensions = [];
expect(source.getLength()).toBe(0);
});
});

View File

@@ -180,7 +180,7 @@ describe('collectionRepeatManager service', function() {
manager.secondaryScrollSize = function() {
return 100;
};
var result = manager.calculateDimensions();
var result = manager.calculateDimensions().dimensions;
expect(result[0].primarySize).toBe(20);
expect(result[0].secondarySize).toBe(100);
expect(result[0].primaryPos).toBe(0);
@@ -214,7 +214,7 @@ describe('collectionRepeatManager service', function() {
manager.secondaryScrollSize = function() {
return 90;
};
var result = manager.calculateDimensions();
var result = manager.calculateDimensions().dimensions;
expect(result[0].primarySize).toBe(30);
expect(result[0].secondarySize).toBe(30);
expect(result[0].primaryPos).toBe(0);
@@ -251,7 +251,11 @@ describe('collectionRepeatManager service', function() {
it('should work without data', function() {
var manager = setup();
spyOn(manager, 'render');
spyOn(manager, 'calculateDimensions').andReturn([]);
spyOn(manager, 'calculateDimensions').andReturn({
dimensions: [],
beforeSize: 0,
totalSize: 0
});
spyOn(manager, 'setCurrentIndex');
manager.resize();
expect(manager.dimensions).toEqual([]);
@@ -261,9 +265,13 @@ describe('collectionRepeatManager service', function() {
});
it('should work with data', function() {
var manager = setup();
spyOn(manager, 'calculateDimensions').andReturn([{
primaryPos: 100, primarySize: 30
}]);
spyOn(manager, 'calculateDimensions').andReturn({
dimensions: [{
primaryPos: 100, primarySize: 30
}],
beforeSize: 0,
totalSize: 130
});
manager.resize();
expect(manager.viewportSize).toBe(130);
});
@@ -430,12 +438,12 @@ describe('collectionRepeatManager service', function() {
});
manager.resize(); //triggers render
//it should render (items that fit * items per row) with one extra row at end
expect(Object.keys(manager.renderedItems).length).toBe(18);
for (var i = 0; i < 18; i++) {
//it should render (items that fit * items per row) with three extra row at end
expect(Object.keys(manager.renderedItems).length).toBe(20);
for (var i = 0; i < 20; i++) {
expect(manager.renderedItems[i]).toBe(true);
}
expect(manager.renderedItems[18]).toBeUndefined();
expect(manager.renderedItems[20]).toBeUndefined();
});
it('should render items in the middle of the screen', function() {
@@ -449,32 +457,15 @@ describe('collectionRepeatManager service', function() {
manager.resize();
var startIndex = 17;
var bufferStartIndex = 14; //one row of buffer before the start
var bufferEndIndex = 35; //start + 17 + 6
var bufferEndIndex = 37; //start + 17 + 6
expect(Object.keys(manager.renderedItems).length).toBe(22);
expect(Object.keys(manager.renderedItems).length).toBe(24);
for (var i = bufferStartIndex; i <= bufferEndIndex; i++) {
expect(manager.renderedItems[i]).toBe(true);
}
expect(manager.renderedItems[bufferStartIndex - 1]).toBeUndefined();
expect(manager.renderedItems[bufferEndIndex + 1]).toBeUndefined();
});
it('should remove items outside the range', function() {
var manager = mockRendering({
itemWidth: 3,
itemHeight: 20,
scrollWidth: 10,
scrollHeight: 100
});
manager.resize();
manager.removeItem.reset();
manager.renderedItems = { 17: true, 18: true, 19: true };
//resize() re-renders everything, need to just do a normal rerender
manager.render();
expect(manager.removeItem.callCount).toBe(2);
expect(manager.removeItem).toHaveBeenCalledWith('18');
expect(manager.removeItem).toHaveBeenCalledWith('19');
});
});
describe('.renderItem()', function() {