web-dev-qa-db-fra.com

Suspendre l'envoi des actions NgRx pendant que l'animation est en cours

J'ai une petite application qui affiche un seul point sur l'écran. application screenshot

Il s'agit d'un simple div lié à indiquer dans le magasin NgRx.

<div class="dot"
   [style.width.px]="size$ | async"
   [style.height.px]="size$ | async"
   [style.backgroundColor]="color$ | async"
   [style.left.px]="x$ | async"
   [style.top.px]="y$ | async"
   (transitionstart)="transitionStart()"
   (transitionend)="transitionEnd()"></div>

Les changements d'état des points sont animés par des transitions CSS.

.dot {
  border-radius: 50%;
  position: absolute;

  $moveTime: 500ms;
  $sizeChangeTime: 400ms;
  $colorChangeTime: 900ms;
  transition:
    top $moveTime, left $moveTime,
    background-color $colorChangeTime,
    width $sizeChangeTime, height $sizeChangeTime;
}

J'ai un backend qui pousse les mises à jour pour le point (position, couleur et taille). Je mappe ces mises à jour sur les actions NgRx.

export class AppComponent implements OnInit {
  ...

  constructor(private store: Store<AppState>, private backend: BackendService) {}

  ngOnInit(): void {
    ...

     this.backend.update$.subscribe(({ type, value }) => {
       // TODO: trigger new NgRx action when all animations ended
       if (type === 'position') {
         const { x, y } = value;
         this.store.dispatch(move({ x, y }));
       } else if (type === 'color') {
         this.store.dispatch(changeColor({ color: value }));
       } else if (type === 'size') {
         this.store.dispatch(changeSize({ size: value }));
       }
     });
   }
 }

Le problème est que les nouveaux changements du backend arrivent parfois avant la fin de l'animation. Mon objectif est de retarder la mise à jour de l'état en magasin (pause déclenchant de nouvelles actions NgRx) jusqu'à la fin de toutes les transitions. Nous pouvons facilement gérer ce moment car chrome prend déjà en charge l'événement transitionstart.

Je peux également expliquer cela avec un tel diagramme spacing

L'espacement dépend de la durée de transition.

Voici l'application exécutable https://stackblitz.com/edit/angular-qlpr2g et repo https://github.com/cwayfinder/pausable-ngrx .

16
Taras Hupalo

Vous pouvez utiliser concatMap et delayWhen pour ce faire. Notez également que transitionEnd événement peut être déclenché plusieurs fois si plusieurs propriétés ont été modifiées, j'utilise donc debounceTime pour filtrer ces événements doubles. Nous ne pouvons pas utiliser distinctUntilChanged à la place, car le premier transitionEnd déclenchera la prochaine mise à jour qui change immédiatement l'état transitionInProgress $ en true. Je n'utilise pas de rappel transitionStart, car plusieurs mises à jour peuvent survenir avant le déclenchement de transitionStart. Voici l'exemple de travail.

export class AppComponent implements OnInit {
  ...

  private readonly  transitionInProgress$ = new BehaviorSubject(false);

  ngOnInit(): void {
    ...

    this.backend.update$.pipe(
      concatMap(update => of(update).pipe(
        delayWhen(() => this.transitionInProgress$.pipe(
          // debounce the transition state, because transitionEnd event fires multiple
          // times for a single transiation, if multiple properties were changed
          debounceTime(1),
          filter(inProgress => !inProgress)
        ))
      ))
    ).subscribe(update => {
        this.transitionInProgress$.next(true)

        if (update.type === 'position') {
          this.store.dispatch(move(update.value));
        } else if (update.type === 'color') {
          this.store.dispatch(changeColor({ color: update.value }));
        } else if (update.type === 'size') {
          this.store.dispatch(changeSize({ size: update.value }));
        }
    });
  }

  transitionEnd(event: TransitionEvent) {
    this.transitionInProgress$.next(false)
  }
}
5
Valeriy Katkov

J'ai modifié votre démo StackBlitz pour vous offrir un exemple de travail, jetez un œil à ici .

En ce qui concerne l'explication, j'ai copié un code important de StackBlitz pour expliquer des détails importants:

const delaySub = new BehaviorSubject<number>(0);
const delay$ = delaySub.asObservable().pipe(
  concatMap(time => timer(time + 50)),
  share(),
)

const src$ = this.backend.update$
  .pipe(
    tap(item => item['type'] === 'position' && delaySub.next(3000)),
    tap(item => item['type'] === 'size' && delaySub.next(2000)),
    tap(item => item['type'] === 'color' && delaySub.next(1000)),
  )

Zip(src$, delay$).pipe(
  map(([item, delay]) => item)
).subscribe(({ type, value }) => {
  // TODO: trigger new NgRx action when all animations ended
  if (type === 'position') {
    this.store.dispatch(move(value));
  } else if (type === 'color') {
    this.store.dispatch(changeColor({ color: value }));
  } else if (type === 'size') {
    this.store.dispatch(changeSize({ size: value }));
  }
})
  1. Lorsque l'événement arrive de this.backend.update$, Nous mettrons à jour le sujet du retard en fonction du type d'événement. Nous émettrons un nombre de durées en millisecondes, ce qui nous aidera plus tard à retarder d'autres événements d'une durée de + 50 pour des soins supplémentaires.

  2. Zip(src$, delay$) émettra le premier événement de src $ sans délai, cependant l'émission de src$ provoquera une nouvelle valeur pour delay$ en fonction du type d'élément. Par exemple, si le premier est même position delaySub obtiendra la valeur de 3000 et lorsque le prochain événement arrivera à src$, Zip appairera cette nouvelle valeur et le dernier délai de 3000 à l'aide de la fonction concatMap(time => timer(time + 50)),. Enfin, nous obtiendrons le comportement souhaité, le premier élément va arriver sans délai et les événements suivants doivent attendre un certain temps en fonction de l'événement précédent, à l'aide de Zip, concatMap et d'autres opérateurs.

Permettez-moi de mettre à jour ma réponse si vous avez des questions sur mon code.

3
Goga Koreli

Je pense avoir une solution plus ou moins bonne. Vérifiez https://stackblitz.com/edit/angular-xh7ndi

J'ai remplacé la classe NgRx ActionSubject

import { Injectable } from '@angular/core';
import { Action, ActionsSubject } from '@ngrx/store';
import { BehaviorSubject, defer, from, merge, Observable, Subject } from 'rxjs';
import { bufferToggle, distinctUntilChanged, filter, map, mergeMap, share, tap, windowToggle } from 'rxjs/operators';

@Injectable()
export class PausableActionsSubject extends ActionsSubject {

  queue$ = new Subject<Action>();
  active$ = new BehaviorSubject<boolean>(true);

  constructor() {
    super();

    const active$ = this.active$.pipe(distinctUntilChanged());
    active$.subscribe(active => {
      if (!active) {
        console.time('pauseTime');
      } else {
        console.timeEnd('pauseTime');
      }
    });

    const on$ = active$.pipe(filter(v => v));
    const off$ = active$.pipe(filter(v => !v));

    this.queue$.pipe(
      share(),
      pause(on$, off$, v => this.active$.value)
    ).subscribe(action => {
      console.log('action', action);
      super.next(action);
    });
  }

  next(action: Action): void {
    this.queue$.next(action);
  }

  pause(): void {
    this.active$.next(false);
  }

  resume(): void {
    this.active$.next(true);
  }
}

export function pause<T>(on$: Observable<any>, off$: Observable<any>, haltCondition: (value: T) => boolean) {
  return (source: Observable<T>) => defer(() => { // defer is used so that each subscription gets its own buffer
    let buffer: T[] = [];
    return merge(
      source.pipe(
        bufferToggle(off$, () => on$),
        // append values to your custom buffer
        tap(values => buffer = buffer.concat(values)),
        // find the index of the first element that matches the halt condition
        map(() => buffer.findIndex(haltCondition)),
        // get all values from your custom buffer until a haltCondition is met
        map(haltIndex => buffer.splice(0, haltIndex === -1 ? buffer.length : haltIndex + 1)),
        // spread the buffer
        mergeMap(toEmit => from(toEmit)),
      ),
      source.pipe(
        windowToggle(on$, () => off$),
        mergeMap(x => x),
      ),
    );
  });
}

Dans le AppModule j'ai spécifié des fournisseurs

providers: [
    PausableActionsSubject,
    { provide: ActionsSubject, useExisting: PausableActionsSubject }
]

À des fins de débogage, j'ai augmenté le temps de transition CSS

.dot {
  border-radius: 50%;
  position: absolute;

  $moveTime: 3000ms;
  $sizeChangeTime: 2000ms;
  $colorChangeTime: 1000ms;
  transition:
    top $moveTime, left $moveTime,
    background-color $colorChangeTime,
    width $sizeChangeTime, height $sizeChangeTime;
}

Dans la console du navigateur, je vois ceci

enter image description here

2
Taras Hupalo

En fait, je pense qu'il existe une solution assez simple avec Zip similaire à ce que @ goga-koreli a fait.

Fondamentalement, Zip n'émet le nième élément que lorsque toutes ses sources émettent le nième élément. Vous pouvez donc envoyer autant de mises à jour backend que possible, puis conserver un autre observable (ou sujet dans ce cas) qui n'émet sa nième valeur que lors de la fin de l'animation. En d'autres termes, même lorsque le backend envoie des mises à jour trop rapidement, Zip n'enverra les actions qu'à la fin des animations.

private animationEnd$ = new Subject();

...

Zip(
    this.backend.update$,
    this.animationEnd$.pipe(startWith(null)), // `startWith` to trigger the first animation.
  )
  .pipe(
    map(([action]) => action),
  )
  .subscribe(({ type, value }) => {
    ...
  });

...

transitionEnd() {
  this.animationEnd$.next();
}

Votre démo mise à jour: https://stackblitz.com/edit/angular-42alkp?file=src/app/app.component.ts

0
martin