-
Notifications
You must be signed in to change notification settings - Fork 27.5k
fix(ngRepeat): trigger move animation #15072
base: master
Are you sure you want to change the base?
Changes from 2 commits
2331c42
263be6b
f3c6062
47bd3a4
7a98172
d8320a1
dcc80c1
c969ae2
410312c
4d6feb9
3b17d5d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -325,7 +325,6 @@ | |
</example> | ||
*/ | ||
var ngRepeatDirective = ['$parse', '$animate', '$compile', function($parse, $animate, $compile) { | ||
var NG_REMOVED = '$$NG_REMOVED'; | ||
var ngRepeatMinErr = minErr('ngRepeat'); | ||
|
||
var updateScope = function(scope, index, valueIdentifier, value, keyIdentifier, key, arrayLength) { | ||
|
@@ -340,9 +339,9 @@ var ngRepeatDirective = ['$parse', '$animate', '$compile', function($parse, $ani | |
scope.$odd = !(scope.$even = (index & 1) === 0); | ||
}; | ||
|
||
var getBlockStart = function(block) { | ||
return block.clone[0]; | ||
}; | ||
// var getBlockStart = function(block) { | ||
// return block.clone[0]; | ||
// }; | ||
|
||
var getBlockEnd = function(block) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. getBlockStart function is no longer used. |
||
return block.clone[block.clone.length - 1]; | ||
|
@@ -425,126 +424,129 @@ var ngRepeatDirective = ['$parse', '$animate', '$compile', function($parse, $ani | |
|
||
//watch props | ||
$scope.$watchCollection(rhs, function ngRepeatAction(collection) { | ||
var index, length, | ||
previousNode = $element[0], // node that cloned nodes should be inserted after | ||
// initialized to the comment node anchor | ||
nextNode, | ||
// Same as lastBlockMap but it has the current state. It will become the | ||
// lastBlockMap on the next iteration. | ||
nextBlockMap = createMap(), | ||
collectionLength, | ||
key, value, // key/value of iteration | ||
trackById, | ||
trackByIdFn, | ||
collectionKeys, | ||
block, // last object information {scope, element, id} | ||
nextBlockOrder, | ||
elementsToRemove; | ||
var | ||
block, // last object information {scope, element, id} | ||
collectionKey, | ||
collectionKeys = [], | ||
elementsToRemove, | ||
index, key, value, // key/value of iteration | ||
lastBlockOrder = [], | ||
lastKey, | ||
nextBlockMap = createMap(), | ||
nextBlockOrder = [], | ||
nextKey, nextLength, | ||
previousNode = $element[0], // node that cloned nodes should be inserted after | ||
// initialized to the comment node anchor | ||
trackById, | ||
trackByIdFn; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. variables are defined in alphabetical order (mostly) |
||
|
||
if (aliasAs) { | ||
$scope[aliasAs] = collection; | ||
} | ||
|
||
// get collectionKeys | ||
if (isArrayLike(collection)) { | ||
collectionKeys = collection; | ||
trackByIdFn = trackByIdExpFn || trackByIdArrayFn; | ||
} else { | ||
trackByIdFn = trackByIdExpFn || trackByIdObjFn; | ||
// if object, extract keys, in enumeration order, unsorted | ||
collectionKeys = []; | ||
for (var itemKey in collection) { | ||
if (hasOwnProperty.call(collection, itemKey) && itemKey.charAt(0) !== '$') { | ||
collectionKeys.push(itemKey); | ||
for (collectionKey in collection) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why this change? Key of the current item makes more sense to me than key of collection. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are probably right. I went back and forth on several names and don't remember why I settled on collectionKey. Possibly for consistency in how I named other keys. Let me know if you want me to change it. |
||
if (hasOwnProperty.call(collection, collectionKey) && collectionKey.charAt(0) !== '$') { | ||
collectionKeys.push(collectionKey); | ||
} | ||
} | ||
} | ||
nextLength = collectionKeys.length; | ||
|
||
collectionLength = collectionKeys.length; | ||
nextBlockOrder = new Array(collectionLength); | ||
|
||
// locate existing items | ||
for (index = 0; index < collectionLength; index++) { | ||
// setup nextBlockMap | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The "setup nextBlockMap" loop has a similar purpose to the "locate existing items" loop in the previous version. It does not delete previously seen blocks from lastBlockMap. Therefore, lastBlockMap does not need to be restored if a duplicate block is detected. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Indentation of the comment here is wrong There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Narretz Is it? Looks good to me. |
||
for (index = 0; index < nextLength; index++) { | ||
key = (collection === collectionKeys) ? index : collectionKeys[index]; | ||
value = collection[key]; | ||
trackById = trackByIdFn(key, value, index); | ||
if (lastBlockMap[trackById]) { | ||
// found previously seen block | ||
block = lastBlockMap[trackById]; | ||
delete lastBlockMap[trackById]; | ||
nextBlockMap[trackById] = block; | ||
nextBlockOrder[index] = block; | ||
} else if (nextBlockMap[trackById]) { | ||
// if collision detected. restore lastBlockMap and throw an error | ||
forEach(nextBlockOrder, function(block) { | ||
if (block && block.scope) lastBlockMap[block.id] = block; | ||
}); | ||
|
||
if (nextBlockMap[trackById]) { | ||
// if collision detected, throw an error | ||
throw ngRepeatMinErr('dupes', | ||
'Duplicates in a repeater are not allowed. Use \'track by\' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}, Duplicate value: {2}', | ||
expression, trackById, value); | ||
} else { | ||
// new never before seen block | ||
nextBlockOrder[index] = {id: trackById, scope: undefined, clone: undefined}; | ||
nextBlockMap[trackById] = true; | ||
'Duplicates in a repeater are not allowed. Use \'track by\' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}, Duplicate value: {2}', | ||
expression, trackById, value); | ||
} | ||
} | ||
|
||
// remove leftover items | ||
for (var blockKey in lastBlockMap) { | ||
block = lastBlockMap[blockKey]; | ||
elementsToRemove = getBlockNodes(block.clone); | ||
$animate.leave(elementsToRemove); | ||
if (elementsToRemove[0].parentNode) { | ||
// if the element was not removed yet because of pending animation, mark it as deleted | ||
// so that we can ignore it later | ||
for (index = 0, length = elementsToRemove.length; index < length; index++) { | ||
elementsToRemove[index][NG_REMOVED] = true; | ||
} | ||
} | ||
block.scope.$destroy(); | ||
nextBlockMap[trackById] = {id: trackById, clone: undefined, scope: undefined, index: index, key: key, value: value}; | ||
nextBlockOrder[index] = trackById; | ||
} | ||
|
||
// we are not using forEach for perf reasons (trying to avoid #call) | ||
for (index = 0; index < collectionLength; index++) { | ||
key = (collection === collectionKeys) ? index : collectionKeys[index]; | ||
value = collection[key]; | ||
block = nextBlockOrder[index]; | ||
// setup lastBlockOrder, used to determine if block moved | ||
for (lastKey in lastBlockMap) { | ||
lastBlockOrder.push(lastKey); | ||
} | ||
|
||
if (block.scope) { | ||
// if we have already seen this object, then we need to reuse the | ||
// associated scope/element | ||
for (index = 0; index < nextLength; index++) { | ||
nextKey = nextBlockOrder[index]; | ||
|
||
nextNode = previousNode; | ||
if (lastBlockMap[nextKey]) { | ||
// we have already seen this object and need to reuse the associated scope/element | ||
block = lastBlockMap[nextKey]; | ||
|
||
// skip nodes that are already pending removal via leave animation | ||
do { | ||
nextNode = nextNode.nextSibling; | ||
} while (nextNode && nextNode[NG_REMOVED]); | ||
// move | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This loop removes elements and now occurs after the "enter and move" loop. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic of when to move and where to move is a key change in this pull request. |
||
if (lastBlockMap[nextKey].index !== nextBlockMap[nextKey].index) { | ||
// If this block has moved because the last previous block was removed, | ||
// then use the last previous block to set previousNode. | ||
lastKey = lastBlockOrder[lastBlockMap[nextKey].index - 1]; | ||
if (lastKey && !nextBlockMap[lastKey]) { | ||
previousNode = getBlockEnd(lastBlockMap[lastKey]); | ||
} | ||
|
||
if (getBlockStart(block) !== nextNode) { | ||
// existing item which got moved | ||
$animate.move(getBlockNodes(block.clone), null, previousNode); | ||
block.index = nextBlockMap[nextKey].index; | ||
} | ||
|
||
updateScope(block.scope, index, | ||
valueIdentifier, nextBlockMap[nextKey].value, | ||
keyIdentifier, nextBlockMap[nextKey].key, nextLength); | ||
|
||
nextBlockMap[nextKey] = block; | ||
previousNode = getBlockEnd(block); | ||
updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength); | ||
|
||
} else { | ||
// enter | ||
// new item which we don't know about | ||
$transclude(function ngRepeatTransclude(clone, scope) { | ||
block.scope = scope; | ||
nextBlockMap[nextKey].scope = scope; | ||
// http://jsperf.com/clone-vs-createcomment | ||
var endNode = ngRepeatEndComment.cloneNode(false); | ||
clone[clone.length++] = endNode; | ||
|
||
$animate.enter(clone, null, previousNode); | ||
previousNode = endNode; | ||
|
||
// Note: We only need the first/last node of the cloned nodes. | ||
// However, we need to keep the reference to the jqlite wrapper as it might be changed later | ||
// by a directive with templateUrl when its template arrives. | ||
block.clone = clone; | ||
nextBlockMap[block.id] = block; | ||
updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength); | ||
nextBlockMap[nextKey].clone = clone; | ||
updateScope(scope, nextBlockMap[nextKey].index, | ||
valueIdentifier, nextBlockMap[nextKey].value, | ||
keyIdentifier, nextBlockMap[nextKey].key, nextLength); | ||
|
||
delete nextBlockMap[nextKey].key; | ||
delete nextBlockMap[nextKey].value; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is new, right? Is this necessary? The tests do not fail without this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The previous version did not store key and value in nextBlockMap. I am deleting them to keep them from needlessly using memory when nextBlockMap is assigned to lastBlockMap. |
||
}); | ||
} | ||
} | ||
|
||
// leave | ||
// This must go after enter and move because leave prevents getting element's parent. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you explain this further? The leave animation section seems to have changed quite a bit from the original. Is this necessary for this PR? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The main change is there no need to keep track of deleted elements because we aren't doing any more processing where we might need to ignore the element. The other change is to skip blocks that don't need removed. |
||
for (lastKey in lastBlockMap) { | ||
if (nextBlockMap[lastKey]) { | ||
continue; | ||
} | ||
|
||
block = lastBlockMap[lastKey]; | ||
elementsToRemove = getBlockNodes(block.clone); | ||
$animate.leave(elementsToRemove); | ||
block.scope.$destroy(); | ||
} | ||
|
||
lastBlockMap = nextBlockMap; | ||
}); | ||
}; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1487,7 +1487,13 @@ describe('ngRepeat animations', function() { | |
$rootScope.items = ['1','3']; | ||
$rootScope.$digest(); | ||
|
||
item = $animate.queue.shift(); | ||
while ($animate.queue.length) { | ||
item = $animate.queue.shift(); | ||
if (item.event === 'leave') { | ||
break; | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A comment to explain why this is needed would be helpful. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am going to revert this code. The ngRepeat tests assume the animation queue is in a certain order. During refactoring of ngRepeat, the order was different in this test so I altered the test. My latest version of ngRepeat, c969ae2, passes the original version of this test. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should tests assume that animation events are queued in a certain order? If not, then perhaps the queue should be tested like this: // Test each queue item's event type
while ($animate.queue.length) {
item = $animate.queue.shift();
switch (item.element.text()) {
case '2':
expect(item.event).toBe('leave');
break;
case '3':
expect(item.event).toBe('move');
break;
default:
expect(item).toBeUndefined();
}
} Maybe we should add a Jasmine matcher in the "ngRepeat Animations" block that compares item.event and item.text() values between two arrays? Should this be new GitHub issue? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apparently, the animation queue order IS different than it used to be.
As far as I can tell, tests should not assume the order of events in the queue. So I propose the following matcher be added to the 'ngRepeat animations' group of tests: beforeEach(function() {
jasmine.addMatchers({
toContainQueueItem: function() {
return {
compare: generateCompare(false),
negativeCompare: generateCompare(true)
};
function generateCompare(isNot) {
/**
* Matcher that checks that the expected element text is in the actual Array of
* animation queue items and that the event matches.
* @param {array} actual
* @param {string} expectedElementText
* @param {string} expectedEvent optional if isNot = true
* @returns {{pass: boolean, message: message}}
*/
return function(actual, expectedElementText, expectedEvent) {
if (expectedEvent === undefined) {
expectedEvent = '';
}
var actualLength = actual.length;
var i;
var item;
var message = valueFn(
'Expected animation queue to ' + (isNot ? 'not ' : '') + 'contain an item where the element\'s text is "'
+ expectedElementText + '"' + (isNot ? '' : ' and the event is "' + expectedEvent + '"')
);
var pass = isNot;
for (i = 0; i < actualLength; i++) {
item = actual[i];
if (item.element.text() === expectedElementText) {
pass = item.event === expectedEvent;
break;
}
}
return {'pass': pass, 'message': message};
};
}
}
});
}); The tests should use the matcher like this: expect($animate.queue).not.toContainQueueItem('1');
expect($animate.queue).toContainQueueItem('2', 'leave');
expect($animate.queue).toContainQueueItem('3', 'move');
$animate.queue = []; What does everyone think? |
||
|
||
expect(item.event).toBe('leave'); | ||
expect(item.element.text()).toBe('2'); | ||
})); | ||
|
@@ -1565,6 +1571,56 @@ describe('ngRepeat animations', function() { | |
item = $animate.queue.shift(); | ||
expect(item.event).toBe('move'); | ||
expect(item.element.text()).toBe('3'); | ||
|
||
item = $animate.queue.shift(); | ||
expect(item.event).toBe('move'); | ||
expect(item.element.text()).toBe('1'); | ||
}) | ||
); | ||
|
||
it('should fire off the move animation for filtered items', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This description could be more clear. Perhaps:
Or something like that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about this description? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 |
||
inject(function($compile, $rootScope, $animate) { | ||
|
||
var item; | ||
|
||
element = $compile(html( | ||
'<div>' + | ||
'<div ng-repeat="item in items | filter:search">' + | ||
'{{ item }}' + | ||
'</div>' + | ||
'</div>' | ||
))($rootScope); | ||
|
||
$rootScope.items = ['1','2','3']; | ||
$rootScope.search = ''; | ||
$rootScope.$digest(); | ||
|
||
item = $animate.queue.shift(); | ||
expect(item.event).toBe('enter'); | ||
expect(item.element.text()).toBe('1'); | ||
|
||
item = $animate.queue.shift(); | ||
expect(item.event).toBe('enter'); | ||
expect(item.element.text()).toBe('2'); | ||
|
||
item = $animate.queue.shift(); | ||
expect(item.event).toBe('enter'); | ||
expect(item.element.text()).toBe('3'); | ||
|
||
$rootScope.search = '3'; | ||
$rootScope.$digest(); | ||
|
||
item = $animate.queue.shift(); | ||
expect(item.event).toBe('move'); | ||
expect(item.element.text()).toBe('3'); | ||
|
||
item = $animate.queue.shift(); | ||
expect(item.event).toBe('leave'); | ||
expect(item.element.text()).toBe('1'); | ||
|
||
item = $animate.queue.shift(); | ||
expect(item.event).toBe('leave'); | ||
expect(item.element.text()).toBe('2'); | ||
}) | ||
); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please put a newline between the two |
||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please remove this commented-out code?