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); }
}
}
}
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.
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.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.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>();
}
}
}
}