diff --git a/README.md b/README.md index 4bb29e0f..a8153e39 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/addon/mixins/in-viewport.js b/addon/mixins/in-viewport.js index 4acc60ba..8393b1bf 100644 --- a/addon/mixins/in-viewport.js +++ b/addon/mixins/in-viewport.js @@ -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'; @@ -34,6 +34,12 @@ export default Mixin.create({ */ _debouncedEventHandler: null, + /** + * @property _observerOptions + * @default null + */ + _observerOptions: null, + /** * unbinding listeners will short circuit rAF * @@ -42,7 +48,8 @@ export default Mixin.create({ */ _stopListening: false, - rAFPoolManager: service('-in-viewport'), + _observerAdmin: inject('-observer-admin'), + _rAFAdmin: inject('-raf-admin'), /** * @property viewportExited @@ -84,6 +91,7 @@ export default Mixin.create({ willDestroyElement() { this._super(...arguments); + this._unbindListeners(); }, @@ -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) { @@ -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); }, /** @@ -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) { @@ -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) ); @@ -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'); }, /** @@ -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); @@ -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; diff --git a/addon/services/-observer-admin.js b/addon/services/-observer-admin.js new file mode 100644 index 00000000..23945bd2 --- /dev/null +++ b/addon/services/-observer-admin.js @@ -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) || {}; + } +} diff --git a/addon/services/-in-viewport.js b/addon/services/-raf-admin.js similarity index 92% rename from addon/services/-in-viewport.js rename to addon/services/-raf-admin.js index 08b70757..83caf722 100644 --- a/addon/services/-in-viewport.js +++ b/addon/services/-raf-admin.js @@ -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 = []; diff --git a/app/services/-in-viewport.js b/app/services/-in-viewport.js deleted file mode 100644 index d21615aa..00000000 --- a/app/services/-in-viewport.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-in-viewport/services/-in-viewport'; diff --git a/app/services/-observer-admin.js b/app/services/-observer-admin.js new file mode 100644 index 00000000..18fbb558 --- /dev/null +++ b/app/services/-observer-admin.js @@ -0,0 +1 @@ +export { default } from 'ember-in-viewport/services/-observer-admin'; diff --git a/app/services/-raf-admin.js b/app/services/-raf-admin.js new file mode 100644 index 00000000..13658029 --- /dev/null +++ b/app/services/-raf-admin.js @@ -0,0 +1 @@ +export { default } from 'ember-in-viewport/services/-raf-admin'; diff --git a/tests/unit/mixins/in-viewport-test.js b/tests/unit/mixins/in-viewport-test.js index 2a371ba8..984ed682 100644 --- a/tests/unit/mixins/in-viewport-test.js +++ b/tests/unit/mixins/in-viewport-test.js @@ -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() {}