Custom Events for Scrolling Towards Margins

There is an emerging UI pattern, where additional content is loaded when the user scrolls to the bottom of an element or page. This can be seen on the Facebook newsfeed, or many applications on touch-driven mobile devices. Today's article describes a YUI 3 widget that we use on Votizen.com, which will fire an event when the user scrolls to the border of an element.

Getting ready

Here is the complete source code (ScrollAction.js):

YUI.add('scroll_actioner', function(Y) {
  var BOUNDING_BOX = 'boundingBox',
  VIEWPORT_REGION = 'viewportRegion',

  Lang = Y.Lang,

  ScrollActioner = Y.Base.create('scroll_actioner', Y.Widget, [], {

    _lastScrollOffset: null,
    _lastScrollRegion: null,
    _scrollHandle: null,

    activate: function() {
      var that = this;

      if (that.get('rendered')) {
        if (! that._scrollHandle) {
          that.bindUI();
        }
      }
      else {
        that.render();
      }
    },

    _handleScroll: function() {
      var that = this,
      elBb = that.get(BOUNDING_BOX),
      aCurrScrollOffset = that.getScrollOffset(),
      aLastScrollOffset = that._lastScrollOffset,
      oRegion = elBb.get('region'),
      oVieportRegion = elBb.get(VIEWPORT_REGION),

      isScrollingDown = aLastScrollOffset[1] < aCurrScrollOffset[1],
      isScrollingLeft = aLastScrollOffset[0] > aCurrScrollOffset[0],
      isScrollingRight = aLastScrollOffset[0] < aCurrScrollOffset[0],
      isScrollingUp = aLastScrollOffset[1] > aCurrScrollOffset[1],

      iBottomScrollTrigger = oRegion.bottom - that.get('bottomMargin'),
      iLeftScrollTrigger = oRegion.left + that.get('leftMargin'),
      iRightScrollTrigger = oRegion.right - that.get('rightMargin'),
      iTopScrollTrigger = oRegion.top + that.get('topMargin');

      Y.log('isScrolling (Down|Left|Right|Up)=(' + isScrollingDown + '|' + isScrollingLeft + '|' + isScrollingRight + '|' + isScrollingUp + ')');
      Y.log('scrollTrigger (Down|Left|Right|Up)=(' + iBottomScrollTrigger + '|' + iLeftScrollTrigger + '|' + iRightScrollTrigger + '|' + iTopScrollTrigger + ')');

      if (isScrollingDown && iBottomScrollTrigger < oVieportRegion.bottom) {
        Y.log('Firing ScrollActioner bottom');
        that.fire(ScrollActioner.EVT_BOTTOM_SCROLL, oRegion, oVieportRegion);
      }

      if (isScrollingLeft && iLeftScrollTrigger > oVieportRegion.left) {
        Y.log('Firing ScrollActioner left');
        that.fire(ScrollActioner.EVT_LEFT_SCROLL, oRegion, oVieportRegion);
      }

      if (isScrollingRight && iRightScrollTrigger < oVieportRegion.right) {
        Y.log('Firing ScrollActioner right');
        that.fire(ScrollActioner.EVT_RIGHT_SCROLL, oRegion, oVieportRegion);
      }

      if (isScrollingUp && iTopScrollTrigger > oVieportRegion.top) {
        Y.log('Firing ScrollActioner top');
        that.fire(ScrollActioner.EVT_TOP_SCROLL, oRegion, oVieportRegion);
      }

      that._lastScrollOffset = aCurrScrollOffset;
    },

    deactivate: function() {
      if (this._scrollHandle) {
        this._scrollHandle.detach();
        this._scrollHandle = null;
      }
    },

    bindUI: function() {
      var that = this,
      elDoc = that.get(BOUNDING_BOX).get('ownerDocument');

      that._scrollHandle = elDoc.on('scroll', Y.bind(that._handleScroll, that));
    },

    destructor: function() {
      this.deactivate();
    },

    getScrollOffset: function() {
      var elBb = this.get(BOUNDING_BOX);
      return [elBb.get('docScrollX'), elBb.get('docScrollY')];
    },

    initializer: function() {
      var that = this,
      elBb = that.get(BOUNDING_BOX);

      that._scrollHandle = null;
      that._lastScrollOffset = that.getScrollOffset();
      that._lastScrollRegion = elBb.get(VIEWPORT_REGION);

      if (that.get('activate')) {
        that.activate();
      }
    }
  }, {
    EVT_BOTTOM_SCROLL: 'bottom_scroll',
    EVT_LEFT_SCROLL: 'left_scroll',
    EVT_RIGHT_SCROLL: 'right_scroll',
    EVT_TOP_SCROLL: 'top_scroll',

    ATTRS: {
      activate: {
        validator: Lang.isBoolean,
        value: true
      },

      bottomMargin: {
        validator: Lang.isNumber,
        value: 200
      },

      leftMargin: {
        validator: Lang.isNumber,
        value: 200
      },

      rightMargin: {
        validator: Lang.isNumber,
        value: 200
      },

      topMargin: {
        validator: Lang.isNumber,
        value: 200
      }
    }
  });

  Y.ScrollActioner = ScrollActioner;
}, '', {requires: ['base','widget', 'node']});

How to do it…

To use, include modules list, instantiate it on an element (can be document), render, and then subscribe to events:

YUI({
	modules: {
		'scroll_actioner': {
			fullpath: 'pathToScrollActioner/ScrollActioner.js',
			requires: ['base','widget', 'node']
		}
	}
}).use('event', 'scroll_actioner', function(Y) {
	var oScrollActioner = new Y.ScrollActioner({boundingBox: document}),

	oScrollActioner.on(Y.ScrollActioner.EVT_BOTTOM_SCROLL, function() {
		// turn off until DOM update is complete (usually AJAX request or loading new elements)
		oScrollActioner.deactivate();

		// slight delay for effect
		setTimeout(function() {
			// do something to the DOM

			// turn on when DOM response is complete
			oScrollActioner.activate();
		}, 1000);
	});

	oScrollActioner.on(Y.ScrollActioner.EVT_LEFT_SCROLL, function() {/*...*/});

	oScrollActioner.on(Y.ScrollActioner.EVT_RIGHT_SCROLL, function() {/*...*/});

	oScrollActioner.on(Y.ScrollActioner.EVT_TOP_SCROLL, function() {/*...*/});
});

How it works…

The work is done in the _handleScroll function, which is the callback for the document scroll event. The function does two things, determines a region and a scrolling direction when events should fire. The region is when the user has scrolled near the boundary of the boundingBox, and is cacluated by comparing the viewport region to the boundingBox region, minus a margin. The margin defaults to 200 pixels (so the event will fire when the user scrolls within 200 pixels of the boundary), but is a configurable attribute of the ScrollActioner. Secondly, it determines the direction of the scroll, by looking at the previous scroll offset and comparing it to the current scroll offset. Combining both calculations the ScrollActioner can fire an event when the user scrolls close to the region boundary of the boundingBox, but only if they are scrolling towards the boundary (ie. it won't fire the EVT_BOTTOM_SCROLL when you are at the bottom and scrolling up, left, or right.)

Here is a Test Page where you can see the events firing around the document border. The bottom of the page grows each time the ScrollActioner fires the EVT_BOTTOM_SCROLL event.