-->

How to preserve TwoWay binding of CurrentItem when

2019-02-12 04:51发布

问题:

Lets say we got a simple VM class

public class PersonViewModel : Observable
    {
        private Person m_Person= new Person("Mike", "Smith");

        private readonly ObservableCollection<Person> m_AvailablePersons =
            new ObservableCollection<Person>( new List<Person> {
               new Person("Mike", "Smith"),
               new Person("Jake", "Jackson"),                                                               
        });

        public ObservableCollection<Person> AvailablePersons
        {
            get { return m_AvailablePersons; }
        }

        public Person CurrentPerson
        {
            get { return m_Person; }
            set
            {
                m_Person = value;
                NotifyPropertyChanged("CurrentPerson");
            }
        }
    }

It would be enough to successfully databind to a ComboBox for example like this:

<ComboBox ItemsSource="{Binding AvailablePersons}" 
          SelectedValue="{Binding Path=CurrentPerson, Mode=TwoWay}" />

Notice that Person has Equals overloaded and when I set CurrentPerson value in ViewModel it causes combobox current item to display new value.

Now lets say I want to add sorting capabilities to my view using CollectionViewSource

 <UserControl.Resources>
        <CollectionViewSource x:Key="PersonsViewSource" Source="{Binding AvailablePersons}">
            <CollectionViewSource.SortDescriptions>
                <scm:SortDescription PropertyName="Surname" Direction="Ascending" />
            </CollectionViewSource.SortDescriptions>
        </CollectionViewSource>
    </UserControl.Resources>

Now combobox items source binding will look like this:

<ComboBox ItemsSource="{Binding Source={StaticResource PersonsViewSource}}"
          SelectedValue="{Binding Path=CurrentPerson, Mode=TwoWay}" />    

And it will be indeed sorted (if we add more items its clearly seen).

However when we change CurrentPerson in VM now (before with clear binding without CollectionView it worked fine) this change isn't displayed in bound ComboBox.

I believe that after that in order to set CurrentItem from VM we have to somehow access the View (and we dont go to View from ViewModel in MVVM), and call MoveCurrentTo method to force View display currentItem change.

So by adding additional view capabilities (sorting ) we lost TwoWay binding to existing viewModel which I think isn't expected behaviour.

Is there a way to preserve TwoWay binding here ? Or maybe I did smth wrong.

EDIT: actually situation is more complicated then it may appear, when I rewrite CurrentPerson setter like this:

set
{
    if (m_AvailablePersons.Contains(value)) {
       m_Person = m_AvailablePersons.Where(p => p.Equals(value)).First();
    }
    else throw new ArgumentOutOfRangeException("value");
    NotifyPropertyChanged("CurrentPerson");

}

it works fine!

Its buggy behaviour, or is there an explanation? For some reasons even though Equals is overloaded it requires reference equality of person object.

I really don't understand why It needs reference equality so I am adding a bounty for someone who can explain why normal setter doesn't work, when Equal method is overloaded which can clearly be seen in "fixing" code that uses it

回答1:

There are 2 problems ganging up on you, but you have highlighted a real problem with using CollectionViewSource with a ComboBox. I am still looking for alternatives to fix this in a "better way", but your setter fix avoids the problem for good reason.

I have reproduced your example in full detail to confirm the problem and a theory about the cause.

ComboBox binding to CurrentPerson does not use the equals operator to find a match IF YOU USE SelectedValue INSTEAD OF SelectedItem. If you breakpoint your override bool Equals(object obj) you will see it is not hit when you change the selection.

By changing your setter to the following, you are finding a specific matching object, using your Equals operator, so a subsequent value compare of 2 objects will work.

set
{
    if (m_AvailablePersons.Contains(value)) {
       m_Person = m_AvailablePersons.Where(p => p.Equals(value)).First();
    }
    else throw new ArgumentOutOfRangeException("value");
    NotifyPropertyChanged("CurrentPerson");

}

Now the really interesting result:

Even if you change your code to use SelectedItem, it will work for a normal binding to the list but still fail for any binding to the sorted view!

I added debug output to the Equals method and even though matches were found, they were ignored:

public override bool Equals(object obj)
{
    if (obj is Person)
    {
        Person other = obj as Person;
        if (other.Firstname == Firstname && other.Surname == Surname)
        {
            Debug.WriteLine(string.Format("{0} == {1}", other.ToString(), this.ToString()));
            return true;
        }
        else
        {
            Debug.WriteLine(string.Format("{0} <> {1}", other.ToString(), this.ToString()));
            return false;
        }
    }
    return base.Equals(obj);
}

My conclusion...

...is that behind the scenes the ComboBox is finding a match, but because of the presence of the CollectionViewSource between it and the raw data it is then ignoring the match and comparing objects instead (to decide which one was selected). From memory a CollectionViewSource manages its own current selected item, so if you do not get an exact object match it will never work using a CollectionViewSource with a ComboxBox.

Basically your setter change works because it guarantees an object match on the CollectionViewSource, which then guarantees an object match on the ComboBox.

Test code

The full test code is below for those that want to play (sorry about the code-behind hacks, but this was just for testing and not MVVM).

Just create a new Silverlight 4 application and add these files/changes:

PersonViewModel.cs

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
namespace PersonTests
{
    public class PersonViewModel : INotifyPropertyChanged
    {
        private Person m_Person = null;

        private readonly ObservableCollection<Person> m_AvailablePersons =
            new ObservableCollection<Person>(new List<Person> {
               new Person("Mike", "Smith"),
               new Person("Jake", "Jackson"),                                                               
               new Person("Anne", "Aardvark"),                                                               
        });

        public ObservableCollection<Person> AvailablePersons
        {
            get { return m_AvailablePersons; }
        }

        public Person CurrentPerson
        {
            get { return m_Person; }
            set
            {
                if (m_Person != value)
                {
                    m_Person = value;
                    NotifyPropertyChanged("CurrentPerson");
                }
            }

            //set // This works
            //{
            //  if (m_AvailablePersons.Contains(value)) {
            //     m_Person = m_AvailablePersons.Where(p => p.Equals(value)).First();
            //  }
            //  else throw new ArgumentOutOfRangeException("value");
            //  NotifyPropertyChanged("CurrentPerson");
            //}
        }

        private void NotifyPropertyChanged(string name)
        {
            if (PropertyChanged != null)
            {
                PropertyChanged(this, new PropertyChangedEventArgs(name));
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;
    }

    public class Person
    {
        public string Firstname { get; set; }
        public string Surname { get; set; }

        public Person(string firstname, string surname)
        {
            this.Firstname = firstname;
            this.Surname = surname;
        }

        public override string ToString()
        {
            return Firstname + "  " + Surname;
        }

        public override bool Equals(object obj)
        {
            if (obj is Person)
            {
                Person other = obj as Person;
                if (other.Firstname == Firstname && other.Surname == Surname)
                {
                    Debug.WriteLine(string.Format("{0} == {1}", other.ToString(), this.ToString()));
                    return true;
                }
                else
                {
                    Debug.WriteLine(string.Format("{0} <> {1}", other.ToString(), this.ToString()));
                    return false;
                }
            }
            return base.Equals(obj);
        }
    }
}

MainPage.xaml

<UserControl x:Class="PersonTests.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:scm="clr-namespace:System.ComponentModel;assembly=System.Windows" mc:Ignorable="d"
    d:DesignHeight="300" d:DesignWidth="400">
    <UserControl.Resources>
        <CollectionViewSource x:Key="PersonsViewSource" Source="{Binding AvailablePersons}">
            <CollectionViewSource.SortDescriptions>
                <scm:SortDescription PropertyName="Surname" Direction="Ascending" />
            </CollectionViewSource.SortDescriptions>
        </CollectionViewSource>
    </UserControl.Resources>
    <StackPanel x:Name="LayoutRoot" Background="LightBlue" Width="150">
        <!--<ComboBox ItemsSource="{Binding AvailablePersons}"
              SelectedItem="{Binding Path=CurrentPerson, Mode=TwoWay}" />-->
        <ComboBox ItemsSource="{Binding Source={StaticResource PersonsViewSource}}"
          SelectedItem="{Binding Path=CurrentPerson, Mode=TwoWay}" />
        <Button Content="Select Mike Smith" Height="23" Name="button1" Click="button1_Click" />
        <Button Content="Select Anne Aardvark" Height="23" Name="button2" Click="button2_Click" />
    </StackPanel>
</UserControl>

MainPage.xaml.cs

using System.Windows;
using System.Windows.Controls;

namespace PersonTests
{
    public partial class MainPage : UserControl
    {
        public MainPage()
        {
            InitializeComponent();
            this.DataContext = new PersonViewModel();
        }

        private void button1_Click(object sender, RoutedEventArgs e)
        {
            (this.DataContext as PersonViewModel).CurrentPerson = new Person("Mike", "Smith");
        }

        private void button2_Click(object sender, RoutedEventArgs e)
        {
            (this.DataContext as PersonViewModel).CurrentPerson = new Person("Anne", "Aardvark");

        }
    }
}


回答2:

Have you considered using a CollectionView and set IsSynchronizedWithCurrentItem on the combobox?

That is what I would do - instead of having your CurrentPerson property you have the selected person on your collectionView.CurrentItem and the combobox following currentitem on the collectionview.

I Have used collectionview with sorting and grouping without problems - and you get a nice decoupling from ui with it.

I would move the collectionview to code and bind to it there

public ICollectionView AvailablePersonsView {get;private set;}

in ctor:

AvailablePersonsView = CollectionViewSource.GetDefaultView(AvailablePersons)



回答3:

The TwoWay binding works as it should, but ComboBox doesn't update itself on the UI when you set SelectedItem or SelectedIndex from code. If you want this functionality, just extend ComboBox and listen to SelectionChanged inherited from Selector or if you only want to set an initial selection, do it on Loaded.



回答4:

I highly recommend the use of ComboBoxExtensions by Kyle McClellan of Microsoft, found here.

You can declare a datasource for your ComboBox in XAML - and it's far more flexible and usable in async modes.

Basically the solution, largely, is NOT to use CollectionViewSource for ComboBoxes. You can do the sorting on the server side query.