Reputation: 246
Vue users are easy to implement such item shuffle animations, see their official docs:
I search a lot but can't find a solution for Angular users. ngFor
seems to switch item contents instead of moving items when shuffling them.
Here's my demo: http://embed.plnkr.co/3IcKcC/
When you click shift, you should see items move animation thanks to li {transform: all 1s;}
. But when you shuffle them, there's no animation. So I'm here asking for a solution.
Upvotes: 18
Views: 10472
Reputation: 258
Here is my version of @yurzui code. Changes:
import { AfterViewInit, Component, ContentChildren, Directive, ElementRef, Input, QueryList } from '@angular/core';
@Directive({
selector: '[transition-group-item]'
})
export class TransitionGroupItemDirective {
prevPos: any;
newPos: any;
el: HTMLElement;
moved: boolean;
moveCallback: any;
constructor(elRef: ElementRef) {
this.el = elRef.nativeElement;
}
}
@Component({
selector: '[transition-group]',
template: '<ng-content></ng-content>'
})
export class TransitionGroupComponent implements AfterViewInit {
@Input('transition-group') class;
@ContentChildren(TransitionGroupItemDirective) items: QueryList<TransitionGroupItemDirective>;
prevTop = 0;
prevLeft = 0;
ngAfterViewInit() {
this.items.changes.subscribe((items: QueryList<TransitionGroupItemDirective>) => {
items.forEach(item => item.prevPos = item.newPos || this.calculatePosition(item));
items.forEach(this.runCallback);
this.refreshPosition();
/// Check if parent top changed
const topPos = items.reduce((acc, item) => { return Math.min(acc, item.newPos.top) }, Infinity);
if (topPos != this.prevTop) {
items.forEach(item => item.prevPos.top += topPos - this.prevTop);
}
this.prevTop = topPos;
/// Check if parent left changed
const leftPos = items.reduce((acc, item) => { return Math.min(acc, item.newPos.left) }, Infinity);
if (leftPos != this.prevLeft) {
items.forEach(item => item.prevPos.left += leftPos - this.prevLeft);
}
this.prevLeft = leftPos;
const animate = () => {
items.forEach(this.applyTranslation);
this['_forceReflow'] = document.body.offsetHeight; // force reflow to put everything in position
this.items.forEach(this.runTransition.bind(this));
}
const willMoveSome = items.some((item) => {
const dx = item.prevPos.left - item.newPos.left;
const dy = item.prevPos.top - item.newPos.top;
return dx != 0 || dy != 0;
});
if (willMoveSome) {
animate();
} else {
setTimeout(() => { // for removed items
this.refreshPosition();
animate();
}, 0);
}
})
}
runCallback(item: TransitionGroupItemDirective) {
if (item.moveCallback) {
item.moveCallback();
}
}
runTransition(item: TransitionGroupItemDirective) {
if (!item.moved) {
return;
}
const cssClass = this.class + '-move';
let el = item.el;
let style: any = el.style;
el.classList.add(cssClass);
style.transform = style.WebkitTransform = style.transitionDuration = '';
el.addEventListener('transitionend', item.moveCallback = (e: any) => {
if (!e || /transform$/.test(e.propertyName)) {
el.removeEventListener('transitionend', item.moveCallback);
item.moveCallback = null;
el.classList.remove(cssClass);
}
});
}
refreshPosition(updatePrevTopLeft=false) {
this.items.forEach(item => {
item.newPos = this.calculatePosition(item);
});
if (updatePrevTopLeft) {
this.prevTop = this.items.reduce((acc, item) => { return Math.min(acc, item.newPos.top) }, Infinity);
this.prevLeft = this.items.reduce((acc, item) => { return Math.min(acc, item.newPos.left) }, Infinity);
}
}
updateCurrentPosition() {
this.refreshPosition(true);
}
calculatePosition(item: TransitionGroupItemDirective) {
const rect = item.el.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
};
}
applyTranslation(item: TransitionGroupItemDirective) {
item.moved = false;
const dx = item.prevPos.left - item.newPos.left;
const dy = item.prevPos.top - item.newPos.top;
if (dx || dy) {
item.moved = true;
let style: any = item.el.style;
style.transform = style.WebkitTransform = 'translate(' + dx + 'px,' + dy + 'px)';
style.transitionDuration = '0s';
}
}
}
Remember to add to css (thx @toby-harding)
.flip-list-move {
transition: transform 300ms;
}
Upvotes: 12
Reputation: 16837
You can get pretty close to the desired effect by using CSS transforms
combined with Angular trackBy
. The trick is to use the trackBy
function to persist the HTML element between list position changes.
@Component()
class AppComponent {
arr = [
{ id: 1 },
{ id: 2 },
{ id: 3 },
{ id: 4 },
{ id: 5 },
{ id: 6 },
{ id: 7 }
];
shuffle() {
this.arr = shuffle(this.arr);
}
transform(index: number) {
return `translateY(${(index + 1) * 100}%)`;
}
trackBy(index, x) {
return x.id;
}
}
<button (click)="shuffle()">Shuffle</button>
<div class="container">
<div
class="list-item"
*ngFor="let obj of arr; index as index; trackBy: trackBy"
[style.transform]="transform(index)"
>
id: {{ obj.id }}
</div>
</div>
.list-item {
position: absolute;
width: 100%;
transition: all 1s;
background-color: coral;
border: 1px solid white;
padding: 8px;
color: white;
}
Upvotes: 2
Reputation: 103
More correct (and TSLint-compliant) would be to use a different Directive name, as:
@Directive({
selector: '[appTransitionGroupItem]'
})
and using a component as an element and not overloading the input name:
@Component({
selector: 'app-transition-group',
template: '<ng-content></ng-content>'
})
export class TransitionGroupComponent implements AfterViewInit {
@Input() className;
Which gives the code better Angular structure, my compliant, better read (YMMV) code, being:
<app-transition-group [className]="'flip-list'">
<div class="list-items" *ngFor="let item of items" appTransitionGroupItem>
etc
Also, if you're wondering why the transition animation isn't working, don't forget the CSS required:
.flip-list-move {
transition: transform 1s;
}
Upvotes: 6
Reputation: 498
Once the animated elements are not in the view the animation breaks. I fixed it by editing refreshPosition function:
refreshPosition(prop: string) {
this.items.forEach(item => {
item[prop] = {
top: item.el.offsetTop,
left: item.el.offsetLeft
}
});
}
Originally @yurzui used el.getBoundingClientRect() to get the positions but this method returns positions relative to the viewport.
I changed it so it gets the positions using el.offsetTop and el.offsetLeft which are relative to the first ancestor that isn't positioned 'static'.
Upvotes: 4
Reputation: 214175
Here is simple implementation such functionality Plunker Example
1) Build directives
@Directive({
selector: '[transition-group-item]'
})
export class TransitionGroupItemDirective {
prevPos: any;
newPos: any;
el: HTMLElement;
moved: boolean;
moveCallback: any;
constructor(elRef: ElementRef) {
this.el = elRef.nativeElement;
}
}
@Component({
selector: '[transition-group]',
template: '<ng-content></ng-content>'
})
export class TransitionGroupComponent {
@Input('transition-group') class;
@ContentChildren(TransitionGroupItemDirective) items: QueryList<TransitionGroupItemDirective>;
ngAfterContentInit() {
this.refreshPosition('prevPos');
this.items.changes.subscribe(items => {
items.forEach(item => {
item.prevPos = item.newPos || item.prevPos;
});
items.forEach(this.runCallback);
this.refreshPosition('newPos');
items.forEach(this.applyTranslation);
// force reflow to put everything in position
const offSet = document.body.offsetHeight;
this.items.forEach(this.runTransition.bind(this));
})
}
runCallback(item: TransitionGroupItemDirective) {
if(item.moveCallback) {
item.moveCallback();
}
}
runTransition(item: TransitionGroupItemDirective) {
if (!item.moved) {
return;
}
const cssClass = this.class + '-move';
let el = item.el;
let style: any = el.style;
el.classList.add(cssClass);
style.transform = style.WebkitTransform = style.transitionDuration = '';
el.addEventListener('transitionend', item.moveCallback = (e: any) => {
if (!e || /transform$/.test(e.propertyName)) {
el.removeEventListener('transitionend', item.moveCallback);
item.moveCallback = null;
el.classList.remove(cssClass);
}
});
}
refreshPosition(prop: string) {
this.items.forEach(item => {
item[prop] = item.el.getBoundingClientRect();
});
}
applyTranslation(item: TransitionGroupItemDirective) {
item.moved = false;
const dx = item.prevPos.left - item.newPos.left;
const dy = item.prevPos.top - item.newPos.top;
if (dx || dy) {
item.moved = true;
let style: any = item.el.style;
style.transform = style.WebkitTransform = 'translate(' + dx + 'px,' + dy + 'px)';
style.transitionDuration = '0s';
}
}
}
2) Use it as follows
<ul [transition-group]="'flip-list'">
<li *ngFor="let item of items" transition-group-item>
{{ item }}
</li>
</ul>
Upvotes: 25