Skip to content

Infinite Scrolling with Ember.js

by cory on July 28th, 2013
Let's implement a simple infinite scrolling pattern using Ember.
First, we will start with a basic scaffolding app. Let's load some fake models in the index route. Our `App.IndexRoute` will look like this:
App.IndexRoute = Ember.Route.extend({
  model: function(){
    var items = Em.A([]);
    for (var i = 0; i < 10; i++) {
      items.pushObject({name: ''+i});
    }
    return items;
  }
});

This code implements the `model` hook on the Index Route to return an array of 10 items that look like `[{name:'1'},{name:'2'}...]`. It's not too exciting but it's enough for us to start with. The return value from the model hook will be set as the `content` property on the `App.IndexController`.
We'll make sure our index controller is an array controller, since it will be showing a list of items (those returned from the route's `model` hook). `App.IndexController` looks like:
App.IndexController = Ember.ArrayController.extend({});

We now have enough scaffolding to write up some simple templates to display this information. We'll create an 'application' template with an outlet, and an 'index' template that shows each object in the index controller's content:
<script data-template-name="application" type="text/x-handlebars">
{{outlet}}
</script>

<script data-template-name="index" type="text/x-handlebars">
<h2>Widgets ({{content.length}})</h2>
<ul>
  {{#each widget in controller}}
    <li>Widget: {{widget.name}}</li>
  {{/each}}
</ul>
</script>
If we fire this up in the browser now, we see a header "Widgets (10)" and a list of widgets: Widget: 1, Widget: 2, etc.

Before we add the ability to automatically pull in content when the user scrolls, we'll add in a link the user can click to load more widgets manually. When that is working we'll figure out how to automatically trigger that code when the user scrolls to the bottom.

Next, add a link to the bottom of the 'index' template. Because we want to avoid double-loading new data, we'll add a boolean 'isLoading' property to the `App.IndexController` that we can also use to both show a 'loading...' message in the browser. We'll add that link to the bottom of the 'index' template:
{{#if loadingMore}}
  Loading more...
{{else}}
  <a href='#' {{action 'getMore'}}>Get More...</a>
{{/if}}
So we'll need to add the `getMore` action to the `App.IndexController`. This will pull in new content and push it onto the end of the 'content' array property on the controller. We could add the widget-finding code directly into the `IndexController`, but in a case like this I like to use the `events` hash on the route to load in the new data and just use the controller to direct this action and update its own properties that change what we see in the GUI (like the `isLoading` property). As I said, there's no reason we couldn't also do the loading of new data (widgets) on the controller, but I like adding it to the route because the other data-loading method (the `model` hook) is already there and this way we can keep all the code that loads external data (eventually -- right now we are only creating fake objects, of course) together.

We also will need to add `page` and `perPage` properties to the IndexController so that it can keep track of what new data to load each time. Here are the changes:
/* Add `getMore` action to App.IndexController */

// add properties
page: 1,
perPage: 10,

getMore: function() {
  // don't load new data if we already are
  if (this.get('loadingMore')) return;

  this.set('loadingMore', true);

  // pass this action up the chain to the events hash on the route
  this.get('target').send('getMore');
},

// Also add a method `gotMore` that the route can call back to
// notify the controller that the new data is in and it can stop
// showing its loading indicator
gotMore: function(items, page) {
  this.set('loadingMore', false);
  this.set('page', page);

  this.pushObjects(items);
}

/* Add `getMore` to the events hash on the App.IndexRoute */
// ...
events: {
  getMore: function() {
    var controller = this.get('controller'),
        nextPage = controller.get('page') + 1,
        perPage = controller.get('perPage'),
        items;

    items = this.events.fetchPage(nextPage, perPage);

    // alert the controller to the new data
    controller.gotMore(items, nextPage);
  },
  // load another page's worth of fake widgets
  fetchPage: function(page, perPage) {
    var items = Em.A([]);
    var firstIndex = (page-1) * perPage;
    var lastIndex  = page * perPage;
    for (var i = firstIndex; i < lastIndex; i++) {
      items.pushObject({name:''+i});
    }

    return items;
  }
}
Try it out in this jsbin demo. Clicking the 'get more' link loads another 10 fake widgets each time. Great! Last step is to do this automatically when the user scrolls to the bottom.

How do we determine that the user has scrolled to the bottom? jQuery has some helpful functions for us here. `$(document).height()` gives the height of the entire document, and `$(window).height()` gives the height of the browser viewport. The difference of these two integers is the distance to the top of the viewport, which happens to be exactly what `$(document).scrollTop()` returns. When those quantities are equal, we know the user has scrolled to the bottom. We will add a listener to the window's "scroll" event to determine when the user has scrolled, so that we can check whether to load new content. We'll add `didScroll` and `isScrolledToBottom` methods to `App.IndexView`:
// App.IndexView
// ...

// this is called every time we scroll
didScroll: function(){
  if (this.isScrolledToBottom()) {
    this.get('controller').send('getMore');
  }
},

// we check if we are at the bottom of the page
isScrolledToBottom: function(){
  var distanceToViewportTop = (
    $(document).height() - $(window).height());
  var viewPortTop = $(document).scrollTop();

  if (viewPortTop === 0) {
    // if we are at the top of the page, don't do
    // the infinite scroll thing
    return false;
  }

  return (viewPortTop - distanceToViewportTop === 0);
}

And now we'll use Ember's `didInsertElement` and `willDestroyElement` to add and remove the scroll listener, respectively:
// App.IndexView
didInsertElement: function(){
  // we want to make sure 'this' inside `didScroll` refers
  // to the IndexView, so we use jquery's `proxy` method to bind it
  $(window).on('scroll', $.proxy(this.didScroll, this));
},
willDestroyElement: function(){
  // have to use the same argument to `off` that we did to `on`
  $(window).off('scroll', $.proxy(this.didScroll, this));
}

Note that it's important that the second argument we give to `$(window).off` is the same function we gave to `$(window).on`, otherwise the 'off' call will remove all scroll listeners on the window, which is not what we want. Luckily, jQuery's `proxy` method is smart enough that it will return the same method when we call it with the same function name. See jQuery's documentation for more on `.off()`, though, because in other cases it may be safer to use an explicitly named, bound function instead.

Now we have a fully working, end-to-end infinite scrolling solution! You can see a jsbin demo of it here, where I've added a slight delay to the loading of new data to simulate it coming from an API with some latency.

If you would like to use this code in your own project, I have extracted all the infinite-scrolling-specific bits to a series of mixins that are available on github here LINK, along with instructions for mixing them into your project's route, controller and view.

From → general

No comments yet

Leave a Reply

Note: XHTML is allowed. Your email address will never be published.

Subscribe to this comment feed via RSS