Pagination Tricks in Magento – Part 1

We recently had an interesting pagination problem in Magento; a design that required catalog pagination pages to have different numbers of products on them. This turned out to be a bit trickier then we anticipated.

We required continuous scrolling for one of our stores, which was not a big deal unto itself once we found the very practical jQuery plugin “Infinite Ajax Scroll“. This plugin works by loading subsequent catalog pages via AJAX and injecting them into the DOM. This is all well and good except our particular design has a header image taking up part of the first row of products, which meant if we loaded pages all of the same size as the first one we’d end up with an incomplete row of products and a website that looked like it had some teeth knocked out.

One approach (and in retrospect perhaps the most straightforward way) would be to do everything client-side; load pages of whatever size but check to see if the last row is filled, and if not just hide it until we loaded more products. This is not what we did.

What we decided to do was manipulate the product collection server-side to contain the products we wanted; the first 10 products on the first page, then the subsequent eight products on subsequent pages. We naively assumed that we would be able to manipulate offsets in Magento collections, but looking into how this collection loads leads us to the method Mage_Eav_Model_Entity_Collection_Abstract::_loadEntities which executes this code right before the collection is loaded:

$this->getSelect()->limitPage($this->getCurPage(), $this->_pageSize);

Following the trail further leads us to Zend_Db_Select::limitPage

    /**
     * Sets the limit and count by page number.
     *
     * @param int $page Limit results to this page number.
     * @param int $rowCount Use this many rows per page.
     * @return Zend_Db_Select This Zend_Db_Select object.
     */
    public function limitPage($page, $rowCount)
    {
        $page     = ($page > 0)     ? $page     : 1;
        $rowCount = ($rowCount > 0) ? $rowCount : 1;
        $this->_parts[self::LIMIT_COUNT]  = (int) $rowCount;
        $this->_parts[self::LIMIT_OFFSET] = (int) $rowCount * ($page - 1);
        return $this;
    }

The problem with our plan is evident; the collection offset and the page sizes are intrinsically linked and cannot be manipulated independently.

For example if we wanted 10 items on the first page and only eight items on subsequent pages how would we load the third page? We want to start with item 19 so the offset needs to be 18 and the count needs to be 8. Plugging these numbers into the formula above gives us 18 = 8 x ($page -1), which leads us to a fractional page number (this can actually be made to work btw).

There are other ways we could massage the count and page number to cover the items we want and discard the rest, but the math was starting to blow my mind so we decided the simplest thing would be on subsequent page loads we would change the page size to eight and remove the first two items from the collection. Then everything falls into place.

The key spot for manipulating the catalog collection is in the _beforeToHtml() method of the catalog/product_list block (Mage_Catalog_Block_Product_List); this is where client-supplied parameters (e.g. page number, sort order) are pulled from the environment and passed to the collection, and where the collection actually gets loaded. We found it convenient to use the catalog_block_product_list_collection event which is dispatched in this function right before the collection is loaded, but our assumption that the collection is not loaded when our observer does its thing is a potential source of brittleness so there may be better approaches.

Basically what our observer function does is load the first two items of the collection, save those IDs, clear the collection, then add a filter to keep those two items out of the collection next time it gets loaded. One potential gotcha is you need a complete order on the collection as MySQL may change the order in response to the filter if there is ambiguity here.

The observer function we used is below*

    public function adjustCollection(Varien_Event_Observer $observer)
    {
        $productCollection = $observer->getEvent()->getCollection();

        if ($productCollection instanceof Varien_Data_Collection) {
            $productCollection->addOrder('entity_id', 'ASC'); // mySQL was returning records in an inconsistent order without this

            // do this only not on first page load
            $currentPage = (int) Mage::App()->getRequest()->getParam('p');
            if (1 < $currentPage){
                $productCollection->setCurPage(1)->setPageSize(2);

                $dontUse = array();
                foreach($productCollection as $item){
                    $dontUse[] = $item->getId();
                }
                $productCollection->clear();
                $productCollection->addAttributeToFilter('entity_id', array('nin' => $dontUse));
                $productCollection->setCurPage($currentPage)->setPageSize(8);
            }
        }

        return $this;
    }
 

* we actually didn’t use this function, we switched to a responsive design and didn’t need the functionality in the end.