Blog

Infinite Ajax Carousel

August 10, 2010 | James Brantly

In a forthcoming demo app of a shopping browser, we'll be showcasing an "infinite carousel", a new feature of our carousel component that we'll be releasing as part of Sencha Touch. The major feature of the infinite carousel is active DOM management that keeps the web app within the iPad's memory limits. Here I'd like to walk you through some of the more interesting technical aspects that went into its creation. The biggest challenges revolved around the fact that in our shopping browser, a single carousel could contain literally thousands of images. Simply loading these all into memory at once was a guaranteed browser crash. So we needed to make sure that certain portions of the application were able to scale correctly.

Creating An Infinite Carousel

The infinite carousel can handle thousands of items. It does this by using a sliding window for objects it keeps in memory. For the currently displayed card, the immediate preceding and following cards are also created. As the user scrolls through, items are created and destroyed at each end as needed so that the window is always kept up-to-date. With this method, we can ensure that the number of created items is below some fixed constant regardless of how many items are actually in the carousel. In the diagram below, the sliding window size is 3 items. Initially it is centered on item 3 and only items 2 through 4 have actually been created. When the user changes the current item to 4, the window slides right. Item 2 is removed and item 5 is created and added to the carousel.

The API for using this carousel is pretty simple. Instead of using an items config property, there is a createItem config property which accepts a function. Whenever an item needs to be created, the function will be called with the appropriate index. The function then creates and returns the item. To demonstrate this I'll show two ways to accomplish the same thing: one with a normal carousel and one with our infinite carousel.
// Normal carousel
var carousel = new Ext.Carousel({
    items: [{
        html: '1'
    },{
        html: '2'
    }, {
        html: '3'
    }]
});
 
// Infinite carousel
var carousel = new SS.BufferedCarousel({
    itemCount: 3,
    createItem: function(index) {
        return {html: (i+1)+''};
    }
});
Conceptually, the sliding window is pretty simple. On initial load and whenever the visible item is changed, update the window. So the meat of the implementation is in the window update code. It is called with the index of the item that the window should be centered on. Here is the source code in full:
bufferCards: function(index) {
    // Quick return if there is nothing to do
    if (this.lastBufferedIndex == index) { return; }
    this.lastBufferedIndex = index;
 
    // Initialize variables
    var
        // size of the window
        bufferSize = this.bufferSize,
        // constrained start index of the window
        start = (index-bufferSize).constrain(0, this.itemCount-1),
        // constrained end index of the window
        end = (index+bufferSize).constrain(0, this.itemCount-1), 
        items = this.items,
        // flag to determine if any items were added/removed
        changed = false,
        // will be set to the item where its position == index
        activeCard;
 
    // make sure the index is within bounds
    index = index.constrain(0, this.itemCount-1);
 
    // cull existing items
    var i = 0;
    while (i < items.length) {
        var item = items.get(i),
            itemIndex = item.carouselPosition;
 
        if (itemIndex  end) {
            this.remove(item, true);
            changed = true;
        }
        else {
            i++;
        }
    }
 
    // function to create a card and add to the carousel
    var createCard = function(carouselPos, layoutPos) {
        var card = this.createItem(i);
        if (card) {
            card.carouselPosition = carouselPos;
            if (layoutPos != null) {
                this.insert(layoutPos, card);
            }
            else {
                this.add(card);
            }
            if (carouselPos == index) {
                activeCard = card;
            }
            changed = true;
        }
    };
 
    // add new items
    if (items.length) { // if existing items, add to the left and right
        var first = items.first().carouselPosition,
            last = items.last().carouselPosition;
        for (var i = first-1; i>=start; i--) {
            if (i >= 0) {
                createCard.call(this, i, 0);
            }
        }
 
        for (var i = last+1; i<=end; i++) {
            createCard.call(this, i);
        }
    }
    else { // if no existing items, just add cards
        for (var i = start; i= 0) {
                createCard.call(this, i);
            }
        }
    }
 
    // if changed, make sure the layout is updated
    // also, update the active item if needed
    if (changed) {
        this.doLayout();
 
        var activeItem = this.layout.getActiveItem();
        if (activeCard && activeItem != activeCard) {
            this.layout.setActiveItem(activeCard);
        }
    }
}

Custom Carousel Indicator

In an infinite carousel the position indicator for a normal carousel does not work. To remedy this we created a position indicator component which looks more like a normal Touch scrollbar. Using the standard indicator as a template, the code is fairly straightforward:
SS.PagedCarousel.Indicator = Ext.extend(Ext.Component, {
    baseCls: "ss-pagedCarousel-indicator",
 
    initComponent: function() {
        if (this.carousel.rendered) {
            this.render(this.carousel.body);
        }
        else {
            this.carousel.on('render', function() {
                this.render(this.carousel.body);
            }, this, {single: true});
        }
    },
 
    onRender: function() {
        SS.PagedCarousel.Indicator.superclass.onRender.apply(this, arguments);
 
        this.positionIndicator = this.el.createChild({tag: 'span'});
    },
 
    onBeforeCardSwitch: function(carousel, card) {
        if (card) {
            var position = card.carouselPosition/(this.carousel.itemCount-1),
                position = isNaN(position) ? 0 : position*100,
                el = this.el;
 
            this.positionIndicator[this.carousel.direction=='vertical'?'setTop':'setLeft'](position.toFixed(2)+"%");
 
            el.setStyle('opacity', '1');
 
            if (this.hideTimeout != null) {
                clearTimeout(this.hideTimeout);
            }
 
            this.hideTimeout = setTimeout(function() {
                el.setStyle('opacity', '0');
            }, 1500);
        }
    },
 
});

Data Retrieval and Precaching

The backend for this demo provides an API that can be used to retrieve and filter products from categories. It also provides a way to page the data so that it can be retrieved in chunks as needed. In the app, however, we wanted to make sure that we always had the data we need as soon as we needed it. When rendering an item to the carousel, we didn't want to wait for network latency. This meant that we needed to precache images. So, we created a separate class called a DataCache to ease the process of retrieving and caching products. At its heart, DataCache contains a simple function called “getItems” which accepts a range of products and a callback function for handling the products once they are retrieved. In addition, “getItems” has some simple logic whereby calls for data that have already been requested do not trigger an additional request over the wire. And calls for data that has already been received returns the data immediately. The result is that handling precaching becomes extremely easy. All we need to do is call getItems for a chunk of products before the app needs to display those products and the rest is taken care of automatically. This process is best shown in a diagram:

When getItems is called for the first 100 items, a request is made to the backend for those items. When the subsequent calls to getItems for items 1-10 and 11-20 are made, the app detects that these have already been asked for and no request is made to the backend API. Once the response from the backend comes back, all outstanding callbacks depending on that data are invoked. If later on getItems is used for items 21-30, the callback is instantly invoked since the data has already been retrieved. Using the data cache greatly simplifies the efficient retrieval of data throughout the rest of the application.

Fun with XTemplate

When displaying products, each carousel page shows 8 or 9 (depending on orientation) products in a grid layout. Most of the time people use floating elements to provide a grid layout which will automatically stack side by side and wrap when necessary. However, in this case we really wanted to use CSS3 flexible box layout since we can take advantage of a modern webkit browser. To do this, we need to create wrapper elements within the HTML that act as containers around every 2 or 3 products, and we want to do this within a single loop. Here is the resulting code:
landscapeProductTpl: new Ext.XTemplate(
    '<div class="productlist-container">',
    '',
        '{[ xindex == 1 || xindex % 2 == 1 ? "<div>" : ""]}',
        '<div>',
            '<div class="productlist-item-img boxFlexible">{images:this.renderImage}</div>',
            '<div class="productlist-item-text">',
                '<span>{name}</span>',
                '<span>{price:this.renderPrice} - {retailer}</span>',
            '</div>',
        '</div>',
        '{[ xindex == xcount &amp;&amp; xindex % 2 == 1 ? "<div></div>" : ""]}',
        '{[ xindex == xcount || xindex % 2 == 0 ? "</div>" : ""]}',
    '',
    '</div>',
    templateFunctions
)
Note the use of inline Javascript to conditionally insert divs before and after each product. Through careful use of the xindex and xcount variables and some modulus magic, this template will create a wrapper div around every 2 products. This wrapper div uses the CSS3 vertical box functionality to stack the two products on top of each other. The end result is a 2x4 grid when 8 products are fed into the template.

The Power of Mobile Web

The infinite carousel demonstrates the power of Sencha Touch to create application experiences that meet the bar that's been set by native applications. And best of all, because it's web based, this should also run on other tablets like the expected HP Tablet based on WebOS and Android Tablets (once we add support). We hope to launch the shopping browser demo app soon. Then you'll be able to see the demo in action!

There are 23 responses. Add yours.

jeroenvduffelen

2 years ago

Cool! We are implementing this exact same feature - but with the DataView, for more flexibility of the scroll area!

Awesomee Bob

2 years ago

Cool beans. Glad to see that performance and functionality can still be achieved at the same time. Keep them coming!

Rafael Ferreira

2 years ago

We implemented exactly the same feature a 2 years ago.

Douglas Bonneville

2 years ago

Will it have inertia scrolling? For example, if you flick to the left or right, will the UI scroll with inertia and then let the images catch up with where the carousel is? Would it work essentially like the iTunes or OSX preview “cover flow”?

Tof

2 years ago

GNi ?!

xindex == 1 || xindex % 2 == 1

JeeShen Lee

2 years ago

Can’t wait to check out the DEMO! smile

Animal

2 years ago

Use XTemplate member functions to calculate the <divs> and keep the template a bit simpler.

Nasty code in Javascript not inside a template:

</code>
var t = new Ext.XTemplate(’<tpl for=”.”>’ +
’{[this.getTopTag(xindex)]}’ +
’{.}’ +
’{[this.getBottomTag(xindex, xcount)]}’ +
’</tpl>’,
{
getTopTag: function(idx){
return idx == 1 || idx % 2 == 1 ? “<div>” : “”;
},
getBottomTag: function(idx, xcount){
var result = idx == xcount && idx % 2 == 1 ? “<div></div>” : “”;
if (idx == xcount || idx % 2 == 0) {
result += ‘<div>’;
}
return result;
}
});
t.apply([1, 2, 3, 4, 5, 6]);
</code>

Jackson

2 years ago

Awesome! Thanks for the article..

Juergen

2 years ago

When do you expect to lunch this feature ?

SenchaUser

2 years ago

Any updates on the demo?

Michael Mullany

2 years ago

You can see the demo staged here:

www.touchstyle.mobi/app/

Enjoy!!

SenchaUser

2 years ago

I’m using the code from your example, just updated to new version (0.97) and it seems like this.doLayout(); is breaking. Any ideas how to fix it?

Marcel

2 years ago

can sencha touch carousel use multitouch
see here: touch.sproutcore.com

Blippo

2 years ago

Please could post an example for the iPhone I could not’m starting

ml

1 year ago

if (itemIndex end) {
        this.remove(item, true);
        changed = true;
      }
      else {
        i++;
      }
  }

should it say (if (itemIndex == end) ?

norabora

1 year ago

I found that your demo site (http://www.touchstyle.mobi/app/) uses old version of Sencha Touch. So when I use BufferedCarousel with 1.0, indicator is now showing. Are you planning to port this demo to Sencha Touch 1.0?

norabora

1 year ago

I forgot to copy css about indicator. Sorry about that. One question though, since it is ‘infinite’ carousel, how can I set itemCount? It will be increasing forever.

JackSparrow

1 year ago

//Try to this

SS.BufferedCarousel.Indicator = Ext.extend(Ext.Component, {
  baseCls: “ss-pagedCarousel-indicator”,
 
  initComponent: function() {
      if (this.carousel.rendered) {
        this.render(this.carousel.body);
      }
      else {
        this.carousel.on(‘render’, function() {
          this.render(this.carousel.body);
        }, this, {single: true});
      }
  },
 
  onRender: function() {
      SS.BufferedCarousel.Indicator.superclass.onRender.apply(this, arguments);
     
      this.positionIndicator = this.el.createChild({tag: ‘span’});
  },
 
  onBeforeCardSwitch: function(carousel, card) {
      if (card) {
        var position = card.carouselPosition/(this.carousel.itemCount-1),
          position = isNaN(position) ? 0 : position*100,
          el = this.el;
       
        this.positionIndicator[this.carousel.direction==‘vertical’?‘setTop’:‘setLeft’](position.toFixed(2)+”%”);
       
        el.setStyle(‘opacity’, ‘1’);
       
        if (this.hideTimeout != null) {
          clearTimeout(this.hideTimeout);
        }
       
        this.hideTimeout = setTimeout(function() {
          el.setStyle(‘opacity’, ‘0’);
        }, 1500);
      }
  },
  // @private
  createIndicator: function() {
      this.indicators = this.indicators || [];
      this.indicators.push(this.el.createChild({
        tag: ‘span’
      }));
  },
  // @private
  onCardAdd: function() {
      this.createIndicator();
  },

  // @private
  onCardRemove: function() {
      this.indicators.pop().remove();
  }
});

JackSparrow

1 year ago

//mod SS.BufferedCarousel.Indicator class
//add new 3 functions (from sencha 1.0.1) to this class

// @private
createIndicator: function() {
this.indicators = this.indicators || [];
this.indicators.push(this.el.createChild({
tag: ‘span’
}));
},
// @private
onCardAdd: function() {
this.createIndicator();
},

// @private
onCardRemove: function() {
this.indicators.pop().remove();
}

//enjoin

Ivan Marquez

1 year ago

Need help with this problem, i’m using sencha touch 1.0.1 and use this:

this.carousel = new SS.BufferedCarousel({
  itemCount: 3,
  initialCarouselPosition: 0,
  createItem: function(index) {
  return {html: (i+1)+’‘};
  },
  direction: Ext.getOrientation() == “portrait” ? ‘vertical’ : ‘horizontal’
  });

BUt i have this error: Uncaught TypeError: Object -2 has no method ‘constrain’

Justin Dijkshoorn

1 year ago

I’ve got the same error as well..

Uncaught TypeError: Object -2 has no method ‘constrain’
its on this line: start = (index-bufferSize).constrain(0, this.itemCount-1),

Is there any way to fix this in 1.0.1?

Erick

9 months ago

@Justin To fix that error add this line some where before you instantiate the class:
Number.prototype.constrain = Ext.util.Numbers.constrain;

I am getting another error which is: TypeError: Object has no method ‘onCardAdd’

On sencha debug it’s line 37195

Erick

9 months ago

@Justin @Ivan Marquez, I was able to get the BufferedCarousel to work. The previous code I posted, gets rid of the error, but it is incorrect. The correct code should be:

Number.prototype.constrain = function(min, max){return Ext.util.Numbers.constrain(this.valueOf(), min, max);}

I also had to use @JackSparrow’s fix for the indicator class that’s included in the BufferedCarousel.js file. It’s working smoothly now with Sencha 1.1

Comments are Gravatar enabled. Your email address will not be shown.

Commenting is not available in this channel entry.