ComboBox ItemsSource changed => SelectedItem разрушен
Хорошо, это уже некоторое время подтачивало меня. И мне интересно, как другие обрабатывают следующий случай:
<ComboBox ItemsSource="{Binding MyItems}" SelectedItem="{Binding SelectedItem}"/>
Объектный код DataContext:
public ObservableCollection<MyItem> MyItems { get; set; }
public MyItem SelectedItem { get; set; }
public void RefreshMyItems()
{
MyItems.Clear();
foreach(var myItem in LoadItems()) MyItems.Add(myItem);
}
public class MyItem
{
public int Id { get; set; }
public override bool Equals(object obj)
{
return this.Id == ((MyItem)obj).Id;
}
}
Очевидно, что когда метод RefreshMyItems()
вызывается, поле со списком принимает события Collection Changed, обновляет его элементы и не находит SelectedItem в обновленной коллекции = > устанавливает для SelectedItem значение null
. Но мне понадобится поле со списком, чтобы использовать метод Equals
, чтобы выбрать правильный элемент в новой коллекции.
Другими словами - коллекция ItemsSource по-прежнему содержит правильный MyItem
, но это объект new
. И я хочу, чтобы поле со списком использовало что-то вроде Equals
для его автоматического выбора (это делается еще сложнее, потому что сначала коллекция источников вызывает Clear()
, которая сбрасывает коллекцию, и уже в этот момент значение SelectedItem установлено на null
)..
ОБНОВЛЕНИЕ 2 Перед тем, как скопировать код ниже, обратите внимание, что он далеко не совершенен! И обратите внимание, что по умолчанию он не связывает два пути.
ОБНОВЛЕНИЕ На всякий случай у кого-то такая же проблема (вложенное свойство, предложенное Павлом Глазковым в его ответе):
public static class CBSelectedItem
{
public static object GetSelectedItem(DependencyObject obj)
{
return (object)obj.GetValue(SelectedItemProperty);
}
public static void SetSelectedItem(DependencyObject obj, object value)
{
obj.SetValue(SelectedItemProperty, value);
}
// Using a DependencyProperty as the backing store for SelectedIte. This enables animation, styling, binding, etc...
public static readonly DependencyProperty SelectedItemProperty =
DependencyProperty.RegisterAttached("SelectedItem", typeof(object), typeof(CBSelectedItem), new UIPropertyMetadata(null, SelectedItemChanged));
private static List<WeakReference> ComboBoxes = new List<WeakReference>();
private static void SelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
ComboBox cb = (ComboBox) d;
// Set the selected item of the ComboBox since the value changed
if (cb.SelectedItem != e.NewValue) cb.SelectedItem = e.NewValue;
// If we already handled this ComboBox - return
if(ComboBoxes.SingleOrDefault(o => o.Target == cb) != null) return;
// Check if the ItemsSource supports notifications
if(cb.ItemsSource is INotifyCollectionChanged)
{
// Add ComboBox to the list of handled combo boxes so we do not handle it again in the future
ComboBoxes.Add(new WeakReference(cb));
// When the ItemsSource collection changes we set the SelectedItem to correct value (using Equals)
((INotifyCollectionChanged) cb.ItemsSource).CollectionChanged +=
delegate(object sender, NotifyCollectionChangedEventArgs e2)
{
var collection = (IEnumerable<object>) sender;
cb.SelectedItem = collection.SingleOrDefault(o => o.Equals(GetSelectedItem(cb)));
};
// If the user has selected some new value in the combo box - update the attached property too
cb.SelectionChanged += delegate(object sender, SelectionChangedEventArgs e3)
{
// We only want to handle cases that actually change the selection
if(e3.AddedItems.Count == 1)
{
SetSelectedItem((DependencyObject)sender, e3.AddedItems[0]);
}
};
}
}
}
Ответы
Ответ 1
Стандарт ComboBox
не имеет такой логики. И как вы уже сказали, SelectedItem
становится null
уже после того, как вы вызываете Clear
, поэтому ComboBox
не имеет представления о том, что вы намерены добавить тот же самый элемент позже, и поэтому он ничего не делает для его выбора. При этом вам нужно будет запомнить ранее выбранный элемент вручную, и после того, как вы обновите свою коллекцию, восстановите выбор вручную. Обычно это делается примерно так:
public void RefreshMyItems()
{
var previouslySelectedItem = SelectedItem;
MyItems.Clear();
foreach(var myItem in LoadItems()) MyItems.Add(myItem);
SelectedItem = MyItems.SingleOrDefault(i => i.Id == previouslySelectedItem.Id);
}
Если вы хотите применить одно и то же поведение ко всем элементам ComboBoxes
(или, возможно, всем Selector
), вы можете рассмотреть возможность создания Behavior
(прикрепленное свойство или поведение смешивания). Это поведение будет подписано на события SelectionChanged
и CollectionChanged
и при необходимости сохранит/восстановит выбранный элемент.
Ответ 2
Это самый верный результат Google для "wpf itemssource equals" прямо сейчас, поэтому для любого, кто пытается использовать тот же подход, что и в вопросе, он работает до тех пор, пока вы полностью выполняете функции равенства. Вот полная реализация MyItem:
public class MyItem : IEquatable<MyItem>
{
public int Id { get; set; }
public bool Equals(MyItem other)
{
if (Object.ReferenceEquals(other, null)) return false;
if (Object.ReferenceEquals(other, this)) return true;
return this.Id == other.Id;
}
public sealed override bool Equals(object obj)
{
var otherMyItem = obj as MyItem;
if (Object.ReferenceEquals(otherMyItem, null)) return false;
return otherMyItem.Equals(this);
}
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
public static bool operator ==(MyItem myItem1, MyItem myItem2)
{
return Object.Equals(myItem1, myItem2);
}
public static bool operator !=(MyItem myItem1, MyItem myItem2)
{
return !(myItem1 == myItem2);
}
}
Я успешно протестировал это с помощью множественного выбора ListBox, где listbox.SelectedItems.Add(item)
не смог выбрать соответствующий элемент, но работал после того, как я выполнил вышеуказанное на item
.
Ответ 3
К сожалению, при настройке ItemsSource на объект Selector он немедленно устанавливает SelectedValue или SelectedItem равным null, даже если соответствующий элемент находится в новом ItemsSource.
Независимо от того, реализуете ли вы функции Equals.. или используете неявно сопоставимый тип для вашего SelectedValue.
Хорошо, вы можете сохранить SelectedItem/Value до установки ItemsSource и восстановить. Но что, если есть привязка к SelectedItem/Value, которая будет вызываться дважды:
установить значение null
восстановить оригинал.
Это дополнительные накладные расходы и даже может привести к нежелательному поведению.
Вот решение, которое я сделал. Будет работать для любого объекта Selector. Просто очистите привязку SelectedValue до установки ItemsSource.
UPD: добавлено try/finally для защиты от исключений в обработчиках, а также добавлена нулевая проверка привязки.
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);
}
}
}
Здесь пример XAML:
<telerik:RadComboBox Grid.Column="1" x:Name="cmbDevCamera" DataContext="{Binding Settings}" SelectedValue="{Binding SelectedCaptureDevice}"
SelectedValuePath="guid" e:ComboBoxItemsSourceDecorator.ItemsSource="{Binding CaptureDeviceList}" >
</telerik:RadComboBox>
Unit Test
Вот пример unit test, подтверждающий, что он работает. Просто закомментируйте #define USE_DECORATOR
, чтобы увидеть, как сбой теста при использовании стандартных привязок.
#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<T> : ReactiveObject
{
[Reactive] public int SelectedValue { get; set;}
[Reactive] public IList<T> Numbers { get; set; }
public ViewModel(IList<T> numbers, int selectedValue)
{
Numbers = numbers;
SelectedValue = selectedValue;
}
}
public static class ViewModel
{
public static ViewModel<T> Create<T>(IList<T> numbers, int selectedValue)=>new ViewModel<T>(numbers, selectedValue);
}
/// <summary>
/// From http://stackoverflow.com/a/23823256/158285
/// </summary>
public static class ComboBoxItemsSourceDecorator
{
private static ConcurrentDictionary<DependencyObject, Binding> _Cache = new ConcurrentDictionary<DependencyObject, Binding>();
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;
}
}
}
Ответ 4
Вы можете использовать функцию valueconverter для выбора правильного SlectedItem из вашей коллекции
Ответ 5
Реальное решение этой проблемы состоит в том, чтобы не удалять элементы, находящиеся в новом списке. IE. Не очищайте весь список, просто удалите те, которые не находятся в новом списке, а затем добавьте те, которые имеют новый список, которые не были в старом списке.
Пример.
Текущие элементы Combo Box
Apple, Orange, Banana
Новые элементы с комбинированным полем
Apple, Orange, Pear
Заполнение новых элементов
Удалить банан и добавить грушу
Теперь комбинированный лук по-прежнему действителен для элементов, которые вы могли бы выбрать, и элементы теперь очищаются, если они были выбраны.
Ответ 6
Я просто реализовал очень простое переопределение и, похоже, работает визуально, однако это сокращает кучу внутренней логики, поэтому я не уверен, что это безопасное решение:
public class MyComboBox : ComboBox
{
protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
{
return;
}
}
Итак, если вы используете этот элемент управления, то изменение Items/ItemsSource не повлияет на SelectedValue и Text - они останутся нетронутыми.
Пожалуйста, дайте мне знать, если вы найдете проблемы, которые он вызывает.
Ответ 7
Потеряв половину головных волос и несколько раз разбив клавиатуру,
Я считаю, что для элемента управления combobox предпочтительнее не писать выражение selectItem, Selectedindex и ItemsSource в XAML, поскольку мы не можем проверить, изменился ли ItemSource при использовании свойства ItemsSource, конечно.
В конструкторе окна или пользовательского элемента управления я устанавливаю свойство ItemsSource для Combobox, а затем в обработчике загруженного события окна или пользовательского элемента управления я устанавливаю выражение привязки и отлично работает. Если бы я установил выражение привязки ItemsSource в XAML без "selectedItem", я бы не нашел обработчика событий, чтобы установить выражение привязки SelectedItem, не позволяя combobox обновлять источник с помощью нулевой ссылки (selectedIndex = -1).
Ответ 8
public MyItem SelectedItem { get; set; }
private MyItem selectedItem ;
// <summary>
///////
// </summary>
public MyItem SelectedItem
{
get { return selectedItem ; }
set
{
if (value != null && selectedItem != value)
{
selectedItem = value;
if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs("SelectedItem ")); }
}
}
}