Подтвердить что ты не робот

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

    }
}
4b9b3361

Ответ 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 ")); }
            }
        }
    }