Ok, this has been bugging me for a while now. And I wonder how others handle the following case:
Unfortunately when setting ItemsSource on a Selector object it immediately sets SelectedValue or SelectedItem to null even if corresponding item is in new ItemsSource.
No matter if you implement Equals.. functions or you use a implicitly comparable type for your SelectedValue.
Well, you can save SelectedItem/Value prior to setting ItemsSource and than restore. But what if there's a binding on SelectedItem/Value which will be called twice: set to null restore original.
That's additional overhead and even it can cause some undesired behavior.
Here's a solution which I made. Will work for any Selector object. Just clear SelectedValue binding prior to setting ItemsSource.
UPD: Added try/finally to protect from exceptions in handlers, also added null check for binding.
public static class ComboBoxItemsSourceDecorator
{
public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.RegisterAttached(
"ItemsSource", typeof(IEnumerable), typeof(ComboBoxItemsSourceDecorator), new PropertyMetadata(null, ItemsSourcePropertyChanged)
);
public static void SetItemsSource(UIElement element, IEnumerable value)
{
element.SetValue(ItemsSourceProperty, value);
}
public static IEnumerable GetItemsSource(UIElement element)
{
return (IEnumerable)element.GetValue(ItemsSourceProperty);
}
static void ItemsSourcePropertyChanged(DependencyObject element,
DependencyPropertyChangedEventArgs e)
{
var target = element as Selector;
if (element == null)
return;
// Save original binding
var originalBinding = BindingOperations.GetBinding(target, Selector.SelectedValueProperty);
BindingOperations.ClearBinding(target, Selector.SelectedValueProperty);
try
{
target.ItemsSource = e.NewValue as IEnumerable;
}
finally
{
if (originalBinding != null)
BindingOperations.SetBinding(target, Selector.SelectedValueProperty, originalBinding);
}
}
}
Here's a XAML example:
Here is a unit test case proving that it works. Just comment out the #define USE_DECORATOR to see the test fail when using the standard bindings.
#define USE_DECORATOR
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Permissions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Threading;
using FluentAssertions;
using ReactiveUI;
using ReactiveUI.Ext;
using ReactiveUI.Fody.Helpers;
using Xunit;
namespace Weingartner.Controls.Spec
{
public class ComboxBoxItemsSourceDecoratorSpec
{
[WpfFact]
public async Task ControlSpec ()
{
var comboBox = new ComboBox();
try
{
var numbers1 = new[] {new {Number = 10, i = 0}, new {Number = 20, i = 1}, new {Number = 30, i = 2}};
var numbers2 = new[] {new {Number = 11, i = 3}, new {Number = 20, i = 4}, new {Number = 31, i = 5}};
var numbers3 = new[] {new {Number = 12, i = 6}, new {Number = 20, i = 7}, new {Number = 32, i = 8}};
comboBox.SelectedValuePath = "Number";
comboBox.DisplayMemberPath = "Number";
var binding = new Binding("Numbers");
binding.Mode = BindingMode.OneWay;
binding.UpdateSourceTrigger=UpdateSourceTrigger.PropertyChanged;
binding.ValidatesOnDataErrors = true;
#if USE_DECORATOR
BindingOperations.SetBinding(comboBox, ComboBoxItemsSourceDecorator.ItemsSourceProperty, binding );
#else
BindingOperations.SetBinding(comboBox, ItemsControl.ItemsSourceProperty, binding );
#endif
DoEvents();
var selectedValueBinding = new Binding("SelectedValue");
BindingOperations.SetBinding(comboBox, Selector.SelectedValueProperty, selectedValueBinding);
var viewModel = ViewModel.Create(numbers1, 20);
comboBox.DataContext = viewModel;
// Check the values after the data context is initially set
comboBox.SelectedIndex.Should().Be(1);
comboBox.SelectedItem.Should().BeSameAs(numbers1[1]);
viewModel.SelectedValue.Should().Be(20);
// Change the list of of numbers and check the values
viewModel.Numbers = numbers2;
DoEvents();
comboBox.SelectedIndex.Should().Be(1);
comboBox.SelectedItem.Should().BeSameAs(numbers2[1]);
viewModel.SelectedValue.Should().Be(20);
// Set the list of numbers to null and verify that SelectedValue is preserved
viewModel.Numbers = null;
DoEvents();
comboBox.SelectedIndex.Should().Be(-1);
comboBox.SelectedValue.Should().Be(20); // Notice that we have preserved the SelectedValue
viewModel.SelectedValue.Should().Be(20);
// Set the list of numbers again after being set to null and see that
// SelectedItem is now correctly mapped to what SelectedValue was.
viewModel.Numbers = numbers3;
DoEvents();
comboBox.SelectedIndex.Should().Be(1);
comboBox.SelectedItem.Should().BeSameAs(numbers3[1]);
viewModel.SelectedValue.Should().Be(20);
}
finally
{
Dispatcher.CurrentDispatcher.InvokeShutdown();
}
}
public class ViewModel : ReactiveObject
{
[Reactive] public int SelectedValue { get; set;}
[Reactive] public IList Numbers { get; set; }
public ViewModel(IList numbers, int selectedValue)
{
Numbers = numbers;
SelectedValue = selectedValue;
}
}
public static class ViewModel
{
public static ViewModel Create(IList numbers, int selectedValue)=>new ViewModel(numbers, selectedValue);
}
///
/// From http://stackoverflow.com/a/23823256/158285
///
public static class ComboBoxItemsSourceDecorator
{
private static ConcurrentDictionary _Cache = new ConcurrentDictionary();
public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.RegisterAttached(
"ItemsSource", typeof(IEnumerable), typeof(ComboBoxItemsSourceDecorator), new PropertyMetadata(null, ItemsSourcePropertyChanged)
);
public static void SetItemsSource(UIElement element, IEnumerable value)
{
element.SetValue(ItemsSourceProperty, value);
}
public static IEnumerable GetItemsSource(UIElement element)
{
return (IEnumerable)element.GetValue(ItemsSourceProperty);
}
static void ItemsSourcePropertyChanged(DependencyObject element,
DependencyPropertyChangedEventArgs e)
{
var target = element as Selector;
if (target == null)
return;
// Save original binding
var originalBinding = BindingOperations.GetBinding(target, Selector.SelectedValueProperty);
BindingOperations.ClearBinding(target, Selector.SelectedValueProperty);
try
{
target.ItemsSource = e.NewValue as IEnumerable;
}
finally
{
if (originalBinding != null )
BindingOperations.SetBinding(target, Selector.SelectedValueProperty, originalBinding);
}
}
}
[SecurityPermission(SecurityAction.Demand, Flags = SecurityPermissionFlag.UnmanagedCode)]
public static void DoEvents()
{
DispatcherFrame frame = new DispatcherFrame();
Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background, new DispatcherOperationCallback(ExitFrame), frame);
Dispatcher.PushFrame(frame);
}
private static object ExitFrame(object frame)
{
((DispatcherFrame)frame).Continue = false;
return null;
}
}
}