16

I have a tiny application that displays a single dot on the screen. application screenshot

This is a simple div bound to state in NgRx store.

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

The dot state changes are animated by CSS transitions.

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

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

I have a backend which pushes updates for the dot (position, color and size). I map these updates on NgRx actions.

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

The problem is that new changes from backend sometimes come earlier than animation ends. My objective is to delay updating the state in store (pause triggering new NgRx actions) until all transitions ended. We can easily handle this moment because chrome already supports the transitionstart event.

I can also explain this with such a diagram spacing

The spacing depends on the transition duration.

Here is the runnable application https://stackblitz.com/edit/angular-qlpr2g and repo https://github.com/cwayfinder/pausable-ngrx.

Taras Hupalo
  • 1,337
  • 2
  • 16
  • 29
  • seems like you're already using `transitionstart` and `transitionend`. is the problem the accumulation? Can you use a simple counter which grows when transitions start and reduces when transitions end, so your next event only fires when the counter is at 0? – Yaelet Jul 14 '19 at 11:38
  • The problem is the accumulation itself. I need to create some buffer or so. – Taras Hupalo Jul 14 '19 at 11:55
  • If this is a single component for all animations, you can add a class member. If this is something to be coordinated between components, you can add a state property on your store to keep track of all components. – Yaelet Jul 14 '19 at 12:50
  • It's rather a question of how to manage RxJS stream of action to make it pausable. – Taras Hupalo Jul 14 '19 at 13:36
  • I'm just curious how do you run the animation? Do you add a certain class to animated element? – Ken Bekov Jul 14 '19 at 17:49
  • No, I specified transition in CSS. I added CSS code to the post. BTW, you can see the full code if you follow the link above. – Taras Hupalo Jul 15 '19 at 05:53
  • Hello, @TarasHupalo! What do you want to do with actions that were dispatched during this "pause"? – Andrii Zelenskyi Jul 17 '19 at 08:51
  • I want to preserve them – Taras Hupalo Jul 17 '19 at 09:29

4 Answers4

6

You can use concatMap and delayWhen to do this. Also notice that transitionEnd event can be fired multiple times if multiple properties were changed, so I use debounceTime to filter such double events. We cannot use distinctUntilChanged instead, because the first transitionEnd will trigger next update which immediately changes transitionInProgress$ state to true. I don't use transitionStart callback, because multiple updates can come before the transitionStart will be triggered. Here is the working example.

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)
  }
}
Valeriy Katkov
  • 33,616
  • 20
  • 100
  • 123
  • We can use the operator `distinctUntilChanged` to deal with multiple updates – Taras Hupalo Jul 20 '19 at 08:01
  • I just tried this approach. This doesn't work as expected. – Taras Hupalo Jul 20 '19 at 08:14
  • Could you please tell, what is working as not expected? I tried the solution, and it worked. What about 'distinctUntilChanged', it won't work here, because the first transitionEnd will trigger the update processing, which will change 'transitionInProgress$' to 'true', so the next ,'transitionEnd' will trigger one more update – Valeriy Katkov Jul 20 '19 at 09:20
  • I've updated the answer by adding the [working example link](https://stackblitz.com/edit/angular-r7m291). Also, now I use debounceTime to filter duplicated transition state changes, instead of filtering them explicitly by properties names. – Valeriy Katkov Jul 20 '19 at 10:14
3

I modified your StackBlitz demo to offer you working example, have a look at here.

As for explanation I copied important code from StackBlitz to explain important details:

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. When the event arrives from this.backend.update$, we will update delay subject according to the event type. We will emit duration number in milliseconds, which will later help us to delay other events that amount of time + 50 for extra care.

  2. zip(src$, delay$) will emit first event from src$ without any delay, however emit from the src$ will cause new value for delay$ based on the item type. For example if first even is position delaySub will get value of 3000 and when the next event arrives at src$, zip will pair this new value and latest delay of 3000 with the help of concatMap(time => timer(time + 50)),. Finally we will get intended behavior, first item is going to arrive without any delay and subsequent events have to wait for specific amount of time based on the previous event, with the help of zip, concatMap and other operators.

Let me update my answer if you have any questions about my code.

Goga Koreli
  • 2,807
  • 1
  • 12
  • 31
  • Thanks for your answer! With this approach, we must justify numbers in CSS and code and also add 50 extra ms to be on the safe side (might be too much if animation duration is like 250ms or so). Isn't it better to rely on the exact moment when the animation ends? – Taras Hupalo Jul 18 '19 at 05:30
  • We can go with the exact number as well, still it will behave as you want, I just added it there so that I could spot with my eye animation ending and new animation starting easily. Feel free to modify as you wish to make animation feel the best. – Goga Koreli Jul 18 '19 at 07:22
  • Is it possible to subscribe to the animation end event rather than start a paralel timer and subscribe to it? – Taras Hupalo Jul 19 '19 at 07:56
2

I think I have a more or less good solution. Check https://stackblitz.com/edit/angular-xh7ndi

I have overridden NgRx class 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),
      ),
    );
  });
}

In the AppModule I specified providers

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

For debugging purpose I increased CSS transition time

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

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

In the browser console I see this

enter image description here

Taras Hupalo
  • 1,337
  • 2
  • 16
  • 29
0

Actually, I think there's a pretty simple solution with zip similar to what @goga-koreli made.

Basically zip emits n-th item only when all its sources emit n-th item. So you can push as many backend updates as possible and then keep another Observable (or Subject in this case) that emits its n-th value only on animation end event. In other words even when backend is sending updates too quickly zip will dispatch actions only as fast as the animations complete.

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

Your updated demo: https://stackblitz.com/edit/angular-42alkp?file=src/app/app.component.ts

martin
  • 93,354
  • 25
  • 191
  • 226