In game development, the ScrollRect (Scroll View) is one of the most frequently used components for displaying large lists, such as inventories, shop catalogs, leaderboards, and friend lists.
However, as the number of data entries grows to hundreds or thousands, scrolling performance degrades rapidly. This results in noticeable frame drops (stuttering) and, in extreme cases, makes the UI feel entirely unresponsive.
This article explains the root causes of ScrollRect performance degradation and provides a step-by-step implementation guide for a Recyclable Scroll View (vertical scrolling) that optimizes rendering by reusing a minimal pool of cell objects.
1. The Root Causes: Inefficient Out-of-Bound Calculations and Layout Overhead
The standard way to use a ScrollRect is to instantiate a prefab for every single data entry and attach them under the Content transform. This naive approach introduces three major performance bottlenecks:
Figure: Instantiating all items (left) vs. wrapping out-of-screen cells dynamically (right)
- Rendering and Memory Overhead: Hundreds of cells hidden outside the viewport still consume memory and require evaluation by the rendering system (culling and batching) every frame, placing unnecessary load on both the CPU and GPU.
- High Layout Group Rebuild Costs: Developers typically attach
VerticalLayoutGrouporContentSizeFittercomponents to theContentcontainer to space items automatically. However, these layout components recalculate and rebuild the positions of all child elements whenever a single child changes state (position, size, or active state). The computational cost grows exponentially with the item count N. - GC Spikes from Instantiate and Destroy: Implementing a list by dynamically instantiating new cells and destroying old ones during scrolling generates substantial heap memory allocation (GC Alloc). This triggers the Unity Garbage Collector frequently, causing painful frame drops (stuttering).
2. Solution Design: The Cell Recycling Pattern
The Cell Recycling pattern addresses these bottlenecks. This design forms the foundation of most professional scroll-list assets on the Asset Store:
- Minimize Cell Count: Instantiate only the minimum number of cells required to cover the scroll window's visible area (e.g., if 5 cells fit on-screen, instantiate 7 or 8 cells, including extra padding on both ends).
- Warping (Position Correction): When a cell scrolls past the top (or bottom) boundary and out of view, warp it immediately to the bottom (or top) boundary just outside the viewport.
- Data Re-Binding: Once a cell is repositioned, assign the data corresponding to the new array index (overwriting text and images). To the player, the list appears to scroll seamlessly.
- Eliminate Layout Groups: Avoid automatic layout components entirely. Instead, calculate each cell's coordinate mathematically in C# (e.g.,
Y = -index * (cellHeight + spacing)) and update itsanchoredPositiondirectly. This bypasses the heavy Canvas layout rebuild process.
3. Implementation Guide: Step-by-Step Code
Here is a vertical Recyclable Scroll View implementation. It consists of a data container, a single cell controller, and a ScrollRect manager script.
1. The Cell Controller (ListCell.cs)
Attach this script to your list item prefab to manage UI bindings (text, images).
using UnityEngine;
using UnityEngine.UI;
public class ListCell : MonoBehaviour
{
[SerializeField] private Text titleText;
[SerializeField] private Text descriptionText;
public RectTransform RectTransform { get; private set; }
public int CurrentIndex { get; private set; } = -1;
private void Awake()
{
RectTransform = GetComponent<RectTransform>();
}
// Bind data to the cell UI
public void Bind(int index, string title, string description)
{
CurrentIndex = index;
titleText.text = title;
descriptionText.text = description;
}
}
2. The Scroll View Manager (RecyclableScrollView.cs)
Attach this to your ScrollRect GameObject. This script calculates cell coordinates manually instead of relying on slow automatic layout components.
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(ScrollRect))]
public class RecyclableScrollView : MonoBehaviour
{
[Header("UI References")]
[SerializeField] private ScrollRect scrollRect;
[SerializeField] private RectTransform viewport;
[SerializeField] private RectTransform content;
[SerializeField] private ListCell cellPrefab;
[Header("Layout Settings")]
[SerializeField] private float cellHeight = 100f;
[SerializeField] private float spacing = 10f;
[SerializeField] private int extraPaddingCells = 2; // Buffer cells outside viewport
// Sample data container
public class CellData
{
public string Title;
public string Description;
}
private List<CellData> _dataList = new List<CellData>();
private List<ListCell> _activeCells = new List<ListCell>();
private float _viewportHeight;
private int _maxVisibleCells;
private int _totalItemsCount;
private int _previousStartIdx = -1;
private void Awake()
{
if (scrollRect == null) scrollRect = GetComponent<ScrollRect>();
_viewportHeight = viewport.rect.height;
// Calculate the maximum number of cells that can fit in the viewport, plus buffer cells
_maxVisibleCells = Mathf.CeilToInt(_viewportHeight / (cellHeight + spacing)) + extraPaddingCells * 2;
}
private void Start()
{
// Populate dummy list with 10,000 items
var dummyData = new List<CellData>();
for (int i = 0; i < 10000; i++)
{
dummyData.Add(new CellData
{
Title = $"Item #{i}",
Description = $"Detailed description for item number {i}."
});
}
Initialize(dummyData);
}
public void Initialize(List<CellData> data)
{
_dataList = data;
_totalItemsCount = data.Count;
// Calculate and set total content height in advance
float totalHeight = _totalItemsCount * cellHeight + (_totalItemsCount - 1) * spacing;
content.sizeDelta = new Vector2(content.sizeDelta.x, totalHeight);
// Clear existing cells
foreach (var cell in _activeCells)
{
if (cell != null) Destroy(cell.gameObject);
}
_activeCells.Clear();
// Spawn only the minimum required cells
int spawnCount = Mathf.Min(_maxVisibleCells, _totalItemsCount);
for (int i = 0; i < spawnCount; i++)
{
ListCell cell = Instantiate(cellPrefab, content);
// Set anchors to Top-Left for simple coordinate calculation
cell.RectTransform.anchorMin = new Vector2(0, 1);
cell.RectTransform.anchorMax = new Vector2(1, 1);
cell.RectTransform.pivot = new Vector2(0.5f, 1); // Pivot at top
cell.RectTransform.sizeDelta = new Vector2(0, cellHeight);
_activeCells.Add(cell);
}
// Setup scroll change listener
_previousStartIdx = -1;
scrollRect.onValueChanged.RemoveListener(OnScroll);
scrollRect.onValueChanged.AddListener(OnScroll);
UpdateCells(0);
}
private void OnScroll(Vector2 scrollPos)
{
// Calculate the starting index from current content Y offset
float contentY = content.anchoredPosition.y;
int currentStartIdx = Mathf.FloorToInt(contentY / (cellHeight + spacing));
// Clamp bounds to prevent overflow
currentStartIdx = Mathf.Clamp(currentStartIdx - extraPaddingCells, 0, Mathf.Max(0, _totalItemsCount - _activeCells.Count));
if (currentStartIdx != _previousStartIdx)
{
_previousStartIdx = currentStartIdx;
UpdateCells(currentStartIdx);
}
}
private void UpdateCells(int startIdx)
{
for (int i = 0; i < _activeCells.Count; i++)
{
int dataIdx = startIdx + i;
ListCell cell = _activeCells[i];
if (dataIdx < _totalItemsCount)
{
cell.gameObject.SetActive(true);
// Position cell explicitly in C#
float posY = - (dataIdx * (cellHeight + spacing));
cell.RectTransform.anchoredPosition = new Vector2(cell.RectTransform.anchoredPosition.x, posY);
// Bind corresponding dataset
var itemData = _dataList[dataIdx];
cell.Bind(dataIdx, itemData.Title, itemData.Description);
}
else
{
// Deactivate unused cells if content bounds are reached
cell.gameObject.SetActive(false);
}
}
}
private void OnDestroy()
{
scrollRect.onValueChanged.RemoveListener(OnScroll);
}
}
4. Performance Metrics: The Impact of Optimization
When you transition to a recyclable layout, you will notice immediate improvements in the Unity Profiler during scroll interactions:
- GC Allocations: Aside from the initial allocation during initialization, no additional object instantiation occurs during scrolling. Active GC Allocations drop to 0 bytes.
- Layout Calculation Overhead: Eliminating
LayoutRebuilder.Rebuildspikes ensures that the CPU time spent inCanvas.SendWillRenderCanvasesremains virtually unmeasurable (typically below 0.05ms). - Draw Calls (Render Batches): Since objects out of bounds do not exist in the scene, the rendering engine processes significantly fewer vertices, reducing GPU rendering time.
5. Summary & Best Practices for Production
For production-ready pipelines, you can extend this basic pattern with these additions:
- Variable Height Cells: For logs or chat messages where cells vary in height, calculate and cache individual cell offsets in a list before positioning. Look up coordinates using cumulative heights.
- Grid Layout Configurations: For grid views (e.g. inventory icons), modify coordinate calculations to place columns along the horizontal axis:
X = (index % columns) * cellWidthandY = -(index / columns) * cellHeight. - Asynchronous Asset Loading: When displaying heavy textures (e.g., character portraits), load them asynchronously (using Addressables, etc.) within the
Bindmethod. Be sure to link aCancellationTokento cancel pending load operations when a cell is warped, ensuring stale assets do not overwrite newly bound items.
UI stuttering is a noticeable issue that directly harms player experience. Transitioning from standard instantiation structures to a recyclable cell pattern ensures a smooth, consistent frame rate, even when handling lists with tens of thousands of items.