14. Lists, virtualization, and performance

Goal

Why this matters

Prerequisites

Key namespaces

1. ItemsControl pipeline overview

Every items control follows the same data flow:

  1. Items/ItemsSource is wrapped in an ItemsSourceView that projects the data as IReadOnlyList<object?>, tracks the current item, and provides grouping hooks.
  2. ItemContainerGenerator materializes containers (ListBoxItem, TreeViewItem, etc.) for realized indices and recycles them when virtualization is enabled.
  3. ItemsPresenter hosts the actual panel (by default StackPanel or VirtualizingStackPanel) and plugs into ScrollViewer to handle scrolling.
  4. Templates render your view models inside each container.

Inspecting the view and generator helps when debugging:

var view = MyListBox.ItemsSourceView;
var current = view?.CurrentItem;

MyListBox.ItemContainerGenerator.Materialized += (_, e) =>
    Debug.WriteLine($"Realized range {e.StartIndex}..{e.StartIndex + e.Count - 1}");

MyListBox.ItemContainerGenerator.Dematerialized += (_, e) =>
    Debug.WriteLine($"Recycled {e.Count} containers");

Customize the items presenter when you need a different panel:

<ListBox Items="{Binding Orders}">
  <ListBox.ItemsPanel>
    <ItemsPanelTemplate>
      <VirtualizingStackPanel Orientation="Vertical"/>
    </ItemsPanelTemplate>
  </ListBox.ItemsPanel>
</ListBox>

ItemsPresenter can also be styled to add headers, footers, or empty-state placeholders while still respecting virtualization.

2. VirtualizingStackPanel in practice

VirtualizingStackPanel implements ILogicalScrollable, creating visuals only for the viewport (plus a configurable buffer). Keep virtualization intact by:

<ListBox Items="{Binding People}"
         SelectedItem="{Binding Selected}"
         Height="360"
         ScrollViewer.HorizontalScrollBarVisibility="Disabled">
  <ListBox.ItemsPanel>
    <ItemsPanelTemplate>
      <VirtualizingStackPanel Orientation="Vertical"
                              AreHorizontalSnapPointsRegular="True"
                              CacheLength="1"/>
    </ItemsPanelTemplate>
  </ListBox.ItemsPanel>
  <ListBox.ItemTemplate>
    <DataTemplate x:DataType="vm:PersonViewModel">
      <Grid ColumnDefinitions="Auto,*,Auto" Height="48" Margin="4">
        <TextBlock Grid.Column="0" Text="{CompiledBinding Id}" Width="56" HorizontalAlignment="Right"/>
        <StackPanel Grid.Column="1" Orientation="Vertical" Margin="12,0" Spacing="2">
          <TextBlock Text="{CompiledBinding FullName}" FontWeight="SemiBold"/>
          <TextBlock Text="{CompiledBinding Email}" FontSize="12" Foreground="#6B7280"/>
        </StackPanel>
        <Button Grid.Column="2"
                Content="Open"
                Command="{Binding DataContext.Open, RelativeSource={RelativeSource AncestorType=ListBox}}"
                CommandParameter="{Binding}"/>
      </Grid>
    </DataTemplate>
  </ListBox.ItemTemplate>
</ListBox>

3. Optimising item containers

Container recycling reuses realized ListBoxItem instances. Keep containers lightweight:

<Style Selector="ListBoxItem:selected TextBlock.title">
  <Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
</Style>

When you need to interact with containers manually, use ItemContainerGenerator.ContainerFromIndex/IndexFromContainer rather than walking the visual tree.

4. ItemsRepeater for custom layouts

ItemsRepeater separates data virtualization from layout so you can design custom grids or timelines.

<controls:ItemsRepeater Items="{Binding Photos}"
                        xmlns:controls="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls">
  <controls:ItemsRepeater.Layout>
    <controls:UniformGridLayout Orientation="Vertical" MinItemWidth="220" MinItemHeight="180"/>
  </controls:ItemsRepeater.Layout>
  <controls:ItemsRepeater.ItemTemplate>
    <DataTemplate x:DataType="vm:PhotoViewModel">
      <Border Margin="8" Padding="8" Background="#111827" CornerRadius="6">
        <StackPanel>
          <Image Source="{CompiledBinding Thumbnail}" Width="204" Height="128" Stretch="UniformToFill"/>
          <TextBlock Text="{CompiledBinding Title}" Margin="0,8,0,0"/>
        </StackPanel>
      </Border>
    </DataTemplate>
  </controls:ItemsRepeater.ItemTemplate>
</controls:ItemsRepeater>

5. Selection with SelectionModel

SelectionModel<T> tracks selection without relying on realized containers, making it virtualization-friendly.

public SelectionModel<PersonViewModel> PeopleSelection { get; } =
    new() { SelectionMode = SelectionMode.Multiple };

Bind directly:

<ListBox Items="{Binding People}"
         Selection="{Binding PeopleSelection}"
         Height="360"/>

6. Incremental loading patterns

Load data in pages to keep virtualization responsive. The view model owns the collection and exposes an async method that appends new items.

public sealed class LogViewModel : ObservableObject
{
    private readonly ILogService _service;
    private readonly ObservableCollection<LogEntryViewModel> _entries = new();
    private bool _isLoading;
    private int _pageIndex;
    private const int PageSize = 500;

    public LogViewModel(ILogService service)
    {
        _service = service;
        Entries = new ReadOnlyObservableCollection<LogEntryViewModel>(_entries);
        _ = LoadMoreAsync();
    }

    public ReadOnlyObservableCollection<LogEntryViewModel> Entries { get; }
    public bool HasMore { get; private set; } = true;

    public async Task LoadMoreAsync()
    {
        if (_isLoading || !HasMore)
            return;

        _isLoading = true;
        try
        {
            var batch = await _service.GetEntriesAsync(_pageIndex, PageSize);
            foreach (var entry in batch)
                _entries.Add(new LogEntryViewModel(entry));

            _pageIndex++;
            HasMore = batch.Count == PageSize;
        }
        finally
        {
            _isLoading = false;
        }
    }
}

Trigger loading when the user scrolls near the end:

private async void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
{
    if (DataContext is LogViewModel vm &&
        vm.HasMore &&
        e.Source is ScrollViewer scroll &&
        scroll.Offset.Y + scroll.Viewport.Height >= scroll.Extent.Height - 200)
    {
        await vm.LoadMoreAsync();
    }
}

While loading, display lightweight placeholders (e.g., skeleton rows) bound to IsLoading flags; keep them inside the same template so virtualization still applies.

7. Diagnosing virtualization issues

When scrolling stutters or memory spikes:

AppBuilder.Configure<App>()
    .UsePlatformDetect()
    .LogToTrace(LogEventLevel.Debug, new[] { LogArea.Layout, LogArea.Rendering, LogArea.Control })
    .StartWithClassicDesktopLifetime(args);

8. Practice exercises

  1. Inspect ItemsControl.ItemsSourceView for a dashboard list and log the current item index whenever selection changes. Explain how it differs from binding directly to ItemsSource.
  2. Convert a slow ItemsControl to a virtualized ListBox with VirtualizingStackPanel and record container creation counts before/after.
  3. Build an ItemsRepeater gallery with UniformGridLayout and compare realized item counts against a WrapPanel version.
  4. Replace SelectedItems with SelectionModel in a multi-select list, then synchronize the selection with a detail pane while keeping virtualization intact.
  5. Implement the incremental log viewer above, including skeleton placeholders during fetch, and capture frame-time metrics before and after the optimization.

Look under the hood (source bookmarks)

Check yourself

What's next