Lists, Grids & Trees
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Almost every business screen is a collection on display: a list of books, a grid of loans, a tree of authors and their
titles. WPF handles all of them with one family of controls, the items controls, which turn a collection of data
objects into a collection of visual elements. This page explains that mechanism and then the four concrete controls
built on it: ListBox, ListView (with GridView), DataGrid and TreeView. It also covers UI virtualization, the
feature that decides whether a list of 100,000 rows opens instantly or freezes the window. The page was written against
.NET 10 LTS (latest patch 10.0.13) with C# 14, Visual Studio 2026 18.11.1, Windows App SDK 2.5.1 and
CommunityToolkit.Mvvm 8.4.2 on Windows 10 and Windows 11. .NET 11 is at release candidate 2 and general
availability is expected at .NET Conf, 10 to 13 November 2026; no change to these controls is documented for it at the
time of writing. WPF is the stack in focus; WinUI 3 and Windows Forms are compared in a table further down.
The running scenario is Bookshelf Desktop. The main window shows the books from an IBookRepository in a DataGrid,
a compact ListView of recent titles, and a TreeView that groups books by author, all driven by a BooksViewModel
and a BookViewModel built with the MVVM Toolkit. Sorting, filtering and grouping through ICollectionView, converters
and the full DataTemplate rules have their own page, titled Converters, Collection Views & Data Templates, and the
binding syntax is on the Data Binding page; here they appear only where the controls need them.
The items-control model
Every control that shows a list derives from ItemsControl. The class exists so that the questions "where do the items
come from", "how is one item drawn", "how are the items arranged" and "what wraps each item" each have exactly one
property that answers them.
| Question | Property | Notes |
|---|---|---|
Where do the items come from? |
|
The two are mutually exclusive: once |
How is one item drawn? |
|
Without any of them WPF calls |
How are the items arranged? |
|
|
What wraps each item? |
|
The container ( |
How are items grouped? |
|
Grouping adds header containers between items and interacts with virtualization (see below). |
Two ideas make everything else easier. First, the item is not the visual: the control creates a container per item,
puts the item (your BookViewModel) into the container as its DataContext, and applies the ItemTemplate inside it.
Second, containers are generated on demand, not when the collection is assigned. That on-demand generation is the
hook that virtualization uses.
Selector adds the selection API shared by ListBox, ListView, ComboBox and DataGrid:
| Property | Meaning |
|---|---|
|
The selected data object and its position. Both are two-way bindable. |
|
A value read from the selected item through a property path, for example the |
|
|
|
|
|
Type-ahead selection by a property of the item. |
A collection bound to ItemsSource should implement INotifyCollectionChanged, in practice ObservableCollection<T>.
Replacing items one by one raises one notification each and makes the control rebuild containers each time. To load a
fresh set, create a new collection and assign it to the property, rather than calling Clear and Add in a loop.
Source: Data templating overview
The view models used on this page
All the examples share the same small model. BookViewModel wraps a Book from Bookshelf.Core; AuthorNode groups
books for the tree.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Bookshelf.Core;
namespace Bookshelf.Desktop.ViewModels;
public partial class BookViewModel : ObservableObject
{
public BookViewModel(Book book)
{
Id = book.Id;
Title = book.Title;
Author = book.Author;
Year = book.Year;
}
public int Id { get; }
[ObservableProperty]
public partial string Title { get; set; }
[ObservableProperty]
public partial string Author { get; set; }
[ObservableProperty]
public partial int Year { get; set; }
[ObservableProperty]
public partial bool IsRead { get; set; }
[ObservableProperty]
public partial string? Summary { get; set; }
}
public partial class AuthorNode(string name, IEnumerable<BookViewModel> books) : ObservableObject
{
public string Name { get; } = name;
public ObservableCollection<BookViewModel> Books { get; } = new(books);
[ObservableProperty]
public partial bool IsExpanded { get; set; }
}
public partial class BooksViewModel(IBookRepository repository) : ObservableObject
{
public ObservableCollection<BookViewModel> Books { get; } = [];
public ObservableCollection<AuthorNode> Authors { get; } = [];
[ObservableProperty]
public partial BookViewModel? SelectedBook { get; set; }
[RelayCommand]
private async Task LoadAsync(CancellationToken cancellationToken)
{
IReadOnlyList<Book> loaded = await repository.GetAllAsync(cancellationToken);
Books.Clear();
Authors.Clear();
foreach (Book book in loaded)
{
Books.Add(new BookViewModel(book));
}
foreach (IGrouping<string, BookViewModel> group in Books.GroupBy(b => b.Author).OrderBy(g => g.Key))
{
Authors.Add(new AuthorNode(group.Key, group));
}
}
}
Source: ObservableProperty attribute
The Clear and Add loop above is acceptable for a few hundred books. For thousands, assign a new
ObservableCollection<BookViewModel> to a settable property instead, because each Add raises a notification the
control must process. ObservableCollection<T> has no bulk AddRange, and Microsoft Learn does not provide a
supported bulk-update collection for WPF; community or hand-written collections that raise one Reset notification are
a user pattern.
ListBox
ListBox is the simplest selector: a scrolling, selectable, virtualizing list of containers (ListBoxItem). Its power
is in the three templating properties.
<ListBox x:Name="RecentBooks"
ItemsSource="{Binding Books}"
SelectedItem="{Binding SelectedBook}"
SelectionMode="Single"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
AutomationProperties.Name="Recent books">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel IsItemsHost="True" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Margin" Value="4" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Width="140">
<TextBlock FontWeight="SemiBold" Text="{Binding Title}" TextTrimming="CharacterEllipsis" />
<TextBlock Foreground="Gray" Text="{Binding Author}" />
<TextBlock Foreground="Gray" Text="{Binding Year, StringFormat='Published {0}'}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
Source: ListBox
Swapping the panel to a WrapPanel turns the list into a wrapping gallery, but a WrapPanel is not a virtualizing
panel: every container is created up front. That trade-off is the reason virtualization has its own section.
DisplayMemberPath="Title" is the shortcut for a template that only shows one property as text; it cannot be combined
with an ItemTemplate. ItemStringFormat formats the text without a template.
ListView and GridView
ListView derives from ListBox and adds a View property. With no view it behaves like a list; with a GridView it
becomes a multi-column table with resizable, reorderable columns. It is the right choice when you need a read-only
or lightly interactive table with custom cell content, but not when you need cell editing, which is what DataGrid is for.
<ListView ItemsSource="{Binding Books}"
SelectedItem="{Binding SelectedBook}"
GridViewColumnHeader.Click="OnColumnHeaderClick"
AutomationProperties.Name="Books table">
<ListView.View>
<GridView AllowsColumnReorder="True">
<GridViewColumn Header="Title" Width="240" DisplayMemberBinding="{Binding Title}" />
<GridViewColumn Header="Author" Width="180" DisplayMemberBinding="{Binding Author}" />
<GridViewColumn Header="Year" Width="70" DisplayMemberBinding="{Binding Year}" />
<GridViewColumn Header="Read" Width="60">
<GridViewColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsRead}" IsEnabled="False" HorizontalAlignment="Center" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
Source: GridView overview
GridViewColumn member |
Purpose |
|---|---|
|
The column header content and its styling. |
|
A |
|
A |
|
A number, or |
ListView has no built-in sorting. A common recipe handles the header click and sets a SortDescription on the default
view of the collection:
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace Bookshelf.Desktop;
public partial class MainWindow
{
private string? _sortedBy;
private ListSortDirection _direction = ListSortDirection.Ascending;
private void OnColumnHeaderClick(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is not GridViewColumnHeader { Column.DisplayMemberBinding: Binding binding })
{
return;
}
string property = binding.Path.Path;
_direction = _sortedBy == property && _direction == ListSortDirection.Ascending
? ListSortDirection.Descending
: ListSortDirection.Ascending;
_sortedBy = property;
ICollectionView view = CollectionViewSource.GetDefaultView(((ListView)sender).ItemsSource);
view.SortDescriptions.Clear();
view.SortDescriptions.Add(new SortDescription(property, _direction));
}
}
Source: Sort and group data using a view
This code-behind is deliberately small and view-only, so it does not break the MVVM separation; a view model that owns an
ICollectionView is the alternative described on the page about collection views. Microsoft Learn does not provide a
ready-made sortable GridView.
DataGrid
DataGrid is the editable table. It derives from MultiSelector, is part of WPF in .NET (no extra package, unlike on
.NET Framework 3.5 where it was a separate toolkit download), and creates a DataGridRow per item and a
DataGridCell per column.
Columns
With AutoGenerateColumns="True" (the default) the grid creates a column per public property, choosing the column type
from the property type. That is useful for a quick look at data and almost never right for a product: header text is the
property name and ordering follows reflection. Declare columns explicitly.
| Column type | Displays and edits | Typical use |
|---|---|---|
|
Text in a |
Strings and numbers. |
|
A |
The Read flag. |
|
Text, edited in a |
Choosing a genre or status from a list. |
|
A |
A URL column. |
|
Any |
Cover thumbnails, buttons, rating stars, date pickers. |
Frequently used DataGrid and column settings:
| Setting | Effect |
|---|---|
|
Disables editing for the whole grid or one column. |
|
Show the "new item" row and allow the Delete key. |
|
Header-click sorting, the property used to sort a template column, and the current direction. |
|
Column interaction and columns that stay visible while scrolling horizontally. |
|
|
|
Visual striping and chrome. |
|
A panel under the selected (or every) row: |
|
What Ctrl+C copies: |
|
Row virtualization is on by default; column virtualization is off by default and helps grids with many columns. |
Bookshelf example
<DataGrid ItemsSource="{Binding Books}"
SelectedItem="{Binding SelectedBook, Mode=TwoWay}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
SelectionMode="Single"
SelectionUnit="FullRow"
AlternatingRowBackground="#10808080"
RowDetailsVisibilityMode="VisibleWhenSelected"
AutomationProperties.Name="Books grid">
<DataGrid.Columns>
<DataGridTextColumn Header="Title" Binding="{Binding Title, UpdateSourceTrigger=LostFocus}" Width="2*" />
<DataGridTextColumn Header="Author" Binding="{Binding Author}" Width="*" />
<DataGridTextColumn Header="Year" Binding="{Binding Year}" Width="80"
ElementStyle="{StaticResource Bookshelf.Style.RightAligned}" />
<DataGridCheckBoxColumn Header="Read" Binding="{Binding IsRead}" Width="Auto" />
<DataGridTemplateColumn Header="Open" SortMemberPath="Title" Width="Auto" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Content="Details"
Command="{Binding DataContext.OpenDetailsCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
<DataGrid.RowDetailsTemplate>
<DataTemplate>
<TextBlock Margin="8" TextWrapping="Wrap" Text="{Binding Summary, TargetNullValue='No summary yet.'}" />
</DataTemplate>
</DataGrid.RowDetailsTemplate>
</DataGrid>
Source: DataGrid
Notes on this markup: the Width values *, 2* and Auto are DataGridLength units (star, auto, pixel, and the
SizeToCells and SizeToHeader keywords); the template-column button reaches the view model through
RelativeSource because a cell’s DataContext is the row item (the OpenDetailsCommand would be a
[RelayCommand] on BooksViewModel, which opens the BookDetailsViewModel through the INavigationService).
Bookshelf.Style.RightAligned is a style in the app resources, covered on the page about resources.
Editing and its commit model
A DataGrid edits in two levels, and mixing them up is the main source of "my change did not save" reports:
-
Cell edit. Pressing F2, double-clicking or typing starts a cell edit (the cell swaps to its editing element). Enter or leaving the cell commits the cell, which writes the value to the item through the binding. Esc cancels.
-
Row edit. The row stays in edit mode until the user leaves the row; Enter on the last edited cell, or moving to another row, commits the row. Esc twice cancels the row. If the item implements
IEditableObject, the grid callsBeginEdit,EndEditandCancelEditon it, which is how a view model gets transactional semantics.
The events BeginningEdit, PreparingCellForEdit, CellEditEnding, RowEditEnding and AutoGeneratingColumn let
code-behind veto or react. Calling CommitEdit() on the grid commits the current edit; it is needed before a command
such as Save runs, because a toolbar button does not take focus away from the cell in every case. Note that
UpdateSourceTrigger on a column binding does not make a text cell write on each key press: a cell commits when
the edit ends, and PropertyChanged there would only help where you need earlier validation feedback.
Sorting, grouping and row details
Header clicks sort by adding a SortDescription to the collection view of ItemsSource, which is why a DataGrid
bound to a plain List<T> still sorts but never reflects later changes. Bound to an ObservableCollection<T> the
view re-sorts on insert only when live shaping is enabled (see the page on collection views). A template column sorts
only when SortMemberPath is set, as in the Open column above. Row details (RowDetailsTemplate) are created per
visible row, so keep the template light.
TreeView and HierarchicalDataTemplate
TreeView shows hierarchical data. It derives from ItemsControl (not Selector), so it has no SelectedIndex and
its SelectedItem is read-only. Each node is a TreeViewItem, itself a HeaderedItemsControl: Header is the
node content and Items its children.
A HierarchicalDataTemplate is a DataTemplate that also says where the children come from: its ItemsSource
property names the child collection of each item, and the children are then drawn with whichever template matches their
type. This is what lets one template per level describe an unbounded tree.
<TreeView ItemsSource="{Binding Authors}"
SelectedItemChanged="OnTreeSelectionChanged"
AutomationProperties.Name="Books by author">
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
</Style>
</TreeView.ItemContainerStyle>
<TreeView.Resources>
<HierarchicalDataTemplate DataType="{x:Type vm:AuthorNode}" ItemsSource="{Binding Books}">
<StackPanel Orientation="Horizontal">
<TextBlock FontWeight="SemiBold" Text="{Binding Name}" />
<TextBlock Margin="6,0,0,0" Foreground="Gray" Text="{Binding Books.Count, StringFormat='({0})'}" />
</StackPanel>
</HierarchicalDataTemplate>
<DataTemplate DataType="{x:Type vm:BookViewModel}">
<TextBlock Text="{Binding Title}" />
</DataTemplate>
</TreeView.Resources>
</TreeView>
using System.Windows;
using Bookshelf.Desktop.ViewModels;
namespace Bookshelf.Desktop;
public partial class MainWindow
{
// TreeView.SelectedItem is read-only and cannot be the target of a binding,
// so the view forwards the selection to the view model.
private void OnTreeSelectionChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (DataContext is BooksViewModel viewModel && e.NewValue is BookViewModel book)
{
viewModel.SelectedBook = book;
}
}
}
Source: TreeView overview
In the template, vm: maps to clr-namespace:Bookshelf.Desktop.ViewModels (declared on the window, see the XAML Language Fundamentals page). The Style for TreeViewItem binds IsExpanded to the node, so the view model controls
expansion; the same technique with IsSelected is the usual way to select a node from the view model. The style is
applied to every TreeViewItem, because each generated TreeViewItem copies its parent’s ItemContainerStyle unless
it sets its own (a leaf BookViewModel has no IsExpanded, which produces only a binding trace
message); a second ItemContainerStyle on the HierarchicalDataTemplate is the way to give children their own
style.
A TreeView with a large or deep data set should load children lazily. The documented tools are the Expanded event
of TreeViewItem and a view model that fills its children when IsExpanded first becomes true. A common
pattern is a placeholder child so the expander arrow shows before the real children exist. Microsoft Learn does not
provide a lazy-loading sample; it is a user pattern.
UI virtualization
A visual element is expensive: it costs memory for the container, the template tree and its bindings, plus layout time. UI virtualization creates containers only for the items currently visible (plus a small buffer) and discards or reuses them as the user scrolls. The data objects are not virtualized: the full collection stays in memory, and this is distinct from data virtualization, which fetches items from a source in pages and is not provided by WPF.
VirtualizingStackPanel is the virtualizing panel. Its behavior is controlled with the attached properties of
VirtualizingPanel (usable on any items control):
| Attached property | Default | Effect |
|---|---|---|
|
|
Turns container virtualization on or off. Set it explicitly to |
|
|
|
|
|
|
|
|
How much to realize before and after the viewport, in items, pages or pixels. |
|
|
Must be |
|
|
Per-item opt-out for a container that must keep its state. |
|
|
The content does not update while the thumb is dragged; useful for very large lists. |
<ListBox ItemsSource="{Binding Books}"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.CacheLength="1,1"
VirtualizingPanel.CacheLengthUnit="Page"
ScrollViewer.IsDeferredScrollingEnabled="False" />
Source: Optimizing performance: controls
When virtualization silently stops
WPF reports no error when virtualization stops working: the window just gets slow and memory grows. These are the usual causes and their fixes.
| Cause | What to do |
|---|---|
The list sits inside a |
The panel measures the list with infinite height, so every item is realized. Give the list a bounded size (a |
|
Switches to physical (pixel) scrolling and disables virtualization. Keep it |
|
These panels do not virtualize. Use |
Items are |
Items that are already visuals are their own containers and cannot be virtualized. Bind view models and use a template. |
Grouping without |
Group headers are realized with all their items. Enable the property. |
A custom |
The panel loses its scroll owner, is measured with unbounded size, and realizes all its children. |
|
Set |
Container state (focus, in-template edits) must survive scrolling |
With |
Recycling has one more consequence for code-behind: a DataGrid row that is scrolled out of view is removed or
reused, so ItemContainerGenerator.ContainerFromItem can return null for any item that is off screen. Select and
scroll with ScrollIntoView(item) instead of reaching into containers.
The data side matters as much. The collection can be updated from a background thread only when WPF knows which lock protects it:
using System.Collections.ObjectModel;
using System.Windows.Data;
namespace Bookshelf.Desktop.ViewModels;
public sealed class BookImportMonitor
{
private readonly object _lock = new();
public ObservableCollection<BookViewModel> Imported { get; } = [];
public BookImportMonitor()
{
// Lets a worker thread change the collection safely while the UI thread is reading it.
BindingOperations.EnableCollectionSynchronization(Imported, _lock);
}
public void Add(BookViewModel book)
{
lock (_lock)
{
Imported.Add(book);
}
}
}
Call EnableCollectionSynchronization on the UI thread. Without it, adding to an ObservableCollection from a worker
raises NotSupportedException with a message that the CollectionView does not support changes from a thread
different from the Dispatcher thread. An await in a command resumes on the UI thread, so the LoadAsync method earlier
needs no lock.
Choosing a control
| Need | Control | Reason |
|---|---|---|
A simple selectable list, a gallery with custom layout |
|
Smallest surface; virtualizes unless the panel is swapped. |
Read-mostly table with custom cells and resizable columns |
|
Light, easy cell templates, no editing. |
Editable tabular data, sorting, row details, many columns |
|
Built-in editing, commit model, frozen columns, clipboard. |
Hierarchical data |
|
Recursion by data type. |
A list inside a long page of other content |
|
No selection, no scrolling of its own; accept that nothing virtualizes. |
Very large data (hundreds of thousands of rows) |
|
UI virtualization is not enough; page or window the data in the repository. |
WPF, WinUI 3 and WinForms compared
| Aspect | WPF | WinUI 3 | Windows Forms |
|---|---|---|---|
Base model |
|
Same model: |
Controls own their items; bind through a |
List and table |
|
|
|
Tree |
|
|
|
Virtualization |
|
On by default in |
|
Binding |
|
|
|
Moving from WPF to WinUI 3 changes the list templates: a DataTemplate in WinUI needs x:DataType for {x:Bind},
GridView there is a tile list, and the column-grid behaviour of a WPF ListView has to be rebuilt (or replaced with a
grid library). Windows Forms virtualization is manual by design: in VirtualMode the control raises events such as
CellValueNeeded (DataGridView) or RetrieveVirtualItem (ListView) and never holds more than the visible rows.
What changed across .NET versions
The items controls have been stable since .NET Framework 4. DataGrid shipped in the box from .NET Framework 4 and was
carried into .NET Core 3.0 with the rest of WPF. The WPF what’s-new pages for .NET 8, 9 and 10 list no functional change to the
controls covered here that could be verified; the notable differences in modern .NET are the Fluent theme
(.NET 9) and its refreshed control visuals, which restyle these controls without changing their API (see the page
about themes). The Standard and Recycling virtualization modes behave as in .NET Framework.
Pitfalls
-
Setting
ItemsSourceafterItemshas content throws; use one or the other. -
SelectedItemscannot be bound. Forward selection from theSelectionChangedevent to the view model, or use a behavior; neither is provided by Microsoft. -
Setting a property on an item after the grid has sorted it will not re-sort unless live shaping is enabled.
-
DataGridedit not committed. CallCommitEdit()before a Save command and implementIEditableObjectif you need cancel semantics. -
Large
ClearandAddloops rebuild the containers once per item. Assign a new collection. -
DataContextin cells and tree items is the row item, not the window’s view model; useRelativeSourcefor commands, or an element-name reference. -
ListBoxselection and recycling. Highlight state belongs to the item’sIsSelected, not to a template element you set in code. -
Accessibility. Give grids a name (
AutomationProperties.Name), keep keyboard operation intact when replacing a template, and leaveIsTextSearchEnabledon so screen-reader users can jump by typing.
What Microsoft Learn does not provide
-
There is no page on UI virtualization of
TreeView, grouped lists or custom panels beyond the performance-optimization page and the API reference ofVirtualizingPanel; the failure list above is a consolidation of those and of common experience, and should be measured on your data. -
There is no supported bulk-update
ObservableCollection, no data-virtualization (paging) mechanism for WPF, and no bindableSelectedItemsorTreeView.SelectedItem. -
There is no official
ListViewsorting or lazy-loadingTreeViewsample for MVVM; the code on this page is a user pattern built on documented APIs. -
The WPF
DataGriddocumentation has not been updated for modern .NET; there is no conceptual guidance on large grids.
References
-
Collections and Iterators for the collection types behind
ItemsSource