I'm creating a small tool for a friend and myself using WinUI 3. This is mostly a learning activity, and as a rookie I feel I'm learning a lot and having fun. However, I'm running into some confusing behavior that I'm not sure I fully understand.
I am working with data that has the following structure:
- "Root":
- Item 1:
- Sub-Item
- Sub-Item
- ...
- Item 2:
- Sub-Item
- Sub-Item
- ...
- ...
I'd like to be able to view, edit, add, and remove the items in this structure with a simple UI.
For the sake of simplicity, let the "root" object be an ObservableCollection<ListItem>
, where ListItem
is defined below:
public class ListItem : BindableBase
{
private ObservableCollection<OString> _items;
private string _header;
public string Header { get { return _header; } set { SetProperty(ref _header, value); } }
public ObservableCollection<OString> Items { get { return _items; } set { SetProperty(ref _items, value); } }
public ListItem()
{
Header = "Header";
Items = new ObservableCollection<OString>();
}
}
public class OString : BindableBase
{
private string _value;
public string Value { get { return _value; } set { SetProperty(ref _value, value); } }
public OString()
{
Value = "Default String";
}
}
BindableBase
is a class that implements INotifyPropertyChanged
, and OString
is just a wrapper around string
that extends BindableBase
. I'm using the same BindableBase
that can be found on one of Microsoft's sample applications.
I've created a UserControl
that can display an ObservableCollection<OString>
called StringList
. Below is the XAML for the control.
<UserControl ... >
<!--xmlns:custom points to where OString is defined, omitted for brevity-->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{x:Bind Header}" VerticalAlignment="Center" HorizontalAlignment="Left"/>
<Button Name="AddItem" Click="AddItem_Click" Grid.Column="1" Content="+"/>
</Grid>
<ListView Name="StringList" ItemsSource="{x:Bind ItemsSource}" Grid.Row="1" SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="custom:OString" >
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Text="{x:Bind Value, Mode=TwoWay}" Grid.Column="0"/>
<Button Name="RemoveItem" Tag="{x:Bind}" Click="RemoveItem_Click" Grid.Column="1">-</Button>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
</UserControl>
The C# code for StringList.xaml.cs
is below.
public sealed partial class StringList : UserControl
{
public DependencyProperty HeaderProperty = DependencyProperty.Register(nameof(Header), typeof(string), typeof(DMActionList), new(""));
public DependencyProperty ItemsSourceProperty = DependencyProperty.Register(nameof(ItemsSource), typeof(ObservableCollection<OString>), typeof(DMActionList), new(new ObservableCollection<OString>()));
public string Header
{
get => (string)GetValue(HeaderProperty);
set => SetValue(HeaderProperty, value);
}
public ObservableCollection<OString> ItemsSource
{
get => (ObservableCollection<OString>)GetValue(ItemsSourceProperty);
set => SetValue(ItemsSourceProperty, value);
}
public StringList()
{
this.InitializeComponent();
}
private void AddItem_Click(object sender, RoutedEventArgs e)
{
ItemsSource?.Add(new());
}
private void RemoveItem_Click(object sender, RoutedEventArgs e)
{
if ((sender as Button).Tag != null && (sender as Button).Tag is OString)
{
ItemsSource?.Remove((sender as Button).Tag as OString);
}
}
}
StringList
works fine. When I use the control and bind properly, I am able to display an ObservableCollection<OString>
, edit the items, add new items, and remove items. I'm also able to edit items in code and the UI updates correctly. This takes care of each item in my data structure, but I need multiple StringList
s to fully realize the data I am working with.
So, I created a (poorly named) ListOfStringListTest
control. Here is the XAML for this control:
<UserControl ... >
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{x:Bind Header}" VerticalAlignment="Center" HorizontalAlignment="Left"/>
<Button Name="AddItem" Click="AddItem_Click" Grid.Column="1" Content="+"/>
</Grid>
<ListView Name="ListList" ItemsSource="{x:Bind ItemsSource}" Grid.Row="1" SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="local:ListItem" >
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Text="{x:Bind Header, Mode=TwoWay}" Grid.Column="0"/>
<Button Name="RemoveItem" Tag="{x:Bind}" Click="RemoveItem_Click" Grid.Column="1">-</Button>
</Grid>
<local:StringList Header="{x:Bind Header}" ItemsSource="{x:Bind Items}" Grid.Row="1"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
</UserControl>
The C# for this control, as well as the ListItem class, is below:
public sealed partial class ListOfStringListTest : UserControl
{
public DependencyProperty ItemsSourceProperty = DependencyProperty.Register(nameof(ItemsSource), typeof(ObservableCollection<ListItem>), typeof(ListOfStringListTest), new(new ObservableCollection<ListItem>()));
public DependencyProperty HeaderProperty = DependencyProperty.Register(nameof(Header), typeof(string), typeof(ListOfStringListTest), new("Header"));
public ObservableCollection<ListItem> ItemsSource
{
get => (ObservableCollection<ListItem>)GetValue(ItemsSourceProperty);
set => SetValue(ItemsSourceProperty, value);
}
public string Header
{
get => (string)GetValue(HeaderProperty);
set => SetValue(HeaderProperty, value);
}
public ListOfStringListTest()
{
this.InitializeComponent();
}
private void RemoveItem_Click(object sender, RoutedEventArgs e)
{
ItemsSource.Remove((sender as Button).Tag as ListItem);
}
private void AddItem_Click(object sender, RoutedEventArgs e)
{
ItemsSource.Add(new());
}
}
public class ListItem : BindableBase
{
private ObservableCollection<OString> _items;
private string _header;
public string Header { get { return _header; } set { SetProperty(ref _header, value); } }
public ObservableCollection<OString> Items { get { return _items; } set { SetProperty(ref _items, value); } }
public ListItem()
{
Header = "Header";
Items = new ObservableCollection<OString>();
}
}
Now this control doesn't function as I expect. I can add and remove items, and the buttons on each item appear to be creating new sub-items. I set a breakpoint on AddItem_Click()
in StringList
, and each time I click the button the number of items in the ItemsSource
list increases. However, the overall UI never updates. My guess is this has something to do with how a ListView sizes and places its items in a DataTemplate, but I'm not sure if that is the case or what to do about it.
Oddly, whenever I put similar 'list-of-list' controls inside a button's Flyout
, the StringList
control functions as expected. However, when I remove the item containing the StringList
control and then immediately add another new item to the list, the StringList
control appears the same with the same old data and sub-items. This data does not exist in the bound object in code, I've checked and it is blank. The add/remove buttons to control the sub-items doesn't work either. I'm sorry if my explanation is poor, I understand this is a confusing data structure and a really weird problem. To a rookie, it appears as if UI elements are being "reused," despite my add buttons always calling new().
Again, in the code-behind, the data appears correct with empty lists. However the UI is desynced or unbound from the data somehow.
I've tried recreating a similar data structure without using UserControl.
It appears to work, but accessing specific list/item/subitem data feels messy; adding and removing items is not straightforward, and I wasn't able to figure out how do sub-item removal.
I've also tried using BindableList<T>()
, but I ran into other issues as well. I've been having trouble debugging these issues since a crash just puts a mostly unhelpful exception message in App.g.i.cs
and leaves the debugger to break there.
I'm starting to suspect that UserControl
is not propagating some kind of event or property that a ListView
expects.
I'm sorry for the incredibly long post, I may not know enough to search the right keywords. Thank you for your time.
As I was researching this question further, Stack Overflow actually presented a really helpful and underrated answer.
ListView in WinUI maintains ListBox text after reset
Turns out this behavior is intended, somehow. It is called virtualization and container recycling. The method presented to disable this feature is to changed the ListView.ItemsPanel
property to an ItemsPanelTemplate
containing a StackPanel
, which does not implement virtualization.
I'm leaving this up in case anyone else has a similar problem. I do wonder, though, if I can maintain the performance benefits of virtualization but without container recycling. The documentation isn't super clear on this.
Relevant MS documentation: https://learn.microsoft.com/en-us/dotnet/desktop/wpf/advanced/optimizing-performance-controls?view=netframeworkdesktop-4.8 https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.itemscontrol?view=windowsdesktop-8.0 https://learn.microsoft.com/en-us/windows/uwp/debug-test-perf/optimize-gridview-and-listview https://learn.microsoft.com/en-us/windows/uwp/debug-test-perf/listview-and-gridview-data-optimization https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.virtualizingstackpanel.isvirtualizing?view=netframework-4.0
Again, thank you for your time, to anyone reading this.