wpf - ComboBox ItemsSource changed=> SelectedItem está arruinado
collections (8)
Ok, esto me ha estado molestando por un tiempo ahora. Y me pregunto cómo otros manejan el siguiente caso:
<ComboBox ItemsSource="{Binding MyItems}" SelectedItem="{Binding SelectedItem}"/>
El código del objeto 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;
}
}
Obviamente, cuando se llama al método RefreshMyItems()
, el cuadro combinado recibe los eventos de Colección modificada, actualiza sus elementos y no encuentra el elemento seleccionado en la colección actualizada => establece el elemento seleccionado en null
. Pero necesitaría el cuadro combinado para usar el método Equals
para seleccionar el elemento correcto en la nueva colección.
En otras palabras, la colección ItemsSource todavía contiene el MyItem
correcto, pero es un objeto new
. Y quiero que el cuadro combinado use algo como Equals
para seleccionarlo automáticamente (esto se hace aún más difícil porque primero la colección de origen llama a Clear()
que restablece la colección y ya en ese punto el elemento seleccionado se establece en null
).
ACTUALIZACIÓN 2 Antes de copiar y pegar el código a continuación, tenga en cuenta que está lejos de la perfección. Y tenga en cuenta que no se une de dos maneras por defecto.
ACTUALIZACIÓN En caso de que alguien tenga el mismo problema (una propiedad adjunta según lo propuesto por Pavlo Glazkov en su respuesta):
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]);
}
};
}
}
}
Puede considerar usar un valueconverter para seleccionar el SlectedItem correcto de su colección
El ComboBox
estándar no tiene esa lógica. Y como mencionó que SelectedItem
ya no es null
después de llamar a Clear
, el ComboBox
no tiene idea de su intención de agregar el mismo elemento más adelante y, por lo tanto, no hace nada para seleccionarlo. Una vez dicho esto, tendrá que memorizar el elemento previamente seleccionado manualmente y después de que haya actualizado su colección, restaure la selección también manualmente. Por lo general, se hace algo como esto:
public void RefreshMyItems()
{
var previouslySelectedItem = SelectedItem;
MyItems.Clear();
foreach(var myItem in LoadItems()) MyItems.Add(myItem);
SelectedItem = MyItems.SingleOrDefault(i => i.Id == previouslySelectedItem.Id);
}
Si desea aplicar el mismo comportamiento a todos los ComboBoxes
(o quizás a todos los controles Selector
), puede considerar crear un Behavior
(una propiedad adjunta o un comportamiento de mezcla ). Este comportamiento se suscribirá a los eventos SelectionChanged
y CollectionChanged
y guardará / restaurará el elemento seleccionado cuando corresponda.
Este es el resultado superior de google para "wpf itemssource igual a" en este momento, por lo que para cualquiera que intente el mismo enfoque que en la pregunta, funciona siempre y cuando implemente por completo las funciones de igualdad. Aquí hay una implementación completa de 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);
}
}
Lo probé con éxito con una selección múltiple ListBox, donde listbox.SelectedItems.Add(item)
no seleccionaba el elemento correspondiente, pero funcionó después de que implementé el item
anterior.
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 ")); }
}
}
}
Después de perder la mitad de mi cabeza y romper mi teclado varias veces, creo que para el control de combobox, es preferible no escribir la expresión de enlace selectedItem, Selectedindex y ItemsSource en el XAML ya que no podemos verificar si el ItemsSource ha cambiado, cuando usando la propiedad ItemsSource por supuesto.
En la ventana o el constructor de control de usuario configuro la propiedad ItemsSource de Combobox y luego en el controlador de eventos cargado de la ventana o control de usuario, configuro la expresión de enlace y funciona perfectamente. Si estableciera la expresión de enlace de ItemsSource en el XAML sin el elemento "selectedItem", no encontraría ningún controlador de eventos para establecer la expresión de enlace SelectedItem al tiempo que evita que combobox actualice la fuente con una referencia nula (selectedIndex = -1).
La verdadera solución a este problema es no eliminar los elementos que están en la nueva lista. ES DECIR. No borre toda la lista, simplemente elimine los que no están en la lista nueva y luego agregue los que tiene esa nueva lista que no estaban en la lista anterior.
Ejemplo.
Artículos actuales del cuadro combinado Apple, Orange, Banana
Nuevos elementos del cuadro combinado Apple, Orange, Pear
Para poblar los nuevos elementos Eliminar plátano y agregar pera
Ahora el arco combinado sigue siendo válido para los elementos que podría haber seleccionado y los elementos ahora se borran si fueron seleccionados.
Lamentablemente, al establecer ItemsSource en un objeto selector, inmediatamente establece SelectedValue o SelectedItem en nulo, incluso si el elemento correspondiente está en el nuevo ItemsSource.
No importa si implementa las funciones Equals .. o si usa un tipo implícitamente comparable para su SelectedValue.
Bueno, puede guardar SelectedItem / Value antes de establecer ItemsSource y restaurar. Pero, ¿qué pasa si hay un enlace en SelectedItem / Value que se llamará dos veces: se establece en null restaurar original.
Eso es una carga adicional e incluso puede causar un comportamiento no deseado.
Aquí hay una solución que hice. Funcionará para cualquier objeto Selector. Simplemente borre el enlace SelectedValue antes de establecer ItemsSource.
UPD: se agregó try / finally para protegerse de las excepciones en los manejadores, también se agregó verificación nula para el enlace.
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);
}
}
}
Aquí hay un ejemplo de XAML:
<telerik:RadComboBox Grid.Column="1" x:Name="cmbDevCamera" DataContext="{Binding Settings}" SelectedValue="{Binding SelectedCaptureDevice}"
SelectedValuePath="guid" e:ComboBoxItemsSourceDecorator.ItemsSource="{Binding CaptureDeviceList}" >
</telerik:RadComboBox>
Prueba de unidad
Aquí hay un caso de prueba de unidad que demuestra que funciona. Simplemente comente el #define USE_DECORATOR
para ver que la prueba falla al usar los enlaces estándar.
#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://.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;
}
}
}
Acabo de implementar una anulación muy simple y parece funcionar visualmente, sin embargo, esto corta la lógica interna, por lo que no estoy seguro de que sea una solución segura:
public class MyComboBox : ComboBox
{
protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
{
return;
}
}
Por lo tanto, si usa este control, cambiar Items / ItemsSource no afectará a SelectedValue y Text; permanecerán intactos.
Por favor, avíseme si encuentra problemas que causa.