The following blog post, unless otherwise noted, was written by a member of Gamasutras community.

The thoughts and opinions expressed are those of the writer and not Gamasutra or its parent company.

This blog post was originally posted at http://emi.gd/blog/scrolled-list-dynamic/.

In the previous post about the basic scrolled list I listed out some performance issues that it involves. Today I’m going to present a much more robust solution, which we came up with at Tabasco. We were happily using the base version of the list, until we started working on the campaign for the Kickerinho World. The campaign consists of many… Many different levels (over 600 at this moment), which are displayed in form of a scrolled list. It turned out that scrolling such a huge container full of elements has a great impact on the game’s UI performance. The FPS drop rate was unacceptable, so we had to think of a more sophisticated solution.

Concept of the mechanics

The idea is simple – we do not want to render all of the elements at every update, we just need the visible ones. So, as the user scrolls though our list, we need to hide elements at the top and emerge the bottom ones. To be able to do that, we have to calculate how many items can fit into the window and then react to the changes signalized by the ScrollRect. The following gif shows our script in action:

The base

Don’t worry, you don’t have to throw into the trash the list that was created earlier – our new one will inherit from it. The AddElement and ClearPanel methods will have to be overridden, because they need to operate only on the visible set of elements without modifying the original data.

protected override void AddElement(TData element) { _elements.Add(element); UpdateContainerSize(); } public override void ClearPanel() { RemoveAllChildren(); _elements.Clear(); }

We also need the method that will resize the container as the elements count increases:

protected void UpdateContainerSize() { float height = _elements.Count * _childHeigth; RectTransform rt = _container.GetComponent<RectTransform>(); rt.sizeDelta = new Vector2(rt.sizeDelta.x, height); UpdateChildren(); }

Init method

At the begging of our list’s life, we have to make some preparations. First of all, we will call the Init method of the base class, which will allow us to check whether the container meets our expectations. It has to have the vertical stretching turned off, because otherwise we cannot properly determine the elements size. Then we need to do some caching – remember that everything that requires looking up for the component (using the GetComponent method) should be carried out with caution. We will cache the elements and container size, which allows to calculate the number of items that fit into the screen. Addtionally, the _elements list gets created – it will represent the portion of data that is currently visible on the screen. We also add a listener to the onValueChanged event of the ScrollRect – that will inform us that it is time to play with the elements.

public override void Init() { if (!IsInitialized) { base.Init(); Debug.Assert(_containerTransform.anchorMax.y == _containerTransform.anchorMin.y, "ScrollListPooled: Vertical stretching must be turned off! " + GetType()); _elements = new List<TData>(); var childTrans = _listElementCache.GetComponent<RectTransform>(); _childWidth = childTrans.rect.width; _childHeigth = childTrans.rect.height; if (_transformCache.childCount > 0 && _childHeigth > 0) { RectTransform rt = _transformCache.GetChild(0).GetComponent<RectTransform>(); _height = rt.rect.height; _count = Mathf.CeilToInt(_height / _childHeigth); } GetComponent<ScrollRect>().onValueChanged.AddListener((v) => UpdateChildren()); } }

Updating the children

The method that reacts to the scrolling action is called UpdateChildren. To determine indexes of elements that should be visible, we use the Y coordinate of an anchoredPosition property. This property tells us the position of the pivot of the container’s RectTransform relative to the anchor point.

public void UpdateChildren() { UpdateChildren(_containerTransform.anchoredPosition.y); } public void UpdateChildren(float scrolledY) { int newFirst = Mathf.Clamp(Mathf.CeilToInt(scrolledY / _childHeigth) - 1, 0, _elements.Count - 1); int newLast = Mathf.Clamp(Mathf.CeilToInt(scrolledY / _childHeigth) + _count, 0, _elements.Count); ...

If there are some changes after calculating new first and last visible element index, we need to do some cleaning. If we get ridiculous values, such as new first element index being higher than current last visible one’s, we assume the elements have changed and we build it from scratch, respecting the newly calculated first and last elements indexes.

if (newFirst > _last || newLast < _first) { RemoveAllChildren(); for (int i = _first; i < _last; i++) { AddChild(i, -1); } }

Otherwise destroying all of the visible items is not necessary – we just need to take care of a few ones at top and at the bottom of the window. For example – if we scrolled down, we have to hide the elements at the upper part with the indexes falling between the last first element and the new first element. We also have to add some more elements at the bottom, so we spawn items with indexes falling between new and old last element index. Then we obviously have to save the newest set of values.

else { if (_first != newFirst) { for (int i = _first - 1; i >= newFirst; i--) { AddChild(i, 0); } for (int i = _first; i < newFirst; i++) { RemoveChildFirst(); } } if (_last != newLast) { for (int i = _last; i < newLast; i++) { AddChild(i, -1); } for (int i = newLast; i < _last; i++) { RemoveChildLast(); } } } _first = newFirst; _last = newLast;

Playing with children

You certainly noticed some new methods that showed up – all of them involve modifying the set of data that is currently displayed on the screen. The fattest one is AddChild that takes care of instantiating the element and positioning it within the container.

protected void AddChild(int dataIndex, int childIndex = -1) { Debug.Assert(dataIndex >= 0 && dataIndex < _elements.Count); var _prefabTransform = _listElementCache.transform; if (dataIndex >= 0 && dataIndex < _elements.Count) { var newElement = SetUpChild(_elements[dataIndex]); if (childIndex == -1) { _children.Add(newElement); } else { _children.Insert(childIndex, newElement); } Vector3 position = _prefabTransform.localPosition; position.y = -dataIndex * _childHeigth; newElement.GetComponent<RectTransform>().localPosition = position; } }

Notice that the item’s position in the container is in fact immutable – we reposition the container itself, but we just show and hide the elements.

The rest of methods from this group is pretty straightforward:

protected void RemoveChildFirst() { RemoveChildAt(0); } protected void RemoveChildLast() { RemoveChildAt(_children.Count - 1); } protected void RemoveChildAt(int i) { if (_children.Count > 0) { Destroy(_children[i]); _children.RemoveAt(i); } } protected void RemoveAllChildren() { _children.ForEach(Destroy); _children.Clear(); _first = 0; _last = 0; }

Bonus – automatic scrolling to the item

Sometimes there is a need of displaying the lists not from the beginning, but at a specified index. Our use case was centering at the highest stage that is currently available for the player. This behavior can be achieved using this little piece of code:

public void ScrollTo(int id, float align = 0f) { Debug.Assert(IsInitialized); Vector2 pos = _containerTransform.anchoredPosition; pos.y = Mathf.Max(0f, Mathf.Lerp((id - 1) * _childHeigth, id * _childHeigth - _height, align)); _containerTransform.anchoredPosition = pos; UpdateChildren(); }

Summary

And that’s it! The whole, ready-to-use script can be found here. Further improvements could involve enabling horizontal scroll mode and of course – using pooled objects (objects in reserve, created beforehand), but that is the topic for a whole new post. ;)