web-dev-qa-db-fra.com

ComboBox ItemsSource changé => SelectedItem est ruiné

Ok, ça me dérange depuis un moment maintenant. Et je me demande comment les autres traitent le cas suivant:

<ComboBox ItemsSource="{Binding MyItems}" SelectedItem="{Binding SelectedItem}"/>

Le code de l'objet 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;
    }
}

Évidemment, lorsque la méthode RefreshMyItems() est appelée, la liste déroulante reçoit les événements de la collection modifiée, met à jour ses éléments et ne trouve pas le SelectedItem dans la collection actualisée => définit le SelectedItem sur null. Mais j'aurais besoin de la liste déroulante pour utiliser la méthode Equals afin de sélectionner le bon élément dans la nouvelle collection.

En d'autres termes, la collection ItemsSource contient toujours la MyItem correcte, mais il s'agit d'un objet new. Et je veux que la liste déroulante utilise quelque chose comme Equals pour la sélectionner automatiquement (cette opération est rendue encore plus difficile car la collection source appelle Clear() qui réinitialise la collection et, à ce stade, SelectedItem est défini sur null).

UPDATE 2 Avant de copier-coller le code ci-dessous, veuillez noter qu'il est loin d'être parfait! Et notez qu'il ne lie pas deux manières par défaut.

UPDATEJuste au cas où quelqu'un aurait le même problème (une propriété attachée telle que proposée par Pavlo Glazkov dans sa réponse):

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]);
                                           }
                                       };
        }

    }
}
22
Jefim

La variable standard ComboBox n'a pas cette logique. Et comme vous l'avez mentionné, SelectedItem devient null déjà après avoir appelé Clear, de sorte que ComboBox n'a aucune idée de votre intention d'ajouter ultérieurement le même élément et ne fait donc rien pour le sélectionner. Cela étant dit, vous devrez mémoriser l'élément précédemment sélectionné manuellement et, après la mise à jour, votre collection restaurera également la sélection manuellement. Habituellement, on fait quelque chose comme ça:

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 vous souhaitez appliquer le même comportement à tous les ComboBoxes (ou peut-être à tous les contrôles Selector), vous pouvez envisager de créer un Behavior (un comportement attaché ou de fusion ). Ce comportement souscrira aux événements SelectionChanged et CollectionChanged et enregistrera/restaurera l'élément sélectionné le cas échéant. 

10
Pavlo Glazkov

Ceci est le meilleur résultat de Google pour "wpf itemssource est égal à", donc à tous ceux qui essaient la même approche que dans la question, il fonctionne fonctionne tant que vous Fully implémentez des fonctions d'égalité . Voici une implémentation complète 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);
    }
}

J'ai réussi à tester cela avec un contrôle ListBox à sélection multiple, où listbox.SelectedItems.Add(item) ne parvenait pas à sélectionner l'élément correspondant, mais fonctionnait après avoir implémenté ce qui précède sur item.

15
nmclean

Malheureusement, lorsque vous définissez ItemsSource sur un objet Selector, il définit immédiatement SelectedValue ou SelectedItem sur null, même si l'élément correspondant se trouve dans le nouvel ItemsSource.

Peu importe si vous implémentez des fonctions Equals .. ou utilisez un type implicitement comparable pour votre SelectedValue.

Eh bien, vous pouvez enregistrer SelectedItem/Value avant de définir ItemsSource puis de restaurer. Mais que se passe-t-il s'il existe une liaison sur SelectedItem/Value qui sera appelée deux fois: Définie sur null Restaure l'original.

C'est une surcharge supplémentaire et même cela peut provoquer un comportement indésirable.

Voici une solution que j'ai faite. Travaillera pour n'importe quel objet Selector. Il suffit d'effacer la liaison SelectedValue avant de définir ItemsSource.

UPD: Ajout de try/finally pour protéger contre les exceptions dans les gestionnaires, et ajout d'une vérification nulle pour la liaison.

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

Voici un exemple XAML:

                <telerik:RadComboBox Grid.Column="1" x:Name="cmbDevCamera" DataContext="{Binding Settings}" SelectedValue="{Binding SelectedCaptureDevice}" 
                                     SelectedValuePath="guid" e:ComboBoxItemsSourceDecorator.ItemsSource="{Binding CaptureDeviceList}" >
                </telerik:RadComboBox>

Test de l'unité

Voici un cas de test unitaire prouvant que cela fonctionne. Mettez en commentaire le #define USE_DECORATOR pour constater l'échec du test lors de l'utilisation des liaisons standard.

#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;
        }


    }
}
8
norekhov

Vous pouvez envisager d'utiliser un convertisseur de valeur pour sélectionner le bon SlectedItem de votre collection.

0
biju

La vraie solution à ce problème consiste à ne pas supprimer les éléments de la nouvelle liste. C'EST À DIRE. N'effacez pas toute la liste, supprimez simplement ceux qui ne figurent pas dans la nouvelle liste, puis ajoutez ceux de la nouvelle liste qui ne figuraient pas dans l'ancienne liste.

Exemple.

Articles actuels dans la liste déroulante Pomme, orange, banane

Nouveaux éléments de boîte combinée Pomme, orange, poire

Pour peupler les nouveaux éléments Retirer banane et ajouter poire

Maintenant, l'archet de combo est toujours valide pour les éléments que vous auriez pu sélectionner et ceux-ci sont maintenant effacés s'ils ont été sélectionnés.

0
Greg Gacura

Je viens d'implémenter une substitution très simple et cela semble fonctionner visuellement. Cependant, cela supprime toute une série de logique interne. Je ne suis donc pas sûr que ce soit une solution sûre:

public class MyComboBox : ComboBox 
{
    protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
    {
        return;
    }
}

Donc, si vous utilisez ce contrôle, la modification de Items/ItemsSource n'affectera pas SelectedValue et Text - ils resteront inchangés.

S'il vous plaît laissez-moi savoir si vous trouvez des problèmes qu'il cause.

0
Philipp Munin