3

Here is my code:
app.component.ts

notifier$ = new BehaviorSubject<any>({});

notify() {
  this.notifier$.next({});
}

app.component.html

<div (scroll)="notify()"></div>
<child-component [inp]="notifier$ | async" />

The problem is that when a user is scrolling, the notify() function is called repeatedly and I only want to call notify() once each time the user starts to scroll.

I can accomplish what I want this way:

scrolling = false;
scrollingTimer: NodeJS.Timer;

notify() {
  clearTimeout(this.scrollingTimer);
  if (!this.scrolling) {
    this.notifier$.next({});
  }
  this.scrolling = true;
  this.scrollingTimer = setTimeout(() => (this.scrolling = false), 1000);
}

but I would like to do this with rxjs. However debounceTime is the opposite of what I want, and neither throttleTime nor auditTime are what I want either. Is there a way to do this?

Kamil Naja
  • 6,267
  • 6
  • 33
  • 47
obl
  • 1,799
  • 12
  • 38

3 Answers3

2

you could build an observable like so:

const scroll$ = fromEvent(document, 'scroll');

const scrollEnd$ = scroll$.pipe(
  switchMapTo(
    timer(1000) // on every scroll, restart a timer
  )
);

const scrollStart$ = scrollEnd$.pipe( // the scroll end event triggers switch to scroll$
  startWith(0), // but start it off
  switchMapTo(
    scroll$.pipe( // then just take the first scroll$ event
      first()
    )
  )
);

scrollStart$.subscribe(v => console.log('scroll start'));

you could generalize it to an operator:

function firstTimeout(timeout: number) { // welcoming notes on a better name
  return input$ => {
    const inputTimeout$ = input$.pipe(
      switchMapTo(timer(timeout))
    );

    return inputTimeout$.pipe(
      startWith(0),
      switchMapTo(input$.pipe(first()))
    );
  };
}

and use it like:

notifier$.pipe(firstTimeout(1000)).subscribe(v => console.log('took one'));

a good idea for this case might be to wrap it in a directive for easy reuse:

@Directive({
  selector: '[scrollStart]'
})
export class ScrollStartDirective {

  private scrollSource = new Subject();

  @HostListener('scroll', ['$event'])
  private onScroll(event) {
    this.scrollSource.next(event);
  }

  @Output()
  scrollStart = new EventEmitter();

  constructor() {
    this.scrollSource.pipe(firstTimeout(1000)).subscribe(this.scrollStart);
  }
}

then you can use it like this:

<div (scrollStart)="notify()"></div>
bryan60
  • 28,215
  • 4
  • 48
  • 65
  • Its the correct solution.....I would suggest use take(1) instead of first() because if the user never scrolls then first() would throw an error in your console – Ajay Reddy Jul 18 '19 at 21:29
  • Awesome! The only thing I would add is to `unsubscribe` in the directive – obl Jul 19 '19 at 20:58
  • @obl you could but don’t have to... it’s a local subject – bryan60 Jul 19 '19 at 21:48
  • @bryan60 hm i'm not sure i understand when unsubscribing is needed. What's the difference between this and using `.subscribe` from within a `component`? – obl Jul 21 '19 at 21:35
  • 1
    @obl I wrote an answer about this here: https://stackoverflow.com/questions/57007118/do-i-need-to-complete-takeuntil-subject-inside-ngondestroy/57009988#57009988 – bryan60 Jul 21 '19 at 23:20
0

When the user scrolls you want notify$ to emit for each scroll event. This provides a constant stream of emitted values. So you want notifier$ to emit once when the stream starts, and again when it's idle for 1 second.

notify$ = new Subject();

notifier$ = merge(
    notify$.pipe(first()),
    notify$.pipe(switchMap(value => of(value).pipe(delay(1000))))
).pipe( 
    take(2),
    repeat()
);

<div (scroll)="notify$.next()"></div>

You merge two observables. The first emits immediately, and the second emits after a 1 second delay. You use a switchMap so that the delayed observable is always restarted.

We take the next 2 values which triggers the stream to complete, and we use repeat to start over.

Reactgular
  • 52,335
  • 19
  • 158
  • 208
-1

you can use take(1)

this.inp.pipe(
  take(1),
).subscribe(res => console.log(res));

take(1) just takes the first value and completes. No further logic is involved.

Of course you are supposed to use the above in your child component :)

Also since your observable is finishing...you can create a new subject and pass it to child component every time he scrolls


notify() {
  this.notifier$ = new BehaviorSubject<any>({});
  this.notifier$.next({});
}
Ajay Reddy
  • 1,475
  • 1
  • 16
  • 20
  • I want to take the first value every time the user scrolls, not just once. If the user stops scrolling and then starts scrolling again, I want to emit again. – obl Jul 18 '19 at 19:28
  • can you try using takeUntil() – Ajay Reddy Jul 18 '19 at 19:36
  • This does not work, when the user is scrolling, `notify()` gets called repeatedly. – obl Jul 18 '19 at 19:52