Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use one IntersectionObserver per viewport #153

Merged
merged 2 commits into from
Jul 27, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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