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
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");
}
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);
}
...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.
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:
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);
}
}
}
<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>
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");
}
}
}
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)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With