Search code examples
c#xamllistviewdata-bindinguwp

My ListView is showing the same item twice. How do I fix it?


I have a ComboBox that allows the user to select a category and a ListView that is bound to an ObservableCollection of items in the selected category. When the user selects a different category, the items in the collection are updated. Sometimes this works as expected, but sometimes the list of items is mangled. It shows a duplicate item when there should be two separate items.

The results seem to depend on which category I'm switching from. For example, if I switch from a category with no items to a category with two items, the same item is shown twice. But if I switch from a category with four items to that same category with two items, they are shown correctly.

Here is a repro:

MainPage.xaml

<Page
    x:Class="ListViewDuplicateItem_Binding.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:ListViewDuplicateItem_Binding">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition />
        </Grid.RowDefinitions>
        <ComboBox
            Grid.Row="0"
            Grid.Column="0"
            ItemsSource="{Binding ViewModel.Groups}"
            SelectedItem="{Binding ViewModel.SelectedGroup, Mode=TwoWay}" />
        <ListView
            Grid.Row="1"
            Grid.Column="0"
            ItemsSource="{Binding ViewModel.Widgets}"
            SelectedItem="{Binding ViewModel.SelectedWidget, Mode=TwoWay}">
            <ListView.ItemTemplate>
                <DataTemplate x:DataType="local:Widget">
                    <TextBlock Text="{Binding Id}" />
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
        <local:MyControl
            Grid.Row="1"
            Grid.Column="1"
            Text="{Binding ViewModel.SelectedWidget.Id, Mode=OneWay}" />
    </Grid>
</Page>

MainPage.xaml.cs

using Windows.UI.Xaml.Controls;

namespace ListViewDuplicateItem_Binding
{
    public sealed partial class MainPage : Page
    {
        public MainPage()
        {
            InitializeComponent();
            DataContext = this;
        }

        public MainViewModel ViewModel { get; } = new MainViewModel();
    }
}

MainViewModel.cs

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;

namespace ListViewDuplicateItem_Binding
{
    public class MainViewModel : INotifyPropertyChanged
    {
        private string _selectedGroup;
        private Widget _selectedWidget;

        public MainViewModel()
        {
            PropertyChanged += HomeViewModel_PropertyChanged;
            SelectedGroup = Groups.First();
        }

        public event PropertyChangedEventHandler PropertyChanged;

        public ObservableCollection<string> Groups { get; } = new ObservableCollection<string>(DataSource.AllGroups);

        public string SelectedGroup
        {
            get => _selectedGroup;
            set
            {
                if (_selectedGroup != value)
                {
                    _selectedGroup = value;
                    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedGroup)));
                }
            }
        }

        public Widget SelectedWidget
        {
            get => _selectedWidget;
            set
            {
                if (_selectedWidget != value)
                {
                    _selectedWidget = value;
                    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedWidget)));
                }
            }
        }

        public ObservableCollection<Widget> Widgets { get; } = new ObservableCollection<Widget>();

        private void HomeViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (e.PropertyName == nameof(SelectedGroup))
            {
                var widgetsToLoad = DataSource.GetWidgetsForGroup(SelectedGroup);
                // Add widgets in this group
                widgetsToLoad.Except(Widgets).ToList().ForEach(w => Widgets.Add(w));
                // Remove widgets not in this group
                Widgets.Except(widgetsToLoad).ToList().ForEach(w => Widgets.Remove(w));
                // Select the first widget
                if (SelectedWidget == null && Widgets.Any())
                {
                    SelectedWidget = Widgets.First();
                }
            }
        }
    }
}

DataSource.cs

using System.Collections.Generic;
using System.Collections.ObjectModel;

namespace ListViewDuplicateItem_Binding
{
    public static class DataSource
    {
        public static ObservableCollection<string> AllGroups { get; } = new ObservableCollection<string>
        {
            "First Widget",
            "First Two Widgets",
            "Last Two Widgets",
            "All Widgets",
            "None"
        };

        public static List<Widget> AllWidgets { get; } = new List<Widget>
        {
            new Widget()
            {
                Id = 1,
            },
            new Widget()
            {
                Id = 2,
            },
            new Widget()
            {
                Id = 3,
            },
            new Widget()
            {
                Id = 4,
            }
        };

        public static List<Widget> GetWidgetsForGroup(string group)
        {
            switch (group)
            {
                case "First Widget":
                    return new List<Widget> { AllWidgets[0] };

                case "First Two Widgets":
                    return new List<Widget> { AllWidgets[0], AllWidgets[1] };

                case "Last Two Widgets":
                    return new List<Widget> { AllWidgets[2], AllWidgets[3] };

                case "All Widgets":
                    return new List<Widget>(AllWidgets);

                default:
                    return new List<Widget>();
            }
        }
    }
}

Widget.cs

namespace ListViewDuplicateItem_Binding
{
    public class Widget
    {
        public int Id { get; set; }
    }
}

MyControl.xaml

<UserControl
    x:Class="ListViewDuplicateItem_Binding.MyControl"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <TextBox Text="{x:Bind Text, Mode=TwoWay}" />
</UserControl>

MyControl.xaml.cs

using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

namespace ListViewDuplicateItem_Binding
{
    public sealed partial class MyControl : UserControl
    {
        public static readonly DependencyProperty TextProperty = DependencyProperty.Register(nameof(Text), typeof(string), typeof(MyControl), new PropertyMetadata(null));

        public MyControl()
        {
            InitializeComponent();
        }

        public string Text
        {
            get { return (string)GetValue(TextProperty); }
            set { SetValue(TextProperty, value); }
        }
    }
}

Solution

  • Updating my project to use {x:Bind} (compiled bindings) appeared to resolve the issue, but a week later I unexpectedly started seeing duplicate items in my ListView again. This time I discovered three other factors that contributed to this issue.

    1. I added a FallbackValue to the TextBoxes bound to the SelectedItem so they would be cleared when no item was selected. If I remove the FallbackValue, the list items are not duplicated. However, I need this setting.
    2. I discovered that the order in which I add and remove items with the ObservableCollection bound to the ListView is important. If I add new items first and then remove old items, list items are duplicated. If I remove old items first and then add new items, the items are not duplicated. However, I'm using AutoMapper.Collection to update this collection, so I have no control over the order.
    3. A colleague suggested that this bug may be related to the ListView.SelectedItem. I discovered that if I set the selected item to null before removing it from the collection, list items are not duplicated. This is the solution I am now using.

    Here's an example:

        // This resolves the issue:
        if (!widgetsToLoad.Contains(SelectedWidget))
        {
            SelectedWidget = null;
        }
    
        // AutoMapper.Collection updates collections in this order. The issue does not occur
        // if the order of these two lines of code is reversed.
        {
            // Add widgets in this group
            widgetsToLoad.Except(Widgets).ToList().ForEach(w => Widgets.Add(w));
            // Remove widgets not in this group
            Widgets.Except(widgetsToLoad).ToList().ForEach(w => Widgets.Remove(w));
        }
    

    For a full repro, replace the code blocks in the question with these changes:

    MainPage.xaml

    <Page
        x:Class="ListViewDuplicateItem_Fallback.MainPage"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="using:ListViewDuplicateItem_Fallback">
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition />
                <ColumnDefinition />
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition />
            </Grid.RowDefinitions>
            <ComboBox
                Grid.Row="0"
                Grid.Column="0"
                ItemsSource="{x:Bind ViewModel.Groups}"
                SelectedItem="{x:Bind ViewModel.SelectedGroup, Mode=TwoWay}" />
            <ListView
                Grid.Row="1"
                Grid.Column="0"
                ItemsSource="{x:Bind ViewModel.Widgets}"
                SelectedItem="{x:Bind ViewModel.SelectedWidget, Mode=TwoWay}">
                <ListView.ItemTemplate>
                    <DataTemplate x:DataType="local:Widget">
                        <TextBlock Text="{x:Bind Id}" />
                    </DataTemplate>
                </ListView.ItemTemplate>
            </ListView>
            <TextBox
                Grid.Row="1"
                Grid.Column="1"
                Text="{x:Bind ViewModel.SelectedWidget.Id, Mode=OneWay, FallbackValue=''}" />
        </Grid>
    </Page>
    

    MainViewModel.cs

    using System.Collections.ObjectModel;
    using System.ComponentModel;
    using System.Linq;
    
    namespace ListViewDuplicateItem_Fallback
    {
        public class MainViewModel : INotifyPropertyChanged
        {
            private string _selectedGroup;
            private Widget _selectedWidget;
    
            public MainViewModel()
            {
                PropertyChanged += HomeViewModel_PropertyChanged;
                SelectedGroup = Groups.First();
            }
    
            public event PropertyChangedEventHandler PropertyChanged;
    
            public ObservableCollection<string> Groups { get; } = new ObservableCollection<string>(DataSource.AllGroups);
    
            public string SelectedGroup
            {
                get => _selectedGroup;
                set
                {
                    if (_selectedGroup != value)
                    {
                        _selectedGroup = value;
                        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedGroup)));
                    }
                }
            }
    
            public Widget SelectedWidget
            {
                get => _selectedWidget;
                set
                {
                    if (_selectedWidget != value)
                    {
                        _selectedWidget = value;
                        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedWidget)));
                    }
                }
            }
    
            public ObservableCollection<Widget> Widgets { get; } = new ObservableCollection<Widget>();
    
            private void HomeViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
            {
                if (e.PropertyName == nameof(SelectedGroup))
                {
                    var widgetsToLoad = DataSource.GetWidgetsForGroup(SelectedGroup);
    
                    // This resolves the issue:
                    //if (!widgetsToLoad.Contains(SelectedWidget))
                    //{
                    //    SelectedWidget = null;
                    //}
    
                    // AutoMapper.Collection updates collections in this order. The issue does not occur
                    // if the order of these two lines of code is reversed. I do not simply clear the
                    // collection and reload it because this clears the selected item even when it is in
                    // both groups, and the animation is much smoother if items are not removed and reloaded.
                    {
                        // Add widgets in this group
                        widgetsToLoad.Except(Widgets).ToList().ForEach(w => Widgets.Add(w));
                        // Remove widgets not in this group
                        Widgets.Except(widgetsToLoad).ToList().ForEach(w => Widgets.Remove(w));
                    }
    
                    // Select the first widget
                    if (SelectedWidget == null && Widgets.Any())
                    {
                        SelectedWidget = Widgets.First();
                    }
                }
            }
        }
    }
    

    DataSource.cs

    using System.Collections.Generic;
    using System.Linq;
    
    namespace ListViewDuplicateItem_Fallback
    {
        public static class DataSource
        {
            public static List<string> AllGroups { get; set; } = new List<string> { "Group 1", "Group 2", "Group 3" };
    
            public static List<Widget> AllWidgets { get; set; } = new List<Widget>(Enumerable.Range(1, 11).Select(widgetId => new Widget { Id = widgetId }));
    
            public static List<Widget> GetWidgetsForGroup(string group)
            {
                switch (group)
                {
                    case "Group 1":
                        return AllWidgets.Take(4).ToList();
    
                    case "Group 2":
                        return AllWidgets.Skip(4).Take(4).ToList();
    
                    case "Group 3":
                        return AllWidgets.Take(1).Union(AllWidgets.Skip(8).Take(3)).ToList();
    
                    default:
                        return new List<Widget>();
                }
            }
        }
    }