Search code examples
c#xamllistviewcomboboxwinui-3

How to force combobox in listview to update when listview collection changes in WinUI 3?


I believe this is a quirk of how the ListView works. I have included a problem description what I think is going on, and my question: how do I get around it?

Problem Description

I have a ListView, with a ComboBox specified inside of the ItemTemplate. The ComboBox shows an editable property on the items displayed in the ListView.

Per the "tip" in the official documentation (under the heading "Item Selection"), I am handling the Loaded event to initialize the SelectedItem property in the ComboBox. (I am doing this because the data is being pulled from an asynchronous source and may not be available right away - I could not get data-binding in XAML alone to work).

When the page first loads, this works flawlessly. When I try to add items to the collection afterwards, however - I get some unexpected behavior... despite using the same function (SetItems() in the sample below) to do the updates both times. Using the print statement in the sample below, I have confirmed that when the page first loads, the output is as expected: each item in the list has an output line, and the SelectedValue in each ComboBox (which displays the Status property on each item in the example below) is correct.

When I update the list later (after the page and ListView initially load), however, by calling that same SetItem() function (perhaps after adding an item to the list), the output from that same print statement is NOT as expected. Only a single print statement appears, and prints a seemingly random item from the list, along with the incorrect Status. This is unexpected because I am clearing the ObservableCollection, and re-adding ALL items to it (including the new items).

In the page XAML:

<ListView ItemsSource="{x:Bind MyCollection}">
    <ListView.ItemTemplate>
        <DataTemplate x:DataType="models:MyDataType">
            <!-- Some things omitted for brevity -->
            <ComboBox x:Name="MyComboBox"
                      Tag="{x:Bind ID}"
                      SelectedValuePath="Status"
                      Loaded="MyComboBox_Loaded"></ComboBox>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

The code behind with collection, event handler, and collection-altering code:

// The collection.
public ObservableCollection<MyDataType> MyCollection { get; set; }

// The ComboBox Loaded event handler.
private void MyComboBox_Loaded(object sender, RoutedEventArgs e)
{
    // Get the ID of the item this CombBox is for.
    ComboBox comboBox = sender as ComboBox;
    string id = comboBox.Tag as string;

    // Get a reference to the item in the collection.
    MyDataType item = this.MyCollection.Where(i => i.ID == id).FirstOrDefault();

    // Initialize the ComboBox based on the item's property.
    comboBox.SelectedValue = item.Status;

    // Wire up the Selection Changed event (doing this after initialization prevents the handler from running on initialization).
    comboBox.SelectionChanged += this.MyComboBox_SelectionChanged;

    // Print statement for debugging.
    System.Diagnostics.Debug.WriteLine("ComboBox for item " + item.ID + " loaded and set to " + item.Status);
}

// The function used to update the observable collection (and thus bound listview).
public void SetItems(List<MyDataType> items)
{
    // Clear the collection and add the given items.
    this.MyCollection.Clear();
    items.ForEach(i => this.MyCollection.Add(i));
}

What I think is going on

I believe this is a quirk of how the ListView works. I remember reading somewhere from past experience with item repeaters in a different part of .NET that the UI for each item may not actually be created new each time; UI items may be recycled and reused for efficiency reasons. I am going to guess what is happening here is similar; and the "reused" items are not firing the Loaded event, because they were never re-loaded.

So how do I get around it, and force the ComboBoxes to update each time the collection is changed, like how it does when the page first loads?

UPDATE

The unexpected output from the print statement does not appear to be random - it appears to be consistently the last item in the ListView. So whenever the SetItem() function is called (after the initial time when the page loads), ONLY the ComboBox for the last item in the ListView fires its Loaded event. The ComboBox on the other items are scrambled, however... almost like they were all shifted up by one (and so have the Status of the next item in the ListView rather than the item they are on).


Solution

  • You don't need to use Loaded for this.

    Let me show you a simple example:

    MainPage.xaml

    <Page
        x:Class="ListViewWithComboBoxExample.MainPage"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:local="using:ListViewWithComboBoxExample"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:models="using:ListViewWithComboBoxExample.Models"
        x:Name="RootPage"
        Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
        mc:Ignorable="d">
    
        <Grid RowDefinitions="Auto,*">
            <Button
                Grid.Row="0"
                Click="AddRandomItemsButton_Click"
                Content="Add random item" />
            <ListView
                Grid.Row="1"
                ItemsSource="{x:Bind MyCollection, Mode=OneWay}">
                <ListView.ItemTemplate>
                    <DataTemplate x:DataType="models:MyDataType">
                        <!--
                            Using 'Binding' with 'ElementName' and 'Path' is the trick here
                            to access code-behind properties from the DataTemplate.
                        -->
                        <ComboBox
                            ItemsSource="{Binding ElementName=RootPage, Path=MyComboBoxOptions}"
                            SelectedValue="{x:Bind Status, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
                            Tag="{x:Bind ID, Mode=OneWay}" />
                    </DataTemplate>
                </ListView.ItemTemplate>
            </ListView>
        </Grid>
    </Page>
    

    MainPage.xaml.cs

    using ListViewWithComboBoxExample.Models;
    using Microsoft.UI.Xaml;
    using Microsoft.UI.Xaml.Controls;
    using System;
    using System.Collections.ObjectModel;
    
    namespace ListViewWithComboBoxExample;
    
    public sealed partial class MainPage : Page
    {
        public MainPage()
        {
            this.InitializeComponent();
        }
    
        public ObservableCollection<string> MyComboBoxOptions { get; set; } = new()
        {
            "Active",
            "Inactive",
            "Unknown",
        };
    
        // ObservableCollections notify the UI when their contents change,
        // not when the collection itself changes.
        // You need to instantiate this here or in the constructor.
        // Otherwise, the binding will fail.
        public ObservableCollection<MyDataType> MyCollection { get; } = new();
    
        private void SetItem(MyDataType item)
        {
            MyCollection.Add(item);
        }
    
        private void AddRandomItemsButton_Click(object sender, RoutedEventArgs e)
        {
            MyCollection.Clear();
    
            Random random = new();
            int itemsCount = random.Next(100);
    
            for (int i = 0; i < itemsCount; i++)
            {
                MyDataType newItem = new()
                {
                    ID = random.Next(100).ToString(),
                    Status = MyComboBoxOptions[random.Next(MyComboBoxOptions.Count)],
                };
    
                SetItem(newItem);
            }
        }
    }