Infinite Scrolling
with
Angular & RxJS
AngularJS London, 30 June 2015
Sébastien Cevey
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
Infinite Scrolling with Angular and RxJS
By Sébastien Cevey
Infinite Scrolling with Angular and RxJS
- 11,883