[SOLVED] [TUTORIAL] Virtual List with Sticky Headers

So I realize that the whole point of virtual lists is that they seamlessly display only a subset of a much larger set of rendered items at any given time or position. As a result, there are only ever several items of the larger set rendered at one time. I can see this when I inspect the DOM while I am scrolling through a virtual list.

Now that being said, I’ll arrive at my question: Is there a way to do a virtual list with sticky headers?

I understand that there are several challenges with doing this. For one, let’s say I use a renderItem method instead of just assigning a template for a virtual list, and I render normal items one way and list-group-titles a separate way – then I can kinda get this to work… that is, until the list-group-title item goes out of rendering scope, in which case the sticky title just disappears. Secondly, the position: sticky CSS attribute gets mixed up for all instances further down the list, and causes the item to be placed in an unexpected position.

Now all this being said, what I am suggesting may take me editing the Framework7 virtual list source code just for my project. I would for example have to add an attribute to each item that the virtual list class would have to inspect to pick out sticky title items from the rest of the normally rendered items, and keep sticky titles rendered even if their immediate neighbors aren’t being rendered. If this is the only way to accomplish what I am looking for, then is there an obvious best way to achieve this? (‘No’ is an acceptable answer).

In one of my apps i needed to add item-divider but guess logic will be the same with list group title. I used itemsAfterInsert event to hook into the rendering and prepending item-divider/group title to the list. Here is the piece of that handler:

onVirtualAfterInsert(vlist) {
        const self = this;
        const list = $(vlist.ul);
        const listName = list.parents('.list').attr('data-name');
        let title;
        if (self.type === 'contacts') {
          title = listName === 'main-list' ? 'Contacts' : 'Newly added';
        } else {
          title = listName === 'main-list' ? 'Users' : 'Pending invites';
        }
        list.prepend(`<li class="item-divider">${title}</li>`);
        vlist.setListSize();
        list.css({ height: `${list.height() + (self.$theme.ios ? 31 : 48)}px` });
      },

So you basically need 3 last lines:

list.prepend(`<li class="item-divider">${title}</li>`);
vlist.setListSize();
list.css({ height: `${list.height() + (self.$theme.ios ? 31 : 48)}px` });

Awesome! So I was able to successfully implement a virtual list with sticky headers with the help of the hint you provided me above @nolimits4web.

I was reading on other threads within this forum that the handler function for the itemsAfterInsert event provides both the virtualList and the fragment parameters, but the fragment is always an empty HTML collection by the time the itemsAfterInsert event has been fired because all of its nodes have already been added to the virtual list. This makes since.

So I instead hooked to the itemsBeforeInsert event, which provides me the HTML fragment with all of the nodes about to be inserted. Now, in order to get this to work, I had to employ a number of tricks (they are fairly neat and not too hackish).

I made a working example available:
https://mbplautz.github.io/example/f7-virtuallist-stickyheader-demo.html
Tested on Chrome 69

Tricks to get a virtual list to have sticky headers

This is just FYI to anybody else reading this thread to explain my solution:

  • For sticky headers to work in Framework7 in any list, you have to follow a very specific DOM structure with your list, and each section that is headered. It resembles:
<div class="list links-list">
  <div class="list-group">
    <ul>
      <li class="list-group-title">First Group Title</li>
      <li><a href="#">First Group Item 1</a></li>
      <li><a href="#">First Group Item 2</a></li>
    </ul>
  </div>
  <div class="list-group">
    <ul>
      <li class="list-group-title">Second Group</li>
      <li><a href="#">Second Group Item 1</a></li>
      <li><a href="#">Second Group Item 2</a></li>
    </ul>
  </div>
</div>
  • This DOM structure is different from a typical list because instead of having just one single parent ul element, you have several ul elements – one for each group – each of them wrapped within their own div.list-group.
  • As a result, if you want sticky headers, when the virtual list is rendered, you cannot just add li elements to the virtual list.
  • Instead, you have to add li elements wrapped into a <div class="list-group"><ul>...</ul></div> section, and you have to have one section per sticky header.
  • In the itemsBeforeInsert event, you are given the HTML fragment of all of the rendered items. Remove each item from the HTML fragment, add a section wrapper to the HTML fragment, add* the sticky header as the first list item in the section wrapper, then add all of the rendered items after the header to the section wrapper.
  • In the itemsBeforeInsert event, when you are removing and adding the rendered items, you will have to ensure that each item gets added back to the correct section wrapper in which it belongs – that is, under the correct sticky header
  • Because all we are doing is editing the HTML fragment, there is still the parent ul element it is being added to. This results in a DOM structure that resembles:
<div class="list virtual-list links-list">
  <ul>
    <div class="list-group">
      <ul>
        <li class="list-group-header">{{ Header name }}</li>
        {{ Added rendered items }}
      </ul>
    </div>
  </ul>
</div>
  • This results in extra left padding in the list, due to the default Framework7 CSS rules on two levels of ul elements within a div.list. You need to add a CSS rule to get rid of this padding.
  • If you look at the DOM as you scroll through a virtual list, within the ul element has been added to the parent div.virtual-list element, and you will see that within that ul there are a shifting window of items rendered from the defined itemTemplate attribute (or renderItem if that was used instead). As you scroll down, the top CSS attribute of each of these items changes with each window of rendered items.
  • Since we are removing each of the rendered items from the HTML fragment and adding each of them to the header section wrapper within the HTML fragment, each rendered item does not need its top style set individually. Instead, the section wrapper needs its top style set (we can use the top value from the first item, since they are all the same within each window) and the top style attribute on each rendered item needs to be cleared.

Once you’ve done all of that, you can have a virtual list with sticky headers.

The code

Styles:

		.moving-div {
			position: relative;
		}
		
		.list.virtual-list ul div.list-group.moving-div ul {
			padding-left: 0;
		}

HTML:

        <div class="list links-list virtual-list">
        </div>

Javascript:

This assumes you start with some sort of hierarchical data that gets flattened out

		// Build a flat list from the hierarchy data
		// Assume hierarchyData is an array of objects with properties name: String and subItems: Array
		// Assume each subItem in the subItems array is an object with one property name: String
		var listData = [];
		hierarchyData.forEach(function(item) {
			item.subItems.forEach(function(subItem) {
				// Here is the key for getting sticky headers
				// Make sure the header title is set for each individual item
				subItem.header = item.name;
				
				listData.push(subItem);
			});
		});
		
		function createItemDiv() {
			var d = document.createElement('div');
			d.className = 'list-group moving-div';
			var u = document.createElement('ul');
			d.appendChild(u);
			return { d, u };
		}
		
		function onVirtualBeforeInsert(vlist, fragment) {
			var self = this;
			var list = Dom7(vlist.ul);
			
			var virtualItems = [];
			var first = true;
			var top;
			while (fragment.hasChildNodes()) {
				var c = fragment.removeChild(fragment.firstChild);
				if (first) {
					first = false;
					top = c.style.top;
				}
				Dom7(c).css('top', '');
				virtualItems.push(c);
			}
			
			var previousName = null;
			var addedHeaders = [];
			var e;

			virtualItems.forEach(item => {
				var headerName = Dom7(item).attr('data-header-name');
				if (previousName !== headerName) {
					previousName = headerName;
					e = createItemDiv();
					fragment.appendChild(e.d);
					Dom7(e.d).css('top', top);
					var h = document.createElement('li');
					h.className = 'list-group-title';
					h.appendChild(document.createTextNode(headerName));
					e.u.appendChild(h);
				}
				
				e.u.appendChild(item);
				
			});
			
			vlist.setListSize();
			list.css({ height: `${list.height() + ((app.theme == 'ios' ? 31 : 48) * addedHeaders.length)}px` });
		}
		
		var virtualList = app.virtualList.create({
			el: '.virtual-list',
			items: listData,
			height: app.theme === 'ios' ? 44 : 54,
			// Make each item's header available in the DOM for easier code
			itemTemplate: '<li data-header-name="{{header}}"><a href="#">{{name}}</a></li>',
			on: {
				itemsBeforeInsert: onVirtualBeforeInsert
			}
		});
3 Likes

Awesome, thanks for sharing!

Thanks for sharing!

I’ve just sent a pull request because there is an issue with the list’s height calculation, for sure only a tiny oversight. You can see the issue even from the list render, there is a bottom line draw over one of the latest row.

The fixed code for the onVirtualBeforeInsert functioon is:

	function onVirtualBeforeInsert(vlist, fragment) {
		var self = this;
		var list = Dom7(vlist.ul);
		
		var virtualItems = [];
		var first = true;
		var top;
		while (fragment.hasChildNodes()) {
			var c = fragment.removeChild(fragment.firstChild);
			if (first) {
				first = false;
				top = c.style.top;
			}
			Dom7(c).css('top', '');
			virtualItems.push(c);
		}
		
		var previousName = null;
		var addedHeaders = [];
		var e;

		virtualItems.forEach(item => {
			var headerName = Dom7(item).attr('data-header-name');
			if (previousName !== headerName) {
				previousName = headerName;
				e = createItemDiv();
				fragment.appendChild(e.d);
				Dom7(e.d).css('top', top);
				var h = document.createElement('li');
				h.className = 'list-group-title';
				h.appendChild(document.createTextNode(headerName));
				e.u.appendChild(h);
				addedHeaders.push(e);
			}
			
			e.u.appendChild(item);
			
		});
		
		vlist.setListSize();
		list.css({ height: `${list.height() + ((app.theme == 'ios' ? 31 : 48) * addedHeaders.length)}px` });
	}
3 Likes