ListView
The ItemView article discusses how to create a visual representation for a single data object. However, we'll often want to create a similar kind of visual represention for collections of data objects, such as multiple settings, contacts, inventory items, etc., while leveraging the feature-set supplied by the ItemViews and ItemVisuals. For list-type visualizations, this is accomplished with the ListView.
The ListView component allows us to construct a visual representation of a data collection by creating a mapping between data objects in a data source (e.g. List<Contact>
) to a scrollable collection of ItemViews and ItemVisuals.
Because data collections can become arbitrarily large, the ListView's visual representation of a given data collection is completely virtualized out-of-the-box, only mapping data objects to ItemView prefabs until the viewport is filled, while efficiently recycling and pooling the ItemView prefabs as they are scrolled into and out of view.
ListView Basics
To use a ListView, you must provide three things:
Setup | Description |
---|---|
ItemView Prefab (1+) | At least one prefab which provides a visual representation for an individual item in your data source. |
DataBinder (1+) | At least one callback which handles binding a single item in the data source to one of the provided ItemView prefabs. |
Data Source | The collection of your underlying data that you want the ListView to display. |
When the ListView determines that new content has become visible, for example due to scrolling, or when the underlying data in the data source is dirtied (see Modifying and Rebinding Data), the ListView will use an instance of one of the ItemView prefabs (either recycling an available one from its prefab pool, or instantiating a new one as needed) and invoke a data-bind event handler to bind the newly visible or dirtied data object to the ItemVisuals of the prefab instance.
Initial Setup
In order to use a ListView we need to have a ListView somewhere in the scene, so let's start off by adding a ListView component to a GameObject in Unity.
The ListView component must be on a UIBlock, so if your GameObject did not already have a UIBlock on it one will be added for you automatically. The positioning of individual items in the ListView, including the space between elements, the direction and axis of scrolling, etc., is determined by the AutoLayout configuration of this UIBlock, so be sure to enable and configure the AutoLayout as desired (for this example, we will select the Y
axis with Top
Alignment).
AutoLayout Axis | List Type |
---|---|
X |
Horizontally Scrollable |
Y |
Vertically Scrollable |
Z |
Z-Scrollable |
Warning
The ListView requires full control of its child GameObjects, so the GameObject with the ListView component must not have any children, regardless of if they have a UIBlock on them or not, and you should not re-parent, re-order, or otherwise modify the transforms of a ListView's children directly (see Detaching List Items for additional info).
Providing an ItemView Prefab
Let's start by providing the ListView with one (or more) ItemView prefab(s) to represent individual items in our data source. The ListView will create new instances of the prefabs it's provided, so it only needs a single instance of each unique prefab assigned in the Unity Editor. The ItemView article discusses creating ItemViews and ItemVisuals in detail, so we recommend reading that article first before proceeding.
Reusing the ContactVisuals
type from the ItemView article:
/// <summary>
/// Visuals for a user contact.
/// Display User Name and Photo.
/// </summary>
public class ContactVisuals : ItemVisuals
{
public TextBlock UserName;
public UIBlock2D Photo;
}
Create a GameObject, add an ItemView component, and assign ContactVisuals
to the Visuals dropdown. Then add a UIBlock2D and TextBlock as children of the ItemView GameObject and assign them to the UserName
and Photo
fields of ContactVisuals
.
Next, create a prefab from your ItemView, so that the ItemView component is on the root GameObject of the prefab.
Finally, assign the ItemView prefab you just created to the List Item Prefabs
field of the ListView component we created above. Be sure to use the prefab, and not the ItemView in the scene (which you can delete).
Providing a Data Binder
We have an ItemView prefab for our data type, the next step is to provide the ListView with a way to bind individual items in the data source to the prefab we have created. This is done by calling the AddDataBinder method on the ListView.
AddDataBinder is a generic method with two type arguments, one for the data type and one for the visuals type, respectively:
// Registering a TData ⇄ TVisuals data binder
ListView.AddDataBinder<TData, TVisuals>(BindData);
By calling AddDataBinder, not only are we providing the ListView with a callback (BindData
in the snippet above) to bind a data item to an ItemView, but we're also declaring a Data ⇄ Visuals
type mapping which specifies that a data item of type TData
should use an ItemView with an ItemVisuals of type TVisuals
. This is useful for when you have multiple ItemView prefabs or a non-uniform data source, as discussed in the Advanced Type Mappings section.
For now, we will only need one data binder. Let's create a ContactList
class which will be responsible for populating a ListView
with a List
of Contacts
and providing our data binding callbacks:
using Nova;
using System.Collections.Generic;
using UnityEngine;
public class ContactList : MonoBehaviour
{
// Serialize and assign in Unity Editor
public ListView ListView;
// Our underlying data source of contacts
private List<Contact> contacts;
private void Start()
{
// Initialize contacts list based
// on how app chooses to store contacts
// ...
// Provide a Contact ⇄ ContactVisuals data binder
ListView.AddDataBinder<Contact, ContactVisuals>(BindContact);
}
}
And add the following implementation to ContactList
as well:
/// <summary>
/// Binds a Contact to a ContactVisuals
/// </summary>
private void BindContact(Data.OnBind<Contact> evt, ContactVisuals visuals, int index)
{
// evt.UserData is the object stored at the given index ^^^ in our data source.
// In other words, evt.UserData == contacts[index].
Contact contact = evt.UserData;
visuals.UserName.Text = contact.UserName;
visuals.Photo.SetImage(contact.Photo);
}
The event-handler callback will be provided with the data item being bound to the ItemVisuals instance via the UserData field of the Data.OnBind struct, as well as the data item's index
into the underlying data source.
In Unity, add the ContactList
component we just created to the same GameObject as the ListView, and assign the ListView component to the ListView
field of the ContactList
.
Providing a Data Source
Now that the ListView has a visual representation of the data objects we want to bind, and we've configured our Data ⇄ Visuals bind event handler, we need to give the ListView the data collection holding all the objects we want to bind, i.e. the data source. Providing a data source to the ListView is done using the SetDataSource
method. While SetDataSource
takes in an IList<T>
and supports user-defined implementations of the IList<T>
interface, the simplest and most common way to call SetDataSource
is with a good, old-fashioned List<T>
.
In the context of our example, we will simply call SetDataSource
with our list of Contacts
in the Start
method:
private void Start()
{
// Initialize contacts list based
// on how app chooses to store contacts
// ...
// Provide a Contact ⇄ ContactVisuals data binder
ListView.AddDataBinder<Contact, ContactVisuals>(BindContact);
// Provide the ListView with the contacts data source
ListView.SetDataSource(contacts);
}
Warning
The ListView will attempt to bind your data as soon as you call SetDataSource, so be sure to provide your data binders before setting the data source.
Scrolling and Clipping
If you've followed along so far, styled your ItemView prefabs, and initialized your data source with some valid data, you should have a list of contacts being populated... which doesn't scroll. But have no fear, there is hope!
While the ListView handles virtualizing content and exposes methods to scroll and jump to specific items, it doesn't actually trigger those actions for you automatically. Don't worry though, you don't need to implement scrolling functionality yourself, Nova provides the Scroller component which handles scrolling for you, so let's add it to the same GameObject as the ListView.
You'll likely also want to clip the content that is outside the bounds of the ListView, so add a ClipMask component to the GameObject as well.
Finally, we'll need to provide Nova with input so that the Scroller knows when to scroll. The Input Overview article discusses the details of using input in Nova, along with initial implementations of managing input on your target platform(s), touch, mouse, and XR. Since we're currently just testing in the Unity Editor, let's add the InputManager
component from the Mouse Input article to an active GameObject in our scene, and PLAY. Voila! Now we have a dynamic, scrollable list of contacts.
Advanced ListView Type Mappings
While a uniform list (i.e. a list where all list items are the same) is sufficient in many cases, there are also plenty of situations where you may wish to display a collection of items where the visual representation of each item is not the same across the entire collection.
For example, let's say you wanted to create a settings menu which displays controls for all the configurable user settings in your game. You likely have a variety of types of settings/controls which require different underlying data types, such as a bool
, float
, enum
, etc, and, in turn, require different types of visuals, such as a toggle, slider, or dropdown.
The ListView makes more complex Data ⇄ Visuals
mappings, such as Mutiple Data Types ⇄ Multiple Prefabs
, Mutiple Data Types ⇄ Single Prefab
, and Single Data Type ⇄ Multiple Prefabs
, all quite easy through two features, Type Matching and Prefab Providers.
Number of Data Types | Number of Prefabs | ListView Feature To Use |
---|---|---|
Multiple | Single | Type Matching |
Multiple | Multiple | Type Matching |
Single | Multiple | Prefab Provider |
Type-Matching
One way to create non-uniform lists is with interfaces or inheritance, and then providing the ListView with multiple data-bind event handlers which map the items in the data source to different ItemView prefabs based on the type of the individual data items.
For example, if you created a Setting
base class which all other settings types inherit from, you can then map the inherited settings classes to different ItemVisuals types like so:
// Add bool ⇄ Toggle and float ⇄ Slider binders
ListView.AddDataBinder<BoolSetting, ToggleVisuals>(BindToggle);
ListView.AddDataBinder<FloatSetting, SliderVisuals>(BindSlider);
// ...
private void BindToggle(Data.OnBind<BoolSetting> evt, ToggleVisuals target, int index)
{
BoolSetting setting = evt.UserData;
target.Label.Text = setting.Label;
target.Indicator.Visible = setting.Value;
}
private void BindSlider(Data.OnBind<FloatSetting> evt, SliderVisuals target, int index)
{
FloatSetting setting = evt.UserData;
target.Label.Text = setting.Label;
target.Fill.Size.X.Percent = setting.Value;
target.Knob.Position.X.Percent = setting.Value;
}
Prefab Provider
Another way to create non-uniform lists is by registering a PrefabProviderCallback with the AddPrefabProvider method. The PrefabProviderCallback allows you to specify an ItemView prefab to use on an item-by-item basis, which is especially useful when you want to use a different ItemView prefab not based on the type of the data item, but on its state.
For example, if you had a single Setting
type for all of your settings, and you differentiated the type of the setting with a SettingType
enum, you could do:
// Register a PrefabProviderCallback
ListView.AddPrefabProvider<Setting>(ProvidePrefab);
// ...
private bool ProvidePrefab(int index, out ItemView sourcePrefab)
{
Setting setting = settings[index];
switch(setting.Type)
{
case SettingType.Bool:
sourcePrefab = togglePrefab;
return true;
case SettingType.Float:
sourcePrefab = sliderPrefab;
return true;
// ...
}
}
When the ListView receives a new sourcePrefab
from a Prefab Provider, it starts tracking it in the same way it tracks the prefabs serialized in the Unity Editor. This means List Item Prefabs
registered through Prefab Provider callbacks still don't need to be manually cloned (the ListView will do this for you) and will automatically benefit from the internal pooling/prefab recycling system. Prefab Providers exclusively provide source prefabs to the ListView. The data-bind events will subsequently be invoked, as expected.
If the Prefab Provider returns false
, this signals the ListView to ignore the Prefab Provider and default to the type-matching behavior with one of its serialized List Item Prefabs
.
Let's say we add a prefab to the List Item Prefabs
in the Unity Editor with a ToggleVisuals
type assigned to the ItemView.
// This call declares a mapping between
// BoolSetting ⇄ ToggleVisuals
ListView.AddDataBinder<BoolSetting, ToggleVisuals>(BindToggle);
// Register a PrefabProviderCallback
ListView.AddPrefabProvider<BoolSetting>(ProvideTogglePrefab);
// ...
private bool ProvideTogglePrefab(int index, out ItemView sourcePrefab)
{
BoolSetting toggle = toggles[index];
switch(toggle.Style)
{
case ToggleStyle.CheckBox:
// Returning true here with a non-null value signals the ListView to
// use the togglePrefab here *instead of* the ToggleVisuals prefab
// assigned in the Editor
sourcePrefab = togglePrefab;
return true;
default:
// Returning false here signals the ListView to
// use the ToggleVisuals prefab assigned in the Editor
sourcePrefab = null;
return false;
}
}
Features
Modifying and Rebinding Data
When changes occur to the data source, such as items being added, removed, or modified, the ListView must be notified of these changes if they affect what is currently loaded into view. The MinLoadedIndex and MaxLoadedIndex properties of the ListView can be used to determine if an index in the data source is currently in view, like so:
if (index >= ListView.MinLoadedIndex && index <= ListView.MaxLoadedIndex)
{
// Notify the ListView of changes
}
There are a few ways to notify the ListView of changes to the data source, depending on the type of changes that occurred:
Method(s) | Change Type |
---|---|
Rebind | Specific item or items modified, but the visual changes do not affect the Size of the corresponding ItemView(s). |
Rebind + Relayout | Specific item or items modified, and the visual changes may affect the Size of the corresponding ItemViews, potentially requiring additional ItemViews to be loaded into view. |
Refresh | Items inserted or removed from the data source, or other complex changes which may require additional ItemViews to be loaded into view. |
Gestures
The AddGestureHandler method can be used to subscribe to Gestures on the items in the ListView. The AddGestureHandler of the ListView behaves identically to the AddGestureHandler method for UIBlocks, while additionally providing the index into the data source of the data item that is bound to the ItemVisuals receiving the Gesture.
// Register for click events on ContactVisuals
ListView.AddGestureHandler<Gesture.OnClick, ContactVisuals>(HandleContactClicked);
// ...
private void HandleContactClicked(Gesture.OnClick evt, ContactVisuals target, int index)
{
// Get the underlying data item
// for the clicked ContactVisuals
Contact contact = contacts[index];
}
Jumping and Scrolling
The JumpToIndex and JumpToIndexPage methods allow you to navigate to target locations within the virtualized list without incurring the cost of binding/unbinding everything between the start location and the end location.
The Scroll method allows you to manually manipulate the position of the ListView's virtualized list items and have the content update accordingly.
// Jump to item 25 in the list
ListView.JumpToIndex(25);
// Immediately scroll the ListView by 5 items
float itemHeight = 1f;
ListView.Scroll(5f * itemHeight);
Detaching List Items
In general, changing sibling index, destroying, and re-parenting a ListView's child ItemView's through the Transform API is not recommended. Modifying the transform hierarchy of a ListView or its list items directly can cause the ListView to get out of sync with its data source and may put your UI in an undesired state.
However, the TryDetach method of the ListView provides a way to request ownership of child ItemViews and, if successful, take full control of the GameObject, allowing you to modify it arbitrarily. Ownership of a detached ItemView can then be returned to the ListView at a later point with TryReattach.
Detaching the items of a ListView may be useful in scenarios where you want to temporarily take full control over the position of one of the items, such as when creating a reorderable list where items can be dragged into new locations.
For example, one approach to accomplish the reorderable list scenario described above is: whenever an item enters the "draggable" state, detach the ItemView, remove the item from the data source, and call Refresh on the ListView:
// Detach the ItemView
if (!ListView.TryDetach(index, out ItemView itemToDrag))
{
// Handle errors
}
// Remove the item from the data source
dataList.RemoveAt(index);
// Update the list to handle the item being removed
ListView.Refresh();
After the (successful) call to TryDetach, itemToDrag
will be un-parented from the ListView and you can arbitrarily position or modify it. Once the "drag" interaction has ended, you can then return the item to the list by reattaching the ItemView, inserting the item back into the data source, and calling Refresh:
// Reattach the ItemView
if (!ListView.TryReattach(itemToDrag))
{
// Handle errors
}
// Insert the item into the data source
// at the new index
dataList.Insert(newIndex, dataItem);
// Update the list to handle the item being added
ListView.Refresh();
Note
Detaching an item from the ListView does not modify the underlying data source in any way. This means that if you detach an item with TryDetach but don't change the underlying data source, the next time the ListView refreshes (either due to scrolling or modifications to the Data Source), it will insert a new, but identical, ItemView to replace the one that was detached. This means that you will typically pair a call to TryDetach with some sort of modification of the data source, as well as a call to Rebind or Refresh