Mobile Computing · UI Frameworks

View Recycling and Virtualization in Long Scrolling Lists

How list widgets bound the number of mounted children while scrolling through unbounded content, what the recycling contract requires of item views, and where the reuse goes wrong.

A feed with ten thousand rows, described declaratively as a list of ten thousand children, would mount ten thousand platform views. Each UIView or View costs a few hundred bytes of object overhead before its content, each participates in measure and layout even when off-screen unless the framework explicitly skips it, and each holds a backing layer. The memory cost alone makes it untenable on a device with a few gigabytes shared across every process, and the layout cost makes it untenable regardless of memory.

The solution is to stop describing the list as children. A virtualized list is given a way to produce an item for an index on demand, a way to measure or estimate the extent of an item, and a scroll offset. It mounts only the items intersecting the viewport plus a small margin, and it recycles the platform objects of items that leave as items that enter. The description of the whole list still exists conceptually, but only a window of it is ever materialized.

The viewport, the margin, and the extent

The unit of virtualization is the item’s extent along the scroll axis. For fixed-height items the total content extent is the item count times the height, and mapping a scroll offset to an index is a division. For variable-height items the list must either measure items it has not yet mounted, or estimate, and both have consequences.

React Native’s FlatList and VirtualizedList accept a getItemLayout callback that returns an item’s length and offset. When supplied, the list can compute the visible index range arithmetically and scroll to an arbitrary index without mounting the items in between. When omitted, the list estimates extents from previously measured items, and the scrollbar thumb drifts as the estimate is corrected. That drift is why programmatic scrolling to a far index in an unmeasured list can land somewhere else: the target offset was computed from estimates that the actual measurement later contradicts.

Android’s RecyclerView takes the other approach: it lays out from the current scroll position outward, measuring each item as it goes, and maintains the laid-out extent incrementally. A jump to a distant position is therefore handled by the layout manager, which must decide whether to lay out the intervening items or to approximate the offset; a linear layout manager that has already laid out the intervening range knows its exact extents, and one that has not must either measure them or accept an approximation.

Flutter’s slivers are the same idea with the extent expressed as a protocol: a sliver reports how much it has consumed as it lays out children in order, and SliverList builds children lazily by index. Because the sliver protocol is a layout protocol, the virtualization is part of the layout pass rather than a separate adapter layer, and the list knows the exact geometry of every child it has laid out, which makes precise scroll-to-index possible for the region already measured.

What recycling actually reuses

Recycling reuses the platform object, not the content. A view holder that scrolls off the top is detached from the parent and placed in a pool; when an item enters from the bottom, a pooled holder is taken, its content is rebound to the new item, and it is reattached. The expensive parts, object allocation, view inflation, and the creation of child views and their layout parameters, happen once per pool entry rather than once per item.

The pool size is bounded by the viewport, not by the item count. A screen showing eight rows with a two-row margin needs about twelve holders, and the pool holds roughly that many. This is why the recycling approach uses constant memory regardless of list length, and why the constant is proportional to the viewport rather than to the data.

The binding step is where correctness lives. Rebinding must reset every mutable aspect of the holder: text, images, visibility of subviews, click listeners, checked state, background, and any animation in progress. A holder that was showing a selected row and is reused for an unselected one will display a stale highlight if the binding code only sets the highlight when selected and never clears it. Android’s adapter contract states this as an obligation to reset all state in the bind method, and the failure is common enough that it has a name in the framework’s documentation.

override fun onBindViewHolder(holder: RowHolder, position: Int) {
    val item = items[position]
    holder.title.text = item.title
    // Every mutable property must be assigned unconditionally; the holder
    // arrives in whatever state the row it previously displayed left it.
    holder.row.isSelected = item.selected
    holder.badge.isVisible = item.unread
    holder.itemView.setOnClickListener { onRowClick(item.id) }
}

The declarative frameworks solve the same problem differently. A keyed list in React or Compose does not have a rebinding step: the description for the item at a given position is produced fresh, and the reconciler or recomposer applies the differences. Stale state is impossible in the description because the description is new; stale state in the retained platform object is possible only if the object holds state the description does not mention, such as a text field’s caret. That is why the identity rules of Element Identity and State Retention Across Rebuilds matter more in long lists than anywhere else: a list that recycles by position while the framework preserves state by position will happily carry a half-typed value from row 12 to row 400.

Reuse, not reuse of identity

The distinction between reusing an object and reusing an identity is the crux of the design, and getting it wrong produces the classic symptom: an item that scrolls out and back returns with content from a different item, or a checkbox that reverts.

Recycling is safe because the object carries no identity of its own. Its identity is whatever the binding code says it is at the moment it is attached, and the pool is a cache of shapes rather than a cache of items. Frameworks that blur the two, by allowing a holder to retain a reference to the item it last displayed, create a window in which an asynchronous callback fires for an item that is no longer bound to the holder. The callback updates a view that is now showing something else. The standard discipline is to capture the item’s stable identifier at bind time, compare it on callback, and discard the result if it no longer matches, or to cancel the outstanding work when the holder is rebound.

Prefetching extends the same logic. A list that mounts a margin of rows beyond the viewport is pre-creating work for content the user has not seen. The margin is a bet on scroll direction and speed: too small and a fast fling reveals blank rows before content arrives, too large and the list spends memory and decode bandwidth on content that is never displayed. Frameworks express the margin in different units, as a count of items or as a distance in points, and the choice matters when item heights vary, because a fixed count of tall items can be several screens of prefetch.

Deceleration and the cost of the wrong window

Scroll velocity is not constant, and the window of mounted items has to anticipate it. A fling can move a list several thousand points in a fraction of a second. If the list mounts items only when they intersect the viewport, every frame during the fling reveals new content that was not prepared, and the frame budget is spent on mounting rather than on compositing.

Platforms solve this in two ways. The scroll view can report a predicted end position from the current velocity and deceleration rate, and the list can extend its window toward that position. Or the list can keep a ring of already-mounted items around the viewport and simply move them, accepting that content beyond the ring appears a frame or two late. iOS scroll views expose deceleration rate as a property with two named presets, normal and fast, and the prediction is computed from it; a list that hooks the scroll view’s delegate can use the same prediction to bias its prefetch.

The cost of getting this wrong is visible as a stutter that occurs only during fast scrolling and never during slow dragging, which is a useful diagnostic: slow dragging exercises the same mounting code but with enough time between frames to hide it.

Nested scrolling and the measurement deadlock

A list inside a scrollable parent is the case where the constraint protocol of Constraints Down, Sizes Up: Why Layout Is a Single Pass meets virtualization, and the combination is genuinely awkward. A virtualized list needs a bounded viewport to know how much to materialize; a scrollable parent offers an unbounded constraint in its scroll direction. The list therefore cannot be virtualized, because it does not know its own height, and it cannot compute its height without materializing every item.

Frameworks resolve this by requiring the nesting to be expressed in the framework’s own terms: a sliver inside a sliver, a nested scroll view with an explicit height, or a single list with heterogeneous item types instead of two nested lists. The last is usually the right answer, because a list of sections with headers as item types is one virtualized viewport rather than two competing ones, and it keeps the number of mounted views proportional to the screen rather than to the number of sections.

The degenerate case at the other end is a list with one item. Virtualization has overhead: a scrollable container, a pool, an adapter, a layout pass that computes extents. For a handful of rows the overhead exceeds the savings, and the sensible implementation is a plain column. Frameworks tend to provide both and leave the threshold to the developer, which is a reasonable division, since the threshold depends on item cost rather than on item count alone.

References

  1. Android Developers: Create Dynamic Lists with RecyclerView — Google
  2. React Native Documentation: Optimizing FlatList Configuration — Meta Open Source
  3. Flutter Documentation: SliverList and Lazy Building — Flutter project