Custom Scroller with 'Snap Points' using jQuery and jScrollPane

June 22, 2012 by jason

The Problem

A customer requested a custom content scroller that would showcase a grid of products on a single page. Unlike a typical scroller, they wanted horizontal only scrolling and the addition of "prev" and "next" links to advance the scroller one product column at a time. The challenge came from a user interface constraint that I imposed myself: I wanted to add "snap points" to the scroller.  What do I mean by that? The content is arranged into columns of distinct product regions, and I have deliberately styled the scroller and the products so that exactly 3 columns will fit perfectly within the visible region. Any standard scroller will allow the user to advance the scroll region to an arbitrary position and leave it there. I want to ensure that at any time the scroller content is aligned within the viewer without any content partially cut-off on the edges.

The Details

After searching through the different jQuery scrollers available, I could not find anything that has this functionality out of the box. So next I had to think through the logic of how to do this with some custom javascript.

First, I need to determine the positions of the "snap points." In my case, the columns are even width so they can be pre-calculated positions along the scrollable region.  (i.e. the snap points are located at "0, 1 column width, 2 column widths, ... etc.")

Now, when the user releases the scrollbar, look-up the position of the scrollable content and update the position to the nearest "snap point".

In order to follow the proposed flow, the scroller needs to meet the following constraints:

  • I must be able to determine the arbitrary position of the scroller at any time.
  • The scroller must have an event available to activate whenever the scroller is released.
  • The scroller must be able to be position controlled via javascript.

I decided to move forward with jScrollPane because of its API and excellent documentation.  It has all of these abilities and more.

The Solution

In addition to just updating the position to the nearest "snap point," I wanted the scroller to feel organic and natural so I implemented smooth animations with easing to give the scroller the feel that it was sliding along a smooth track.  This includes the "prev" and "next" buttons, who also reposition the scrollable region by increments of the column widths.

I developed this within the context of a Drupal website, but that is not a requirement.  The following scripts are necessary for this scroller to function:

jQuery
jScrollPane
jQuery Mousewheel Plugin (optional) // Adds mouse scroll functionality
jQuery UI Easing Effects (optional) // Only needed for custom easing functions

Here is the mark-up for the product columns within the scroller:

<div class="product-scroller">
  <div class="product-wrapper">
    <div class="column column-1">
      <div class="prod-1 top-prod">
        <!-- Insert product code here --> 
      </div>
      <div class="prod-2 bottom-prod">
        <!-- Insert product code here -->
      </div>
    </div>
    <div class="column column-2">
      <div class="prod-3 top-prod">
        <!-- Insert product code here -->
      </div>
      <div class="prod-4 bottom-prod">
        <!-- Insert product code here -->
      </div>
    </div>
    ...
    <div class="column column-x column-last">
      <div class="prod-y top-prod">
        <!-- Insert product code here -->
      </div>
      <div class="prod-z bottom-prod">
        <!-- Insert product code here -->
      </div>
    </div>
  </div>
</div>

Here is the custom javascript:

if (jQuery('.column').length) {
  // Define necessary variables
  var cols = jQuery('.column').length,
      width = jQuery('.column-1').width(),
      total = jQuery('.views-row').length,
      scrolltimer = null,
      scrolling = false;
  // Apply the total width to the scrollable area
  jQuery('.product-wrapper').css({'width': (cols-1)*width+jQuery('.column-last').width()});

  // Define the scroller object
  var pane = jQuery('.product-scroller');

  // Define the jScrollPane object
  pane.jScrollPane({
      showArrows: false,
      animateScroll: true,
      trackClickSpeed: 1,
      clickOnTrack: false,
      animateEase: 'easeOutExpo',
      animateDuration: 500
    })
    // Define function for any horizontal scroll change
    .bind( 'jsp-scroll-x', function(event, scrollPositionX, isAtLeft, isAtRight) {
      // add function to update and show products "x-y of z"
      var x = Math.round(scrollPositionX/width)*2+1,
          y = (Math.round(scrollPositionX/width)*2+6) > total ? total : (Math.round(scrollPositionX/width)*2+6);
      jQuery('#pager').html(x+' - '+y+' of '+total);
  
      // add function add disabled class to scroll buttons if at left or right
      if(isAtLeft || isAtRight) {
        if(isAtLeft) {
          jQuery('#scroll-left')
            .stop(true,true)
            .fadeTo('slow', 0.3, function() {
              jQuery(this).addClass('disabled');
            });
        }
        if(isAtRight) {
          jQuery('#scroll-right')
            .stop(true,true)
            .fadeTo('slow', 0.3, function() {
              jQuery(this).addClass('disabled');
            });
        }
      } else {
        jQuery('#scroll-right, #scroll-left')
          .not('.hovered')
          .stop(true,true)
          .fadeTo('slow', 0.8, function() {
            jQuery(this).removeClass('disabled');
          });
      }
    });
  // Define the jScrollPane api
  var api = pane.data('jsp');
  
  // Define Pager and add "Next" and "Prev" buttons if applicable
  if(api.getIsScrollableH() && !jQuery('#pager').length) {

    // Add markup for the prev & next buttons and the product counter
    jQuery('.view-products')
      .append('<div id="scroll-left"></div>
               <div id="scroll-right"></div>
               <div id="pager">1 - '+((6 > total ? total : 6)+' of '+total)+'</div>');

    // Add "next" functionality (animate position from snap point to snap point
    // after checking to make sure it isn't currently animating
    jQuery('#scroll-right')
      .click(function() {
        var x = api.getContentPositionX()
        if(!scrolling && x%width != 0) {
          slideToSnap(true);
          api.scrollByX(width);
          scrolling = true;
          scrolltimer = setTimeout(function(){scrolling = false;}, 500);
        }
        else if(!scrolling) {
          api.scrollByX(width);
          scrolling = true;
          scrolltimer = setTimeout(function(){scrolling = false;}, 500);
        }
        return false;
      })
      .hover(function() {
        jQuery(this)
          .addClass('hovered')
          .not('.disabled')
          .stop(true,true)
          .fadeTo('fast',1.0);
      }, function() {
        jQuery(this)
          .removeClass('hovered')
          .not('.disabled')
          .stop(true,true)
          .fadeTo('fast',0.8);
      });
    jQuery('#scroll-left')
      .click(function() {
        var x = api.getContentPositionX()
        if(!scrolling && x%width != 0) {
          slideToSnap(true);
          api.scrollByX(-width);
          scrolling = true;
          scrolltimer = setTimeout(function(){scrolling = false;}, 500);
        }
        else if(!scrolling) {
          api.scrollByX(-width);
          scrolling = true;
          scrolltimer = setTimeout(function(){scrolling = false;}, 500);
        }
        return false;
      })
      .hover(function() {
        jQuery(this)
          .addClass('hovered')
          .not('.disabled')
          .stop(true,true)
          .fadeTo('fast',1.0);
      }, function() {
        jQuery(this)
          .removeClass('hovered')
          .not('.disabled')
          .stop(true,true)
          .fadeTo('fast',0.8);
      })
      // Disable the button on load
      .fadeTo(0, 0.3, function() { jQuery(this).addClass('disabled'); });
  }
 
  // Add slide to snap on drag
  jQuery('.jspDrag').mousedown(function(e) {
    jQuery(document).one('mouseup', function() {
      slideToSnap();
      jQuery(document).unbind();
    });
    return false;
  });
  
  // Define Slide to Snap action
  function slideToSnap(quick) {
    // determine nearest snap point (column number)
    var snap = Math.round(api.getContentPositionX()/width);
    // if quick do not animate, jump direct
    if (quick) {
      api.scrollToX(parseInt(snap*width), false);   
    }
    // else animate the scroller to the snap point
    else {
      api.scrollToX(parseInt(snap*width));  
    }
  }
}

I've highlighted the necessary portions of the javascript to stand out.  The rest of it is for adding the next & prev buttons and the product counter and adding their functionality to dynamically update.  The last portion is the css necessary to make this function:

#scroll-left, #scroll-right {
  position: absolute;
  cursor: pointer;
  opacity: 0.8;
  filter: alpha(opacity=80);
}
#scroll-left {
  left: 30px;
}
#scroll-right {
  right: 30px;
}
#scroll-left.disabled, #scroll-right.disabled, #scroll-left.disabled:hover, #scroll-right.disabled:hover {
  cursor: default;
}
#pager {
  position:absolute;
  bottom: 0;
  right: 0;
}
.product-scroller {
  height: auto;
  max-height: 454px;
  overflow: auto;
  width: 979px;
}
.product-wrapper {
  overflow: hidden;
}
.column {
  float:left;
}
.column > div {
  padding-right: 37px;
}
.column-last > div {
  padding-right: 5px;
}
.top-prod {
  padding-bottom:20px;
}
.bottom-prod {
  padding-bottom:34px;
}

It would be fairly simple to adapt this solution for a set of content that is not uniform, by changing the logic of calculating the snap points to use the jQuery position() function to find the nearest column starting point.  Since my application was known to be uniform, I went with the easiest solution to implement.

Demonstration

To see this "Snap Slider" in action check out the Inge Christopher website.

Comments

great work, dude

Yes, very well done.