【问题标题】:Pause dispatching NgRx actions while animation is in progress动画正在进行时暂停调度 NgRx 动作
【发布时间】:2019-11-23 09:40:57
【问题描述】:

我有一个在屏幕上显示单个点的小应用程序。

这是一个绑定到 NgRx 存储中状态的简单 div。

<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 事件。

我也可以用这样的图来解释

间距取决于过渡持续时间。

这是可运行的应用程序https://stackblitz.com/edit/angular-qlpr2g 和repo https://github.com/cwayfinder/pausable-ngrx

【问题讨论】:

  • 似乎您已经在使用transitionstarttransitionend。是积累的问题吗?你可以使用一个简单的计数器,它在转换开始时增长,在转换结束时减少,这样你的下一个事件只会在计数器为 0 时触发?
  • 问题在于积累本身。我需要创建一些缓冲区左右。
  • 如果这是所有动画的单个组件,您可以添加一个类成员。如果这需要在组件之间进行协调,您可以在商店中添加一个 state 属性来跟踪所有组件。
  • 这是一个如何管理 RxJS 动作流以使其可暂停的问题。
  • 我只是好奇你是如何运行动画的?您是否将某个类添加到动画元素?

标签: javascript angular rxjs css-transitions ngrx


【解决方案1】:

我想我有一个或多或少好的解决方案。检查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;
}

在浏览器控制台中我看到了这个

【讨论】:

    【解决方案2】:

    我修改了您的 StackBlitz 演示以提供工作示例,请查看 here

    至于解释,我从 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 }));
      }
    })
    
    1. 当事件从this.backend.update$到达时,我们会根据事件类型更新延迟主题。我们将以毫秒为单位发出持续时间数字,这将有助于我们延迟时间 + 50 的其他事件,以便额外注意。

    2. zip(src$, delay$) 将立即从 src$ 发出第一个事件,但是从 src$ 发出将根据项目类型为 delay$ 产生新值。例如,如果第一个偶数是 position,则 delaySub 将获得 3000 的值,并且当下一个事件到达 src$ 时,zip 将在 concatMap(time =&gt; timer(time + 50)), 的帮助下将此新值与 3000 的最新延迟配对。最后我们将得到预期的行为,第一个项目将毫无延迟地到达,后续事件必须在 zipconcatMap 和其他运算符的帮助下,根据前一个事件等待特定的时间。

    如果您对我的代码有任何疑问,请让我更新答案。

    【讨论】:

    • 感谢您的回答!使用这种方法,我们必须证明 CSS 和代码中的数字是合理的,并且为了安全起见还要额外增加 50 毫秒(如果动画持续时间像 250 毫秒左右可能会太多)。依靠动画结束的确切时刻不是更好吗?
    • 我们也可以使用确切的数字,但它仍然会按照您的意愿运行,我只是将它添加到那里,以便我可以轻松地发现我的眼睛动画结束和新动画开始。随意修改,让动画感觉最好。
    • 是否可以订阅动画结束事件而不是启动一个并行计时器并订阅它?
    【解决方案3】:

    您可以使用concatMapdelayWhen 来执行此操作。还要注意transitionEnd 事件can be fired multiple times 如果多个属性被更改,所以我使用debounceTime 来过滤这样的双重事件。我们不能改用distinctUntilChanged,因为第一个transitionEnd 将触发下一次更新,它会立即将transitionInProgress$ 状态更改为true。我不使用transitionStart 回调,因为在触发transitionStart 之前可以进行多次更新。 Here is 工作示例。

    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)
      }
    }
    

    【讨论】:

    • 我们可以使用运算符distinctUntilChanged来处理多次更新
    • 我刚试过这种方法。这没有按预期工作。
    • 您能告诉我,什么工作不如预期吗?我尝试了这个解决方案,它奏效了。 'distinctUntilChanged'呢,这里不行,因为第一个transitionEnd会触发更新处理,会改变'transitionInProgress$'为'true',所以下一个,'transitionEnd'会再触发一次更新
    • 我已通过添加working example link 更新了答案。另外,现在我使用 debounceTime 过滤重复的过渡状态更改,而不是通过属性名称显式过滤它们。
    【解决方案4】:

    实际上,我认为zip 有一个非常简单的解决方案,类似于@goga-koreli 所做的。

    基本上zip 仅在其所有源都发出第 n 项时才发出第 n 项。因此,您可以推送尽可能多的后端更新,然后保留另一个仅在动画结束事件时发出其第 n 个值的 Observable(或本例中的主题)。换句话说,即使后端发送更新的速度太快,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

    【讨论】:

      猜你喜欢
      • 2018-07-20
      • 1970-01-01
      • 2013-12-01
      • 1970-01-01
      • 1970-01-01
      • 2011-12-12
      • 2018-12-10
      • 1970-01-01
      • 2017-03-12
      相关资源
      最近更新 更多