Wenris
Wenris

Reputation: 246

How to implement item reorder/shuffle animations with Angular's ngFor?

Vue users are easy to implement such item shuffle animations, see their official docs:

shuffle animation

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

Answers (5)

pawel
pawel

Reputation: 258

Here is my version of @yurzui code. Changes:

  • supports insert and deletion of items
  • a forced reflow survives webpack optimizations
  • detect parent position change (scroll)
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

Tomasz Kula
Tomasz Kula

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.

Stackblitz demo

enter image description here

@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

toby.harding
toby.harding

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

Amit Hadary
Amit Hadary

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

yurzui
yurzui

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

Related Questions