Infinite Scrolling
with
Angular & RxJS

AngularJS London, 30 June 2015

Sébastien Cevey

@theefer

The Grid

Demo:
Load on demand

Lazy Table

viewport

loaded cells

Directives

<!-- sets container height -->
<ul gu-lazy-table="ctrl.items"
    gu-lazy-table-cell-height="280"
    gu-lazy-table-cell-min-width="300"
    gu-lazy-table-load-range="ctrl.loadRange($start, $end)">

  <!-- absolutely positions cell within container -->
  <li ng:repeat="item in ctrl.items"
      gu-lazy-table-cell="image">

    <!-- custom markup for each cell... -->
    <img src="..." />
  </li>
</ul>

Let's do some maths!

Table Height

/* in guLazyTable directive
   link: function(scope, element, attrs) { */

// Config
const cellHeight = attrs.guLazyTableCellHeight; // 280px
const columns = 5; // hardcoded ;_;

// Dynamic
const itemCount = items.length;
const rows = Math.ceil(itemCount / columns);
const height = rows * cellHeight;

element.css('height', height + 'px');

Fixed column count: not responsive!

/* in guLazyTable directive
   link: function(scope, element, attrs) { */










element.css('height', height + 'px');
/* in guLazyTable directive
   link: function(scope, element, attrs) { */





// Dynamic
const itemCount = items.length;
const rows = Math.ceil(itemCount / columns);
const height = rows * cellHeight;

element.css('height', height + 'px');
<ul gu-lazy-table="ctrl.items"
    gu-lazy-table-cell-height="280"
    gu-lazy-table-cell-min-width="300"
    ... >

Variable columns

// Config
const cellHeight   = attrs.guLazyTableCellHeight;   // 280px
const cellMinWidth = attrs.guLazyTableCellMinWidth; // 300px

// Variable columns
const containerWidth = element[0].clientWidth;
const columns = Math.max(1, Math.floor(containerWidth / cellMinWidth));

// Dynamic
const itemCount = items.length;
const rows = Math.ceil(itemCount / columns);
const height = rows * cellHeight;

element.css('height', height + 'px');

Browser window resize?

<ul gu-lazy-table="ctrl.items"
    gu-lazy-table-cell-height="280"
    gu-lazy-table-cell-min-width="300"
    ... >

Dynamic layout: Imperative

// Config
const cellHeight   = attrs.guLazyTableCellHeight;   // 280px
const cellMinWidth = attrs.guLazyTableCellMinWidth; // 300px

$window.addEventListener('resize', recomputeHeight);

recomputeHeight();

function recomputeHeight() {
  // Variable columns
  const containerWidth = element[0].clientWidth;
  const columns = Math.max(1, Math.floor(containerWidth / cellMinWidth));

  // Dynamic
  const itemCount = items.length;
  const rows = Math.ceil(itemCount / columns);
  const height = rows * cellHeight;

  element.css('height', height + 'px');
}

Why read from the DOM again though?

$window.addEventListener('resize', _.debounce(recomputeHeight, 100));

Debounce resize listener?

Attribute config changed?

let cellHeight;
$scope.$watch(() => attrs.guLazyTableCellHeight, (newHeight) => {
  cellHeight = newHeight;
  recomputeHeight();
});
// ditto cellMinWidth

Reactive Programming

Dynamic layout: Reactive

const cellHeight$   = observe$(scope, attrs.guLazyTableCellHeight);
const cellMinWidth$ = observe$(scope, attrs.guLazyTableCellMinWidth);

const viewportResized$ = Rx.DOM.fromEvent($window, 'resize').
  debounce(100).
  startWith({/* init */});

const containerWidth$ = viewportResized$.map(
  () => element[0].clientWidth
);

const columns$ = max$(1, floor$(div$(containerWidth$, cellMinWidth$)));
// ^ define stream combinators for common operations!

// previously:
// const columns = Math.max(1, Math.floor(containerWidth / cellMinWidth));

const items$ = observeCollection$(scope, attrs.guLazyTable);
const itemCount$ = items$.map(items => items.length);
const rows$ = ceil$(div$(itemCount$, columns$));
const height$ = mult$(rows$, cellHeight$);
const cellHeight$   = observe$(scope, attrs.guLazyTableCellHeight);
const cellMinWidth$ = observe$(scope, attrs.guLazyTableCellMinWidth);

const viewportResized$ = Rx.DOM.fromEvent($window, 'resize').
  debounce(100).
  startWith({/* init */});

const containerWidth$ = viewportResized$.map(
  () => element[0].clientWidth
);

const columns$ = Rx.Observable.combineLatest$(
  containerWidth$, cellMinWidth$,
  (containerWidth, cellMinWidth) => {
    return Math.max(1, Math.floor(containerWidth / cellMinWidth)
  }
);
const cellHeight$   = observe$(scope, attrs.guLazyTableCellHeight);
const cellMinWidth$ = observe$(scope, attrs.guLazyTableCellMinWidth);

const viewportResized$ = Rx.DOM.fromEvent($window, 'resize').
  debounce(100).
  startWith({/* init */});

const containerWidth$ = viewportResized$.map(
  () => element[0].clientWidth
);

const columns$ = max$(1, floor$(div$(containerWidth$, cellMinWidth$)));
// ^ define stream combinators for common operations!

// previously:
// const columns = Math.max(1, Math.floor(containerWidth / cellMinWidth));
const cellHeight$   = observe$(scope, attrs.guLazyTableCellHeight);
const cellMinWidth$ = observe$(scope, attrs.guLazyTableCellMinWidth);

const viewportResized$ = Rx.DOM.fromEvent($window, 'resize').
  debounce(100).
  startWith({/* init */});

const containerWidth$ = viewportResized$.map(
  () => element[0].clientWidth
);

const columns$ = max$(1, floor$(div$(containerWidth$, cellMinWidth$)));
// ^ define stream combinators for common operations!

// previously:
// const columns = Math.max(1, Math.floor(containerWidth / cellMinWidth));

const items$ = observeCollection$(scope, attrs.guLazyTable);
const itemCount$ = items$.map(items => items.length);
const rows$ = ceil$(div$(itemCount$, columns$));
const height$ = mult$(rows$, cellHeight$);

height$.safeApply(scope, height => {
  element.css('height', height + 'px');
});
const cellHeight$   = observe$(scope, attrs.guLazyTableCellHeight);
const cellMinWidth$ = observe$(scope, attrs.guLazyTableCellMinWidth);

const viewportResized$ = Rx.DOM.fromEvent($window, 'resize').
  debounce(100).
  startWith({/* init */});

const containerWidth$ = viewportResized$.map(
  () => element[0].clientWidth
);
const cellHeight$   = observe$(scope, attrs.guLazyTableCellHeight);
const cellMinWidth$ = observe$(scope, attrs.guLazyTableCellMinWidth);

const viewportResized$ = Rx.DOM.fromEvent($window, 'resize').
  debounce(100).
  startWith({/* init */});
const cellHeight$   = observe$(scope, attrs.guLazyTableCellHeight);
const cellMinWidth$ = observe$(scope, attrs.guLazyTableCellMinWidth);

Range to load

$start
$end
const viewportScrolled$ =
  Rx.DOM.fromEvent($window, 'scroll').
    debounce(100).
    startWith({/* init */});

const offsetTop$ = viewportScrolled$.map(...);

// Stream of {$start: __, $end: __} range
// of indexes to load
const rangeToLoad$ = ...;

/*
<ul gu-lazy-table="ctrl.images"
    gu-lazy-table-load-range="
      ctrl.loadRange($start, $end)">
*/
rangeToLoad$.subscribe(range => {
  scope.$eval(attrs.guLazyTableLoadRange, range);
});

Cell positioning

return {
  restrict: 'A',
  require: '^guLazyTable',
  link: function(scope, element, attrs, ctrl) {
    const item = scope.$eval(attrs.guLazyTableCell);
    const position$ = ctrl.getCellPosition$(item);
    subscribe$(scope, position$,
               ({top, left, width, height, display}) => {
        element.css({
          position: 'absolute',
          top:    top    + 'px',
          left:   left   + 'px',
          width:  width  + 'px',
          height: height + 'px',
          display: display
        });
    });
  }
};
<!-- absolutely positions cell within container -->
<li ng:repeat="item in ctrl.items"
    gu-lazy-table-cell="image">

  <!-- custom markup for each cell... -->
  <img src="..." />
</li>
top, left, width, height

Demo:
Infinite Scroll

Performance Optimisation

Recalculate Style

const viewWidth$ = viewportResized$
  .map(() => element[0].clientWidth);


// Accessed each time for each cell:
// const cellWidth = f(viewWidth$)
// ...
const viewWidth$ = viewportResized$
  .map(() => element[0].clientWidth)
  .shareReplay(1);

// Accessed once for all cells:
// const cellWidth = f(viewWidth$)
// ...

Only read from the DOM when needed

Not in a loop

ngAnimate

Disabled ngAnimate

(failed to disable ngAnimate locally - Angular 1.3?)

DOM Bloat

/* in guLazyTableCell directive */

return {
  restrict: 'A',
  require: '^guLazyTable',
  transclude: true,
  template: '<ng-transclude ng:if="isCellVisible"></ng-transclude>',
  link: function( // ...

Strip unnecessary nodes from DOM tree

/* in guLazyTableCell directive */

const position$ = ctrl.getCellPosition$(item);
subscribe$(scope, position$,
           ({top, left, width, height, display}) => {

        element.css({
            position: 'absolute',
            top: top + 'px',
            // ... etc ...
        });

});
/* in guLazyTableCell directive */

const position$ = ctrl.getCellPosition$(item);
subscribe$(scope, position$,
           ({top, left, width, height, display}) => {
    scope.$applyAsync(() => { // <- async!
        element.css({
            position: 'absolute',
            top: top + 'px',
            // ... etc ...
        });
    });
});

Batch Cell positioning

/* in guLazyTableCell directive */

const position$ = ctrl.getCellPosition$(item);
subscribe$(scope, position$,
           ({top, left, width, height, display}) => {
    scope.$apply(() => {
        element.css({
            position: 'absolute',
            top: top + 'px',
            // ... etc ...
        });
    });
});

Batch DOM operations in next cycle

Summary

The DOM isn’t that slow

Reactive Programming FTW

Asynchronous composition

Declarative syntax

(if you're careful)

Embrace the timeline

Use to identify performance bottlenecks

Powerful combinators

Questions?

Sébastien Cevey

@theefer

Infinite Scrolling with Angular and RxJS

By Sébastien Cevey

Infinite Scrolling with Angular and RxJS

  • 11,586