Skip to content

Commit

Permalink
Use one IntersectionObserver per viewport (#153)
Browse files Browse the repository at this point in the history
  • Loading branch information
snewcomer authored Jul 27, 2018
1 parent 8cc2138 commit 2bc20d1
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 39 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@

This `ember-cli` addon adds a simple, highly performant Ember Mixin to your app. This Mixin, when added to a `View` or `Component` (collectively referred to as `Components`), will allow you to check if that `Component` has entered the browser's viewport. By default, the Mixin uses the `IntersectionObserver` API if it detects it in your user's browser – failing which, it fallsback to using `requestAnimationFrame`, then if not available, the Ember run loop and event listeners.

## Demo
- App: http://development.ember-in-viewport-demo.divshot.io/
## Demo or examples
- Source: https://github.com/poteto/ember-in-viewport-demo
- [ember-infinity](https://github.com/ember-infinity/ember-infinity)
- [ember-light-table](https://github.com/offirgolan/ember-light-table)

# Installation

Expand Down
89 changes: 56 additions & 33 deletions addon/mixins/in-viewport.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { assign } from '@ember/polyfills';
import Mixin from '@ember/object/mixin';
import { typeOf } from '@ember/utils';
import { assert } from '@ember/debug';
import { inject as service } from '@ember/service';
import { inject } from '@ember/service';
import { set, get, setProperties } from '@ember/object';
import { next, bind, debounce, scheduleOnce } from '@ember/runloop';
import { not } from '@ember/object/computed';
Expand Down Expand Up @@ -34,6 +34,12 @@ export default Mixin.create({
*/
_debouncedEventHandler: null,

/**
* @property _observerOptions
* @default null
*/
_observerOptions: null,

/**
* unbinding listeners will short circuit rAF
*
Expand All @@ -42,7 +48,8 @@ export default Mixin.create({
*/
_stopListening: false,

rAFPoolManager: service('-in-viewport'),
_observerAdmin: inject('-observer-admin'),
_rAFAdmin: inject('-raf-admin'),

/**
* @property viewportExited
Expand Down Expand Up @@ -84,6 +91,7 @@ export default Mixin.create({

willDestroyElement() {
this._super(...arguments);

this._unbindListeners();
},

Expand Down Expand Up @@ -131,7 +139,7 @@ export default Mixin.create({
* @method _setupIntersectionObserver
*/
_setupIntersectionObserver() {
const scrollableArea = get(this, 'scrollableArea') ? document.querySelector(get(this, 'scrollableArea')) : null;
const scrollableArea = get(this, 'scrollableArea') ? document.querySelector(get(this, 'scrollableArea')) : undefined;

const element = get(this, 'element');
if (!element) {
Expand All @@ -141,14 +149,13 @@ export default Mixin.create({
// https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
// IntersectionObserver takes either a Document Element or null for `root`
const { top = 0, left = 0, bottom = 0, right = 0 } = this.viewportTolerance;
const options = {
this._observerOptions = {
root: scrollableArea,
rootMargin: `${top}px ${right}px ${bottom}px ${left}px`,
threshold: get(this, 'intersectionThreshold')
};

this.intersectionObserver = new IntersectionObserver(bind(this, this._onIntersection), options);
this.intersectionObserver.observe(element);
get(this, '_observerAdmin').add(element, bind(this, this._onEnterIntersection), bind(this, this._onExitIntersection), this._observerOptions);
},

/**
Expand All @@ -158,7 +165,7 @@ export default Mixin.create({
* @method _setViewportEntered
*/
_setViewportEntered() {
const scrollableArea = get(this, 'scrollableArea') ? document.querySelector(get(this, 'scrollableArea')) : null;
const scrollableArea = get(this, 'scrollableArea') ? document.querySelector(get(this, 'scrollableArea')) : undefined;

const element = get(this, 'element');
if (!element) {
Expand All @@ -181,7 +188,7 @@ export default Mixin.create({

if (get(this, 'viewportUseRAF') && !get(this, '_stopListening')) {
let elementId = get(this, 'elementId');
rAFIDS[elementId] = get(this, 'rAFPoolManager').add(
rAFIDS[elementId] = get(this, '_rAFAdmin').add(
elementId,
bind(this, this._setViewportEntered)
);
Expand All @@ -190,27 +197,34 @@ export default Mixin.create({
},

/**
* callback provided to IntersectionObserver
* Callback provided to IntersectionObserver
* trigger didEnterViewport callback
*
* @method _onIntersection
* @param {Array} - entries
* @method _onEnterIntersection
*/
_onIntersection(entries) {
_onEnterIntersection() {
const isTearingDown = this.isDestroyed || this.isDestroying;
const [entry] = entries;
let { isIntersecting, intersectionRatio } = entry;

if (isIntersecting) {
if (!isTearingDown) {
set(this, 'viewportEntered', true);
}
this.trigger('didEnterViewport');
} else if (intersectionRatio <= 0) { // exiting viewport
if (!isTearingDown) {
set(this, 'viewportEntered', false);
}
this.trigger('didExitViewport');
if (!isTearingDown) {
set(this, 'viewportEntered', true);
}

this.trigger('didEnterViewport');
},

/**
* trigger didExitViewport callback
*
* @method _onExitIntersection
*/
_onExitIntersection() {
const isTearingDown = this.isDestroyed || this.isDestroying;

if (!isTearingDown) {
set(this, 'viewportEntered', false);
}

this.trigger('didExitViewport');
},

/**
Expand Down Expand Up @@ -266,8 +280,13 @@ export default Mixin.create({
this.trigger(triggeredEventName);
},

/**
* Unbind when enter viewport only if viewportSpy is false
*
* @method _unbindIfEntered
*/
_unbindIfEntered() {
if (!get(this, 'viewportSpy') && get(this, 'viewportEntered')) {
if (get(this, 'viewportEntered')) {
this._unbindListeners();
this.removeObserver('viewportEntered', this, this._unbindIfEntered);
set(this, 'viewportEntered', false);
Expand Down Expand Up @@ -325,29 +344,33 @@ export default Mixin.create({
},

/**
* Remove listeners for rAF or scroll event listeners
* Either from component destroy or viewport entered and
* need to turn off listening
*
* @method _unbindListeners
*/
_unbindListeners() {
set(this, '_stopListening', true);

// 1.
if (this.intersectionObserver) {
this.intersectionObserver.unobserve(this.element);
// if IntersectionObserver
if (get(this, 'viewportUseIntersectionObserver')) {
get(this, '_observerAdmin').unobserve(this.element, get(this, '_observerOptions.root'));
}

// 2.
// if rAF
if (!get(this, 'viewportUseIntersectionObserver') && get(this, 'viewportUseRAF')) {
const elementId = get(this, 'elementId');

next(this, () => {
let rAFPoolManager = get(this, 'rAFPoolManager');
rAFPoolManager.remove(elementId);
rAFPoolManager.cancel();
let _rAFAdmin = get(this, '_rAFAdmin');
_rAFAdmin.remove(elementId);
_rAFAdmin.cancel();
delete rAFIDS[elementId];
});
}

// 3.
// if scroll event listeners
if (!get(this, 'viewportUseIntersectionObserver') && !get(this, 'viewportUseRAF')) {
get(this, 'viewportListeners').forEach((listener) => {
let { context, event } = listener;
Expand Down
107 changes: 107 additions & 0 deletions addon/services/-observer-admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Service from '@ember/service';
import { bind } from '@ember/runloop';

// WeakMap { root: { elements: [{ element, enterCallback, exitCallback }], IntersectionObserver } }
let DOMRef = new WeakMap();

/**
* Static administrator to ensure use one IntersectionObserver per viewport
* Use `root` (viewport) as lookup property
* `root` will have one IntersectionObserver with many entries (elements) to watch
* provided callback will ensure consumer of this service is able to react to enter or exit
* of intersection observer
*
* @module Ember.Service
* @class ObserverAdmin
*/
export default class ObserverAdmin extends Service {
/**
* adds element to observe entries of IntersectionObserver
*
* @method add
* @param {Node} element
* @param {Function} enterCallback
* @param {Function} exitCallback
* @param {Object} options
*/
add(element, enterCallback, exitCallback, options) {
let { root = window } = options;
let { elements, intersectionObserver } = this._findRoot(root);

if (elements && elements.length > 0) {
elements.push({ element, enterCallback, exitCallback });
intersectionObserver.observe(element, options);
} else {
let newIO = new IntersectionObserver(bind(this, this._setupOnIntersection(root)), options);
newIO.observe(element);
DOMRef.set(root, { elements: [{ element, enterCallback, exitCallback }], intersectionObserver: newIO });
}
}

/**
* @method unobserve
* @param {Node} element
* @param {Node|window} root
*/
unobserve(element, root) {
let { intersectionObserver } = this._findRoot(root);
if (intersectionObserver) {
intersectionObserver.unobserve(element);
}
}

/**
* to unobserver multiple elements
*
* @method disconnect
* @param {Node|window} root
*/
disconnect(root) {
let { intersectionObserver } = this._findRoot(root);
if (intersectionObserver) {
intersectionObserver.disconnect();
}
}

_setupOnIntersection(root) {
return function(entries) {
return this._onAdminIntersection(root, entries);
}
}

_onAdminIntersection(root, ioEntries) {
ioEntries.forEach((entry) => {

let { isIntersecting, intersectionRatio } = entry;

// first determine if entry intersecting
if (isIntersecting) {
// then find entry's callback in static administration
let { elements = [] } = this._findRoot(root);

elements.some(({ element, enterCallback }) => {
if (element === entry.target) {
// call entry's enter callback
enterCallback();
return true;
}
});
} else if (intersectionRatio <= 0) { // exiting viewport
// then find entry's callback in static administration
let { elements = [] } = this._findRoot(root);

elements.some(({ element, exitCallback }) => {
if (element === entry.target) {
// call entry's exit callback
exitCallback();
return true;
}
});
}
});
}

_findRoot(root) {
return DOMRef.get(root) || {};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import Service from '@ember/service';
* ensure use on requestAnimationFrame, no matter how many components
* on the page are using this mixin
*
* @class rAFPoolManager
* @class RAFAdmin
*/
export default class rAFPoolManager extends Service {
export default class RAFAdmin extends Service {
init(...args) {
super.init(...args);
this.pool = [];
Expand Down
1 change: 0 additions & 1 deletion app/services/-in-viewport.js

This file was deleted.

1 change: 1 addition & 0 deletions app/services/-observer-admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-in-viewport/services/-observer-admin';
1 change: 1 addition & 0 deletions app/services/-raf-admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-in-viewport/services/-raf-admin';
2 changes: 1 addition & 1 deletion tests/unit/mixins/in-viewport-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import EmberObject from '@ember/object';
import InViewportMixin from 'ember-in-viewport/mixins/in-viewport';
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import rAFPoolManager from 'ember-in-viewport/services/-in-viewport';
import rAFPoolManager from 'ember-in-viewport/services/-raf-admin';

class rAFMock extends rAFPoolManager {
flush() {}
Expand Down

0 comments on commit 2bc20d1

Please sign in to comment.