J'ai une petite application qui affiche un seul point sur l'écran.
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
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 .
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)
}
}
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 }));
}
})
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.
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.
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
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