Search code examples
c#wpfxamlmvvmdependency-properties

SelectedItem is not properly set in the ViewModel even though the dependency callback method is being fired


I am extending ComboBox control to include a label in front, via dependency property as per below:

ComboBoxEx.xaml.cs

   public partial class ComboBoxEx : UserControl
    {
        public ComboBoxEx()
        {
            InitializeComponent();
        }

        public static readonly DependencyProperty LabelProperty = DependencyProperty.Register(
            nameof(Label), typeof(string), typeof(ComboBoxEx), new PropertyMetadata(default(string)));

        public string Label
        {
            get { return (string)GetValue(LabelProperty); }
            set { SetValue(LabelProperty, value); }
        }

        public static readonly DependencyProperty SelectedItemProperty = DependencyProperty.Register(
            nameof(SelectedItem), typeof(object), typeof(ComboBoxEx), new PropertyMetadata(default(object)), SelectedItemChanged);

        private static bool SelectedItemChanged(object value)
        {
            return true;
        }

        public object SelectedItem
        {
            get { return (object)GetValue(SelectedItemProperty); }
            set { SetValue(SelectedItemProperty, value); }
        }

        public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register(
            nameof(ItemsSource), typeof(IEnumerable), typeof(ComboBoxEx), new PropertyMetadata(default(IEnumerable)));

        public IEnumerable ItemsSource
        {
            get { return (IEnumerable)GetValue(ItemsSourceProperty); }
            set { SetValue(ItemsSourceProperty, value); }
        }

        public static readonly DependencyProperty DisplayMemberPathProperty = DependencyProperty.Register(
            nameof(DisplayMemberPath), typeof(string), typeof(ComboBoxEx), new PropertyMetadata(default(string)));

        public string DisplayMemberPath
        {
            get { return (string)GetValue(DisplayMemberPathProperty); }
            set { SetValue(DisplayMemberPathProperty, value); }
        }
    }

ComboBoxEx.xaml

<UserControl x:Class="LabelDoubleTextBox.ComboBoxEx"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:LabelDoubleTextBox"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" SharedSizeGroup="LabelGroup"/>
            <ColumnDefinition Width="Auto" SharedSizeGroup="ValueGroup"/>
        </Grid.ColumnDefinitions>
        <Label Grid.Column="0" Content="{Binding Label,RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:ComboBoxEx}}}"  Margin="5"/>
        <ComboBox Grid.Column="1" ItemsSource="{Binding ItemsSource,RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:ComboBoxEx}}}" 
                  SelectedItem="{Binding SelectedItem,RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:ComboBoxEx}}}"
                  DisplayMemberPath="{Binding DisplayMemberPath,RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:ComboBoxEx}}}"
                  Margin="5" />
    </Grid>
</UserControl>

Here's my ViewModels:

public class MainWindowVM:INotifyPropertyChanged
{
    private Person _selectedHead;
    public string VMValue => "This is a value";
    public string Terrify => "Dont be terrify";

    public List<Person> Persons { get; }


    public Person SelectedHead
    {
        get => _selectedHead;
        set
        {
            _selectedHead = value;
            OnPropertyChanged(nameof(SelectedHead));
        }
    }

    public MainWindowVM()
    {
        Persons = new List<Person>();
        Persons.Add(new Person("Allen", "A Photographer"));
        Persons.Add(new Person("Steven", "A Tycoon"));
        SelectedHead = Persons.First();
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }
}


public class Person
{
    public string Name { get; set; }
    public string Description { get; set; }

    public Person(string name, string description)
    {
        Name = name;
        Description = description;
    }
}

And my MainWindow.xaml:

<Window x:Class="LabelDoubleTextBox.MainWindow"
        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:local="clr-namespace:LabelDoubleTextBox"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <local:MainWindowVM/>
    </Window.DataContext>
    <Grid IsSharedSizeScope="True">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <local:ComboBoxEx Grid.Row="2" Label="5A99 perfect" ItemsSource="{Binding Persons}" SelectedItem="{Binding SelectedHead}" DisplayMemberPath="Name"/>
    </Grid>
</Window>

I put a breakpoint at SelectedItemChanged method in ComboBoxEx, and a breakpoint in _selectedHead = value; in MainWindowVM.

When I toggle the ComboBox, I expect that both the breakpoint will be hit, and that the SelectedItem is properly set ( as it seems properly bind). Alas, only the breakpoint in SelectedItemChanged method is being hit, the one _selectedHead = value; in MainWindowVM is not.

So it seems that the change in the UI is never being communicated to the ViewModel? Why?


Solution

  • The Binding

    SelectedItem="{Binding SelectedHead}"
    

    is by default OneWay. Either make it TwoWay, or declare the SelectedItem property with the metadata option BindsTwoWayByDefault:

    public static readonly DependencyProperty SelectedItemProperty =
        DependencyProperty.Register(
            nameof(SelectedItem), typeof(object), typeof(ComboBoxEx),
            new FrameworkPropertyMetadata(null,
                FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
    
    public object SelectedItem
    {
        get => GetValue(SelectedItemProperty);
        set => SetValue(SelectedItemProperty, value);
    }