Skip to content

oolymer/virtual-scroller

 
 

Repository files navigation

<virtual-scroller>

<virtual-scroller> maps a provided set of JavaScript objects onto DOM nodes, and renders only the DOM nodes that are currently visible, leaving the rest "virtualized".

This document is an early-stage explainer for <virtual-scroller> as a potential future web platform feature, as part of the layered API project. The repository also hosts a proof-of-concept implementation that is being co-evolved with the design.

The (tentative) API design choices made here, as well as the element's capabilities, take inspiration from the infinite list study group research.

Example

<script type="module"
        src="std:virtual-scroller|https://some.cdn.com/virtual-scroller.js">
</script>

<virtual-scroller></virtual-scroller>

<script type="module">
  const scroller = document.querySelector('virtual-scroller');
  const myItems = new Array(200).fill('item');

  scroller.updateElement = (child, item, index) => {
    child.textContent = index + ' - ' + item;
    child.onclick = () => console.log(`clicked item #${index}`);
  };

  // This will automatically cause a render of the visible children
  // (i.e., those that fit on the screen).
  scroller.itemSource = myItems;
</script>

By default, the elements inside the virtual scroller created in this example will be <div>s, and will be recycled. See below for more on customizing this behavior through the createElement and recycleElement APIs.

Checkout more examples in demo/index.html.

API

createElement property

Type: function(item: any, itemIndex: number) => Element

Set this property to configure the virtual scroller with a factory that creates an element the first time a given item at the specified index is ready to be displayed in the DOM.

The default createElement will, upon first being invoked, search for the first <template> element child that itself has at least one child element in its template contents. If one exists, it will create new elements by cloning that child. Otherwise, it will create <div> elements. In either case, it will reuse recycled DOM nodes if recycleElement is left as its default value.

Changing this property from its default will automatically reset recycleElement to null, if recycleElement has been left as its default.

updateElement property

Type: function(child: Element, item: any, itemIndex: number)

Set this property to configure the virtual scroller with a function that will update the element with data from a given item at the specified index.

This property is invoked in these scenarios:

  • The user scrolls the scroller, changing which items' elements are visible. In this case, updateElement is called for all of the newly-visible elements.
  • The developer changes the itemSource property.
  • The developer calls itemsChanged(), which will call updateElement for all currently-visible elements. See below for more on this.

The default updateElement sets the textContent of the child to be the given item, stringified. Almost all uses of <virtual-scroller> will want to change this behavior.

recycleElement property

Type: function(child: Element, item: any, itemIndex: number)

The default recycleElement collects the item's element if it is no longer visible, and leaves it connected to the DOM in order to be reused by the default createElement.

Set this property to null to remove the item's element from the DOM when it is no longer visible, and to prevent recycling by the default createElement.

Usually this property will be customized to introduce custom node recycling logic, as seen in the example below.

itemSource property

Type: Array or ItemSource

Set this property to control how the scroller will map the visible indices into their corresponding items. The items are then provided to the various rendering customization functions: createElement, updateElement, recycleElement.

If an array is provided, it will be converted to an ItemSource instance that returns the elements from the array, as if by using ItemSource.fromArray(array) (with no key argument).

layout property

Type: string

One of:

  • "vertical" (default)
  • "horizontal"
  • "vertical-grid"
  • "horizontal-grid"

Can also be set as an attribute on the element, e.g. <virtual-scroller layout="horizontal-grid"></virtual-scroller>

itemsChanged() method

This re-renders all of the currently-displayed elements, updating them from their source items using updateElement (which in turn consults the itemSource).

This generally needs to be called any time the data to be displayed changes. This includes additions, removals, and modifications to the data. See our examples below for more information.

scrollToIndex(index: number, { position: string = "start" } = {}) method

Scrolls to a specified index, optionally with a position, one of:

  • "start": aligns the start of the item with the start of the visible portion of the scroller
  • "center": aligns the center of the item with the center of the visible portion of the scroller
  • "end": aligns the end of the item with the end of the visible portion of the scroller
  • "nearest": if the item is before the center of the visible portion of the scroller, behaves like "start"; if it is after the center of the visible portion of the scroller, behaves like "end"

Note that what is considered the "start" and "end" of the scroller is dependent on the layout; for vertical layouts, start/end means top/bottom, while for horizontal layouts, they mean left/right.

See demo/scrolling.html to see these behaviors in action.

Note: the options object design is inspired by the options for element.scrollIntoView(). We may in the future add a behavior option for smooth scrolling; see #99.

"rangechange" event

Bubbles: false / Cancelable: false / Composed: false

Fired when the scroller has finished rendering a new range of items, e.g. because the user scrolled. The event is an instance of RangeChangeEvent, which has the following properties:

  • first: a number giving the index of the first item currently rendered.
  • last: a number giving the index of the last item currently rendered.

Also see the example below.

The ItemSource class

The ItemSource class represents a way of translating indices into JavaScript values. You can create them like so:

const source = new ItemSource({
  item(index) { ... },
  getLength() { ... },
  key(index) { ... }
});

For example, to create an ItemSource that gets its items from a contacts array, and uses contact.id as the key, you could do

const contactsSource = new ItemSource({
  item(index) { return contacts[index]; },
  getLength() { return contacts.length; },
  key(index) { return contacts[index].id; }
});

There is also a factory method, ItemSource.fromArray(array[, key]), that makes this easier:

const contactsSource = ItemSource.fromArray(contacts, c => c.id);

The key argument to fromArray() is called with an item, and should return a unique key for the object. If no key argument is given, then the item itself is used as the key.

The main use of the ItemSource class is to be assigned to the itemSource property of a <virtual-scroller> element; as such, for now its only public API is a length property.

More examples

Customizing element creation and updating with <template>

If the user does nothing special, the default createElement callback will create and reuse <div> elements. There are several ways of getting more control over this process.

First, you can use a <template> child element to declaratively set up your new element. This snippet creates a scrolling view onto <section> elements, which (per the default updateElement behavior) displays any items given to it, stringified:

<virtual-scroller>
  <template>
    <section></section>
  </template>
</virtual-scroller>

By setting a custom updateElement behavior, you can leverage more interesting templates, for example:

<virtual-scroller id="scroller">
  <template>
    <section>
      <h1></h1>
      <img></img>
      <p></p>
    </section>
  </template>
</virtual-scroller>

<script type="module">
  scroller.updateElement = (child, contact) => {
    child.querySelector("h1") = contact.name;
    child.querySelector("img").src = contact.avatarURL;
    child.querySelector("p").textContent = contact.bio;
  };

  scroller.itemSource = contacts;
</script>

A useful pattern here is to encapsulate the details of updating your elements inside a custom element, for example:

<virtual-scroller>
  <template>
    <contact-element sortable></contact-element>
  </template>
</virtual-scroller>

<script type="module">
  scroller.updateElement = (child, contact) => {
    child.contact = contact;
  };

  scroller.itemSource = contacts;
</script>

Note that in all these examples, the elements are recycled.

Customizing element creation and updating: using createElement

If you want complete control over element creation, you can set a custom createElement. This could be useful if, for example, you have a completely static list, which you want to fill out ahead of time and never update again:

let myItems = ['a', 'b', 'c', 'd'];

scroller.createElement = item => {
  const child = document.createElement('div');
  child.textContent = item;
  return child;
};

scroller.updateElement = null;

// Calls createElement four times (assuming the screen is big enough)
scroller.itemSource = myItems;

Note that even if we invoke itemsChanged(), or change itemSource, nothing new would render in this case, because we have no updateElement behavior:

// Does nothing
requestAnimationFrame(() => {
  myItems.length = 0;
  myItems.push('A', 'B', 'C', 'D');
  scroller.itemsChanged();
});

// Does nothing
requestAnimationFrame(() => {
  scroller.itemSource = ['X', 'Y', 'Z', 'W'];
});

Note: we include requestAnimationFrame here to wait for <virtual-scroller> rendering.

Custom DOM recycling using recycleElement

The default createElement and recycleElement functions will recycle the created DOM elements. You can also control this process on your own by setting a custom recycleElement:

const nodePool = [];
scroller.createElement = () => {
  return nodePool.pop() || document.createElement('section');
};
scroller.recycleElement = (child) => {
  nodePool.push(child);
};

This example's only customization over the default is using <section> instead of <div>. So, it is equivalent to using

<virtual-scroller>
  <template><section></section></template>
</virtual-scroller>

But at least it illustrates the idea, and gives you a starting point for more advanced customizations.

Data manipulation using itemsChanged()

The <virtual-scroller> element will automatically rerender the displayed items when itemSource changes. For example, to switch to a completely new set of items, you could do:

scroller.itemSource = newArray;

If you want to continue using the same items and item source, but have updated any of them, you need to use itemsChanged() to notify the scroller about changes, and cause a rerender of currently-displayed items. For example:

const myItems = ['a', 'b', 'c'];
scroller.itemSource = myItems;

myItems[0] = 'item 0 changed!';
scroller.itemsChanged();

myItems.push('d');
scroller.itemsChanged();

Efficient re-ordering using a custom item key

<virtual-scroller> keeps track of the generated DOM via an internal key/element map to limit the number of created nodes.

Most of our examples so far have directly assigned an array to the itemSource property. For these cases, the default key is the item itself. But you can set a custom key either by using the second argument to fromArray:

scroller.itemSource = ItemSource.fromArray(items, item => ...);

or by creating a custom ItemSource and providing the key method:

scroller.itemSource = new ItemSource({
  key(index) { ... },
  ...
});

To see how this helps, consider the following example. Imagine we have a list of 3 contacts:

const myContacts = [{name: 'A'}, {name: 'B'}, {name:'C'}];
scroller.updateElement = (child, item) => child.textContent = item.name;
scroller.itemSource = myContacts;

This renders 3 contacts, and the <virtual-scroller> key/element map is:

myContacts[0] → <div>A</div>
myContacts[1] → <div>B</div>
myContacts[2] → <div>C</div>

Let's say we receive new data from the server, which has rearranged the contacts in a different order:

// Pretend this came from the server:
const newContacts = [{name: 'B'}, {name:'C'}, {name: 'A'}];
scroller.itemSource = newContacts;

With the default key function, we would re-update, relayout, and repaint all the contacts. Since none of the new contact objects are in the key/element map, we would need to call createElement again, once for each new contact.

This is suboptimal, as we just needed to move the first DOM node to the end.

If instead we set the key computation appropriately when first setting myContacts, e.g. by doing

scroller.itemSource = ItemSource.fromArray(myContacts, c => c.name);

then the key/element map would be

A → <div>A</div>
B → <div>B</div>
C → <div>C</div>

Now if we update the itemSource, with

scroller.itemSource = ItemSource.fromArray(newContacts, c => c.name);

the <virtual-scroller> will notice that none of its keys changed, and so it can just reuse the same elements from the key/element map, while rearranging them appropriately. Thus we have avoided the expense of creating new ones.

See demo/sorting.html as an example implementation.

Performing actions as the scroller scrolls using the "rangechange" event

Listen for the "rangechange" event to get notified when the displayed items range changes.

scroller.addEventListener('rangechange', (event) => {
  if (event.first === 0) {
    console.log('rendered first item.');
  }
  if (event.last === scroller.itemSource.length - 1) {
    console.log('rendered last item.');
    // Perhaps you would want to load more data for display!
  }
});

Scrolling

<virtual-scroller> needs to be sized in order to determine how many items should be rendered. Its default height is 150px, similar to CSS inline replaced elements like images and iframes.

Main document scrolling will be achievable through document.rootScroller

<virtual-scroller style="height: 100vh"></virtual-scroller>
<script type="module">
  document.rootScroller = document.querySelector('virtual-scroller');
</script>

Development

To work on the proof-of-concept implementation, ensure you have installed the npm dependencies and serve from the project root

$ npm install
$ python -m SimpleHTTPServer 8081

Then, navigate to the url: http://localhost:8081/demo/

For more documentation on the internal pieces that we use to implement our <virtual-scroller> prototype, see DESIGN.md.

Releases

No releases published

Packages

No packages published

Languages

  • JavaScript 94.2%
  • HTML 2.9%
  • Shell 2.9%