web-dev-qa-db-ja.com

アニメーションの進行中にNgRxアクションのディスパッチを一時停止します

画面に単一のドットを表示する小さなアプリケーションがあります。 application screenshot

これは、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イベントをサポートしているため、この瞬間を簡単に処理できます。

このような図でこれを説明することもできます spacing

間隔は遷移時間に依存します。

これが実行可能なアプリケーション https://stackblitz.com/edit/angular-qlpr2g およびrepo https://github.com/cwayfinder/pausable-ngrx です。

16
Taras Hupalo

これを行うには、 concatMap および delayWhen を使用できます。また、transitionEnd event 複数回発生する可能性があります 複数のプロパティが変更された場合は、 debounceTime を使用して、このような二重のイベントをフィルタリングします。最初のdistinctUntilChangedは次の更新をトリガーし、transitionInProgress $状態をすぐにtrueに変更するため、代わりにtransitionEndを使用できません。 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)
  }
}
5
Valeriy Katkov

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$_に到達すると、この新しい値と最新の遅延3000をconcatMap(time => timer(time + 50)),。最後に、意図した動作を取得します。最初のアイテムは遅延なく到着し、後続のイベントは、ZipconcatMapの助けを借りて、前のイベントに基づいて特定の時間待機する必要がありますおよびその他の演算子。

私のコードについて質問がある場合は、私の回答を更新しましょう。

3
Goga Koreli

私は多かれ少なかれ良い解決策を持っていると思います。チェック 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

2
Taras Hupalo

実際には、@ goga-koreliが作成したものと同様のZipを使用したかなり単純な解決策があると思います。

基本的にZipは、すべてのソースがn番目のアイテムを放出する場合にのみn番目のアイテムを放出します。そのため、できるだけ多くのバックエンド更新をプッシュし、アニメーションの終了イベントでのみ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

0
martin