Search code examples
c#windowsxamldata-bindingwinui-3

Updating a ListView of ListViews in WinUI 3


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 StringLists 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.


Solution

  • 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.