Приостановить диспетчеризацию действий NgRx во время анимации
У меня есть крошечное приложение, которое отображает одну точку на экране. ![application screenshot]()
Это простой div, связанный с состоянием в хранилище 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>
Изменения состояния точки анимируются с помощью переходов 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;
}
У меня есть бэкэнд, который выдвигает обновления для точки (положение, цвет и размер). Я сопоставляю эти обновления с действиями 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 }));
}
});
}
}
Проблема в том, что новые изменения в бэкэнде иногда происходят раньше, чем заканчивается анимация. Моя цель - отложить обновление состояния в хранилище (приостановить запуск новых действий NgRx) до завершения всех переходов. Мы легко можем справиться с этим моментом, потому что Chrome уже поддерживает событие transitionstart
.
Я также могу объяснить это с помощью такой диаграммы ![spacing]()
Интервал зависит от продолжительности перехода.
Вот работающее приложение https://stackblitz.com/edit/angular-qlpr2g и репозиторий https://github.com/cwayfinder/pausable-ngrx.
Ответы
Ответ 1
Вы можете использовать concatMap и delayWhen, чтобы сделать это. Также обратите внимание, что событие transitionEnd
может быть запущено несколько раз, если было изменено несколько свойств, поэтому я использую debounceTime для фильтрации таких двойных событий. Вместо этого мы не можем использовать distinctUntilChanged
, потому что первый transitionEnd
будет запускать следующее обновление, которое немедленно изменяет состояние transitionInProgress $ на true. Я не использую обратный вызов transitionStart
, потому что несколько обновлений могут прийти до того, как будет запущен transitionStart. Вот рабочий пример.
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)
}
}
Ответ 2
Я изменил вашу демонстрацию StackBlitz, чтобы предложить вам рабочий пример, посмотрите здесь.
Что касается объяснения, я скопировал важный код из StackBlitz, чтобы объяснить важные детали:
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 }));
}
})
-
Когда событие прибывает из this.backend.update$
, мы обновим тему задержки в соответствии с типом события. Мы выдадим номер продолжительности в миллисекундах, который позже поможет нам отложить другие события на сумму + 50 для дополнительной помощи.
-
zip(src$, delay$)
будет генерировать первое событие из src $ без какой-либо задержки, однако emit from src$
вызовет новое значение для delay$
зависимости от типа элемента. Например, если first even - position
delaySub получит значение 3000, а когда следующее событие достигнет значения src$
, zip свяжет это новое значение и последнюю задержку 3000 с помощью concatMap(time => timer(time + 50)),
. Наконец, мы получим намеченное поведение, первый элемент будет доставлен без какой-либо задержки, а последующим событиям придется ждать определенное количество времени, основываясь на предыдущем событии, с помощью zip
, concatMap
и других операторов.
Позвольте мне обновить мой ответ, если у вас есть какие-либо вопросы о моем коде.
Ответ 3
Я думаю, у меня есть более или менее хорошее решение. Проверьте https://stackblitz.com/edit/angular-xh7ndi
Я переопределил 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),
),
);
});
}
В AppModule
я указал провайдеров
providers: [
PausableActionsSubject,
{ provide: ActionsSubject, useExisting: PausableActionsSubject }
]
Для отладки я увеличил время перехода 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;
}
В консоли браузера я вижу это
![enter image description here]()
Ответ 4
На самом деле, я думаю, что есть довольно простое решение с zip
, похожее на то, что сделал @goga-koreli.
В основном zip
испускает n-й предмет, только когда все его источники испускают n-й предмет. Таким образом, вы можете отправить как можно больше внутренних обновлений, а затем оставить другой Observable (или Subject в данном случае), который выдает свое n-е значение только в конце события анимации. Другими словами, даже когда сервер отправляет обновления слишком быстро zip
будет отправлять действия только с той скоростью, с которой анимация завершена.
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();
}
Ваше обновленное демо: https://stackblitz.com/edit/angular-42alkp?file=src/app/app.component.ts