Web app pull down to refresh

A complete design and implementation for implementing a "pull down to refresh" feature in a web app

What it is

Pull down to refresh is a useful feature in a web app that displays dynamic content, allowing users to force a reload of the content.

A simple "swpie down" gesture on a touch screen (or a click and downwards drag with a mouse) is the usual action.

It is often (but not necessarily) used with lists of data that need to be scrolled up and down.

Our solution is based upon the functionality built into the BBC News app.

A fully working example

This example uses the code and styling that is described below to show pull-to-refresh working in practice.

For this example, our data retrieval function will simply return a random number of random numbers. Because that will complete very quickly, we will add in a delay before the data container gets updated.


Here is that function:

function getRandomInt(max)
{
  return Math.floor(Math.random() * Math.floor(max));
}

function dataRetrieve()
{
  //
  // set a state variable
  //
  refreshing = true;

  //
  // randomise the number of results
  // between 8 and 11
  //
  var numData = 8 + getRandomInt(3);

  var data = '';
  for(var i=0; i<numData; i++)
  {
    //
    // create numData list items with a random value
    // 
    data += '<li>'+getRandomInt(99)+'</li>';
  }
  
  setTimeout(function()
  {
    //
    // write the new list items into the data container
    //
    $('#dataContent ul').html(data);

    //
    // set a state variable
    //
    refreshing = false;
    
    //
    //hide the refresh feedback
    //
    hideRefresh();
  }, 1000);
}

User experience (UX) requirements

These are the key user requirements:

Functional requirements

From the user requirements we can start to consider how the refresh will work

Required assets

For this solution we will require

For convenience we will use jQuery to handle events, and manipulate styling.

The image element

Here is the image that we will use in this example - a very simple 270° arc:

refresh

The main benefit of using a circular form like this is that we can use very simple CSS transforms to rotate it:

refresh style="transform-origin: center center;
transform: rotate(30deg);"
refresh style="transform-origin: center center;
transform: rotate(60deg);"
refresh style="transform-origin: center center;
transform: rotate(90deg);"

As well as "manual" rotations, we can animate it:

refresh

@keyframes rotate
{
  0%   {transform: rotate(0deg);}
  100% {transform: rotate(360deg);}
}

height: 100%;
width: auto;
animation-name: rotate;
animation-timing-function: linear;
animation-duration: 1s;
animation-iteration-count: infinite;
transform-origin: center center;

The HTML structure

We need to mark up our HTML with a few elements to allow us to isolate the scrolling effect and to keep everything in place:

Page

#dataContainer

#spinner

refresh

#dataContent

  • Content item
  • Content item
  • Content item
  • Content item
  • Content item

Other content

The process

We can now define how the process will work in practice:


We just need to decide what constitutes a "large enough" movement.

Since our spinner image is 32px x 32px, we could say a 32px swipe is enough but in reality this is a too small.

Instead we will use a factor of 4 (32px * 4 = 128px). This is an arbitrary decision and you should use what works best for your case.

Our feedback strategy

We will use the spinner image to provide all feedback during each phase of the refresh.

When not refreshing, the spinner is not visible. We will achieve this by setting the height of the spinner div to be 0px.

When refreshing, the spinner is made visible by adjusting the height of the spinner div via jQuery. The height of the image will be set to 100% of the spinner div, and its width set to auto.

It will appear to float over the content already showing on the page.


While the touch/drag motion is not large enough, the height of the spinner div will be proprtional to the minimum required drag.

We will also manually rotate the spinner to give the idea of a refresh about to happen. It is important that the rotation continues even after the minimum drag has been achieved.


Here is the relevant default styling:

#dataContainer
{
  position: relative;
}
#spinner
{
  position: absolute;
  z-index: 10;
}
#spinner.hidden
{
  height: 0px;
}
#spinner img
{
  display: block;
  height: 100%;
  width: auto;
}
#spinner.hidden img
{
  display: none;
}

Here is the jQuery to adjust the height and rotation of the spinner:

function showRefresh(drag,minRefreshDrag)
{
  var factor = drag / minRefreshDrag;
  var rotation = 'rotate('+(360 * factor)+'deg)';
  var height = 32 * Math.min(1.0,factor); 

  $('#spinner').removeClass('hidden');
  $('#spinner').css('transform',rotation);
  $('#spinner').css('height',height+'px');
}
function hideRefresh()
{
  $('#spinner').addClass('hidden');
  $('#spinner').removeAttr("style")
}

Once the drag has finished (touch finishes or mouse button released) and a sufficient movement has occurred then we will trigger the refresh.

We don't know how long that will take to complete, so need to change the feedback into a constant spinning image while we wait. This is where the animation comes in:

@keyframes rotate
{
  0%   {transform: rotate(0deg);}
  100% {transform: rotate(360deg);}
}

.animateRotate
{
  animation-name: rotate;
  animation-timing-function: linear;
  animation-duration: 1s;
  animation-iteration-count: infinite;
  transform-origin: center center;
}

We will also declare some helper functions to switch the animation on and off:

function animateRefresh()
{
  $('#spinner').addClass('animateRotate');
}
function deanimateRefresh()
{
  $('#spinner').removeClass('animateRotate');
}

Listening for the touch and/or mouse events

The final piece of the solution is to react to touch and mouse events.


Firstly we need to add a listening function. For convenience we will use a single function to handle all of the events:

$('#dataContainer').on(
  'touchstart touchmove touchend
   mousedown  mousemove mouseup',
  onDataSwipeDown
);

Next some global state variables that we will access in each call to the event handler:

var userRefreshing = false;
var userRefreshY = 0;
var userMovedEnough = false;
var refreshing = false;

Now a utilty function to determine if the scrollable data is at the top:

function dataAtTop()
{
  var st = $('#dataContainer').scrollTop();
  return (st == 0);
}

Handling the events

Finally, here is the event handler function itself.

The logic is fully described in the code comments:

function onDataSwipeDown(evt)
{
  var minRefreshDrag = 32 * 4; //32px height

  if(!refreshing)
  {
    //
    // only accept the event if the data
    // is not already being refreshed
    //

    if((evt.type=="touchstart") || (evt.type=="mousedown"))
    {
      //
      // potentially starting a refresh action
      // but the data container must be scrolled
      // all the way up
      //

      if(dataAtTop())
      {
        //
        // correct state for the refresh action
        // so initialise the state variables
        //

        userRefreshing = true;
        userMovedEnough = false;

        if(evt.type=="touchstart")
        {
          //
          // n.b. we are only considering
          // a 1-finger touch case
          //

          userRefreshY = evt.originalEvent.touches[0].screenY;
        }
        else
        {
          userRefreshY = evt.originalEvent.screenY;
        }
      }
    }
    else
    {
      //
      // the event type is either a move or end
      // but the refresh action must already have
      // been triggered for it to be taken into account
      //

      if(userRefreshing)
      {
        if((evt.type=="touchmove") || (evt.type=="mousemove"))
        {
          //
          // in the move case we need to
          // 1. check that the movement is downwards
          // 2. show/update the feedback for the current movement
          // 3. check if the movement is large enough
          //

          var deltaY = 0;
          if(evt.type=="touchmove")
          {
            deltaY = evt.originalEvent.changedTouches[0].screenY  - userRefreshY;
          }
          else
          {
            deltaY = evt.originalEvent.screenY - userRefreshY;
          }

          if(deltaY > 0)
          {
            //
            // yes it is a downwards movement
            //

            showRefresh(deltaY,minRefreshDrag);

            if(deltaY >= minRefreshDrag)
            {
              //
              // the movement is greater than our required minimum to trigger the refresh
              //

              userMovedEnough = true;
            }
          }
          else
          {
            //
            // a downwards movement so cancel the refresh request
            //

            userRefreshing = false;
          }
        }
        else
        {
          //
          // touchend or mouseup
          //
          // at the end of the movement we simply check the movement has been big enough
          //

          if(userMovedEnough)
          {
            //
            // yes - go ahead and get fresh data
            //

            animateRefresh();
            dataRetrieve();
          }
          else
          {
            //
            // no - hide the feedback
            //

            deanimateRefresh();
            hideRefresh();
          }

          //
          // we also need to reset the user action state variable
          //
          
          userRefreshing = false;
        }
      }
    }
  }
}