Martin Broder
Martin Broder

Reputation: 221

Reactive Programming with RxJS - can this scroll function be simplified?

I'm pretty new to reactive programming (and RxJS) and all these operators are heavy to understand.

Anyway, I've successfully written this function that handles scrolling of the document while dragging something. I now wonder if this can be simplified.

Basically, onMouseDown I need to check the position of the mouse every 10ms and I need the updated clientY when the mouse moved, thats why I setup an Rx.Oberservable.interval(10) which I combine with the mouseMove observer. This will scroll the page, whether you move your mouse or not (as intended).

Here's the code:

handleWindowScrollOnDrag() {
    var dragTarget = this.getDOMNode()
    var scrollTarget = document.body
    var wHeight = window.innerHeight
    var maxScroll = document.documentElement.scrollHeight - wHeight

    // Get the three major events
    var mouseup   = Rx.Observable.fromEvent(document, 'mouseup');
    var mousemove = Rx.Observable.fromEvent(document, 'mousemove');
    var mousedown = Rx.Observable.fromEvent(dragTarget, 'mousedown');

    var mousedrag = mousedown.flatMap(function (md) {
        var y = scrollTarget.scrollTop
        var multiplier = 1

        // Scroll every 10ms until mouseup when we can
        var intervalSource = Rx.Observable.interval(10).takeUntil(mouseup);

        // Get actual clientY until mouseup
        var movement = mousemove.map(function (mm) {
            return {
                y: mm.clientY
            };
        }).takeUntil(mouseup);

        return Rx.Observable
            .combineLatest(movement, intervalSource, function (s1) {
                multiplier = 1

                if (s1.y < 100 && y >= 0) {
                    if (s1.y < 75) multiplier = 3;
                    if (s1.y < 50) multiplier = 5;
                    if (s1.y < 25) multiplier = 10;
                    if (s1.y < 15) multiplier = 20;
                    y -= (1 * multiplier)
                }

                if (s1.y > wHeight - 100 && y <= (maxScroll)) {
                    if (s1.y > wHeight - 75) multiplier = 3;
                    if (s1.y > wHeight - 50) multiplier = 5;
                    if (s1.y > wHeight - 25) multiplier = 10;
                    if (s1.y > wHeight - 15) multiplier = 20;
                    y += (1 * multiplier)
                }

                return {
                    y: y
                };
        });
    });

    // Update position
    this.subscription = mousedrag.subscribe(function (pos) {
        document.body.scrollTop = pos.y
    });

},

Upvotes: 2

Views: 2021

Answers (1)

paulpdaniels
paulpdaniels

Reputation: 18663

You can remove the extra takeUntils you only need one on the movement stream, since the combineLatest operator will clean up and dispose of all the underlying subscriptions when it completes.

Next, you can remove some extra state by using .scan to manage accumulated state instead of closure variables.

You can also simplify the event body by simply passing the mouse move's y value instead of incurring the overhead of an object allocation in both the map and the combineLatest operators.

Finally, I would change to use .withLatestFrom instead of combineLatest. Since you are already essentially polling at 100 fps with interval combineLatest would emit even faster if the mouse was moving at the same time.

As a side note, while I am not terribly familiar with how DOM scrolling works (and I don't actually know how well your code works in the wild), spamming the page's scroll value faster than the actual render rate of the page seems like overkill. It might be better to lower the interval rate and use something like jQuery.animate to smooth the scrolling in between.

;tldr

handleWindowScrollOnDrag() {
  var dragTarget = this.getDOMNode()
  var scrollTarget = document.body;
  var wHeight = window.innerHeight;
  var maxScroll = document.documentElement.scrollHeight - wHeight;

  // Get the three major events
  var mouseup   = Rx.Observable.fromEvent(document, 'mouseup');
  var mousemove = Rx.Observable.fromEvent(document, 'mousemove');
  var mousedown = Rx.Observable.fromEvent(dragTarget, 'mousedown');

  var mousedrag = mousedown.flatMap(function (md) {
      // Scroll every 10ms until mouseup when we can
      var intervalSource = Rx.Observable.interval(10, Rx.Scheduler.requestAnimationFrame);

      return intervalSource
          .takeUntil(mouseup)
          .withLatestFrom(mousemove, function (s1, s2) {
              return s2.clientY;
           })
           .scan(scrollTarget.scrollTop, function(y, delta) { 
              var multiplier = 1;

              if (delta  < 100 && y >= 0) {
                  if (delta < 75) multiplier = 3;
                  if (delta < 50) multiplier = 5;
                  if (delta < 25) multiplier = 10;
                  if (delta < 15) multiplier = 20;
                  y -= (1 * multiplier);
              }

              if (delta > wHeight - 100 && y <= (maxScroll)) {
                  if (delta > wHeight - 75) multiplier = 3;
                  if (delta > wHeight - 50) multiplier = 5;
                  if (delta > wHeight - 25) multiplier = 10;
                  if (delta > wHeight - 15) multiplier = 20;
                  y += (1 * multiplier);
              }

              return y;
        });
  });

  // Update position
  this.subscription = mousedrag.subscribe(function (pos) {
      document.body.scrollTop = pos;
  });

},

Edit

A mistake in the original actually allows one to simplify the code even further. You can remove the movement stream all together (and with it an extra call to map) by passing mousemove to withLatestFrom and using that result selector to grab the clientY

Additionally I added where you would likely want to add a scheduler if you were trying to synchronize interval with the render loop of the page, though I would still say you probably want to use 15ms (60fps) rather than 10ms, it again comes down to how the scroll rendering is done.

If setting the scroll position will only be updated at the end of each render loop then it will work fine. However, if it greedily recomputes after every set then it would be better to have an intermediary like React to write to Virtual DOM first rather than flushing all changes directly to the DOM.

Upvotes: 3

Related Questions