How To Group Items In Ionic's Collection-Repeat

8 April 2015AngularJS, Ionic

The Ionic Framework has a collection-repeat directive that you can use, instead of ng-repeat, when you need to display very large lists.

I was looking for a way to group items in a collection-repeat list by date, more specifically by the combination of month and year. I had a look at the Ionic Demo for collection-repeat where they group the list by last name. We'll take that code and modify it to do grouping by date.

For this tutorial we're creating an array of a thousand items, every item has a different date, and the array will be sorted in ascending order.

angular.module('myApp')
.controller('ItemController', function() {
	var vm = this,
		items = [];

	for (var i = 1; i <= 1000; i++) {
		var itemDate = moment().add(i, 'days');

		var item = {
			description: 'Description for item ' + i,
			date: itemDate.toDate()
		};
		items.push(item);
	}

	vm.items = items;
	return vm;
}

##Display the collection Let's write the template for displaying the collection. It's basically the same as when you're using ng-repeat, we just replace the ng-repeat directive by collection-repeat.

<ion-content class="has-header" ng-controller="ItemController as vm">
	<label class="item item-input">
    	<i class="icon ion-search placeholder-icon"></i>
        <input type="search" placeholder="Search" ng-model="searchText">
    </label>
    <ion-list>
    	<div collection-repeat="item in vm.items | filter:searchText | groupByMonthYear" divider-collection-repeat>
        	<ion-item>
            	<div>{{ item.description }}</div>
                <div>{{ item.date | date:'dd-MM-yyyy' }}</div>
            </ion-item>
    	</div>
	</ion-list>
</ion-content>

I've added the groupByMonthYear filter to group the collection and the divider-collection-repeat directive to display the dividers. Let's have a look at the code for these new components.

##Group the collection We are going to create a filter that will insert a divider object into the array between items that have a different month or year. So the array will look like this after the filter has run:

[
	{ isDivider: true, divider: "April 2015"},
	{ description: "Item Description 1", date: "2015-04-09T10:42:18.983Z"  },
	{ description: "Item Description 2", date: "2015-04-10T10:42:18.983Z"  },
	{ description: "Item Description 3", date: "2015-04-11T10:42:18.983Z"  },
	...
	{ isDivider: true, divider: "May 2015"},
	{ description: "Item Description 4", date: "2015-05-01T10:42:18.983Z"  },
	{ description: "Item Description 5", date: "2015-05-02T10:42:18.983Z"  },
	{ description: "Item Description 6", date: "2015-05-03T10:42:18.983Z"  },
	...
]

Here is the code for the filter:

angular.module('myApp')
.filter('groupByMonthYear', function($parse) {
	var dividers = {};

	return function(input) {
		if (!input || !input.length) return;

		var output = [],
			previousDate,
			currentDate;

		for (var i = 0, ii = input.length; i < ii && (item = input[i]); i++) {
			currentDate = moment(item.date);
			if (!previousDate ||
				currentDate.month() != previousDate.month() ||
				currentDate.year() != previousDate.year()) {

				var dividerId = currentDate.format('MMYYYY');

				if (!dividers[dividerId]) {
					dividers[dividerId] = {
						isDivider: true,
						divider: currentDate.format('MMMM YYYY')
					};
				}

				output.push(dividers[dividerId]);
			}

			output.push(item);
			previousDate = currentDate;
		}

		return output;
	};
})

The most important thing to note here is that we need to make sure we don't create new divider objects every time the filter is run. If you're creating new divider objects every time, Angular will keep thinking that something has changed and run a digest which will create new divider objects and so on until you get this error: 10 $digest iterations reached.

So to make sure we're reusing the divider objects, the objects are saved in an associative array dividers.

If you were to run the code now, you'd see that there will be an empty item in the list where the divider item should be. That is because the binding on the item is expecting a description and a date property to display and the divider object doesn't have those properties. Also we want the divider to look like a divider, so let's go ahead and implement the code for that.

##Display the dividers We can add the HTML code to the template to display the divider, but it's cleaner to add this code into a directive, hence the divider-collection-repeat directive.

We'll have to add code to hide the regular item and display a divider item in the list when the databound item has isDivider == true.

We'll also have to change the itemHeight on the element since a divider item has a smaller height than a regular item.

angular.module('myApp')
.directive('dividerCollectionRepeat', function($parse) {
	return {
		priority: 1001,
		compile: compile
	};

	function compile (element, attr) {
		var height = attr.itemHeight || '73';
		attr.$set('itemHeight', 'item.isDivider ? 37 : ' + height);

		element.children().attr('ng-hide', 'item.isDivider');
		element.prepend(
			'<div class="item item-divider ng-hide" ng-show="item.isDivider" ng-bind="item.divider"></div>'
		);
	}
})

##Let's run it Let's load up the view again, we can see our collection with the dividers in it. And it also works with the search filtering.

Collection grouped by Month and Year

##Final Thoughts If you have a look at the generated code after the directive has done it's work, you'll see something like this:

...
<div collection-repeat="item in vm.items | filter:searchText | groupByMonthYear" divider-collection-repeat="" item-height="item.isDivider ? 37 : 73" style="-webkit-transform: translate3d(0px, 0px, 0px); height: 38px; width: 375px;">
    <div class="item item-divider ng-binding" ng-show="item.isDivider" ng-bind="item.divider">April 2015</div>
    <ion-item ng-hide="item.isDivider" class="item ng-hide" style="">
        <div class="ng-binding"></div>
        <div class="ng-binding"></div>
    </ion-item>
</div>
<div collection-repeat="item in vm.items | filter:searchText | groupByMonthYear" divider-collection-repeat="" item-height="item.isDivider ? 37 : 73" style="-webkit-transform: translate3d(0px, 37px, 0px); height: 74px; width: 375px;">
    <div class="item item-divider ng-binding ng-hide" ng-show="item.isDivider" ng-bind="item.divider" style=""></div>
    <ion-item ng-hide="item.isDivider" class="item">
        <div class="ng-binding">Description for item 1</div>
        <div class="ng-binding">08-04-2015</div>
    </ion-item>
</div>
...

Every item in the list has a divider and an item element that are either hidden or displayed depending on the type of item in the array. It seems like a lot for the DOM to handle when you have very large lists, but the great thing about collection-repeat is that it doesn't add a thousand div elements to the DOM.

I checked the generated code and it only added 20 of these elements and when you're scrolling through the list, it will update the elements in the DOM with the new items that you're scrolling to.

I've only tested it on an iPhone 5 where it takes a second to load the view, but after it's loaded it performs well on scrolling through the items and there is no lag when it's filtering on the search box. When I was using ng-repeat for this list, there was a noticable lag when I was typing in the search box.


If you know a better way of grouping items with collection-repeat, let me know in the comments.

WRITTEN BY
profile
Ashteya Biharisingh

Full stack developer who likes to build mobile apps and read books.