wpfmvvmbinding

How can I update the source of a binding when cancelling update in DependencyProperty's CoerceValueCallback?


I have a control (named GridView in the example below) and a view model that are bound through 2-way binding on their SelectedValue property. I want to forbid certain values for that property in the GridView control through the use of a CoerceValueCallback.

When the binding pushes that invalid value (42 in this example) into the DependencyProperty, the CoerceValue method discards that value and the control keeps its previous value as expected. Unfortunately, because the DependencyProperty has not changed the binding does not update the source value in the view model.

How can I update the property in the view model with the value in the control when cancelling the property value change?

class ViewModel : INotifyPropertyChanged
{
    public int? SelectedValue
    {
        get { return _selectedValue; }
        set
        {
            _selectedValue = value;
            RaisePropertyChanged("SelectedValue");
        }
    }
    private int? _selectedValue;

    public event PropertyChangedEventHandler PropertyChanged;
    private void RaisePropertyChanged(string name)
    {
        var propertyChanged = PropertyChanged;
        if (propertyChanged != null)
        {
            propertyChanged(this, new PropertyChangedEventArgs(name));
        }
    }
}

class GridView : FrameworkElement
{
    public object SelectedValue
    {
        get { return (object)GetValue(SelectedValueProperty); }
        set { SetValue(SelectedValueProperty, value); }
    }

    public static readonly DependencyProperty SelectedValueProperty =
        DependencyProperty.Register("SelectedValue", typeof(object), typeof(GridView),
            new PropertyMetadata(null, null, new CoerceValueCallback(CoerceValue)));

    private static object CoerceValue(DependencyObject d, object baseValue)
    {
        // forbid value 42 and return previous value
        if (Equals(baseValue, 42))
        {
            return d.GetValue(GridView.SelectedValueProperty);
        }
        return baseValue;
    }
}

    [STAThread]
    static void Main()
    {
        ViewModel vm = new ViewModel();
        GridView grid = new GridView();
        Binding binding =  new Binding("SelectedValue") { Source = vm, Mode = BindingMode.TwoWay };
        grid.SetBinding(GridView.SelectedValueProperty, binding);

        vm.SelectedValue = 12;
        Console.WriteLine("should be 12: {0} = {1}", grid.SelectedValue, vm.SelectedValue);
        grid.SelectedValue = 23;
        Console.WriteLine("should be 23: {0} = {1}", grid.SelectedValue, vm.SelectedValue);
        vm.SelectedValue = 42;
        Console.WriteLine("should still be 23: {0} = {1}", grid.SelectedValue, vm.SelectedValue);
    }

I have tried

    var binding = BindingOperations.GetBindingExpression(d,
                      GridView.SelectedValueProperty);
    binding.UpdateSource();

in the CoerceValue method to no avail. I even tried the previous 2 lines with Dispatcher.CurrentDispatcher.BeginInvoke(updateSource, DispatcherPriority.DataBind); which was suggested in a similar stack overflow question.

Any other ideas?

Update:

@Berryl suggested I should do that logic in the view model and I agree with him... except that I can't. I will explain the full use case I tried to simplify in the code above.

GridView is not my control but a 3rd-party data grid from which I inherit. When a user edits a record, we pop up a window in which the user edits the record and then the view model refreshes the data and reselects the old record using its ID. Everything works well until the user uses the filtering capabilities of the grid. If the edit on the record would make the row hidden because of the filter, the following occurs:

  1. View model sets in ValueList property
  2. The grid reads that new list
  3. The grid filters the list
  4. View Model sets the SelectedValue property with a record that is in the ValueList
  5. The grid reads that new SelectedValue but discards it because it is filtered out
  6. Grid has SelectedValue = 23 and View Model has SelectedValue = 42
  7. User double-clicks on the selected row (he sees 23) to edit the record
  8. The ICommand mapped to the double-click is called in the view model
  9. View model launches edit window with record from its SelectedValue property
  10. The user edits record 42 but thinks it is 23.

Solution

  • I found this solution in my emails from way back and thought I should share it (with a little cleanup). This needed support calls to both Telerik and Microsoft at the time.

    Basically, the reason I was not able to cancel a property update from the CoerceValueCallback is because:

    1. the binding is updated before the value is coerced in the logic for the dependency property
    2. this behaviour of CoerceValueCallback is considered by design

    Now the long answer on how to fix it:

    static GridView()
    {
        SelectedItemProperty.OverrideMetadata(typeof(GridView), new FrameworkPropertyMetadata(null, CoerceSelectedItemProperty));
    }
    
    // Rewriting SelectedItem coercion logic because of the following issue (we had support calls to both Telerik and Microsoft)
    // When
    //   - a filter is applied on the grid
    //   - user refreshes after modifying a record
    //   - the record he has changed would be filtered out by the the grid filter
    //   - the view model sets the SelectedItem to the modified record (which is hidden now)
    //
    // Telerik's coercion method resets the selected item to a visible row but because WPF's
    // binding occurs before the coercion method, the view model is not updated.
    // Even a call to UpdateSource() on the binding does not work in this case because the binding
    // is already updating the target while this happens so it does nothing when you call it.
    private static object CoerceSelectedItemProperty(DependencyObject d, object baseValue)
    {
        // Call normal Coercion method because we don't want to rewrite Telerik's logic
        // and keep result to return it at the end.
        object returnValue = SelectedItemProperty.GetMetadata(typeof(RadGridView)).CoerceValueCallback(d, baseValue);
        var control = (GridView)d;
    
        // If coerce returned something other than DependencyProperty.UnsetValue we can use it to push it back to
        // the binding source because it is of the right type and the right value.
        // The only case when we can use control.SelectedItem is when coerce returned UnsetValue otherwise the
        // view model is always one click late.
        object officialValue = returnValue == DependencyProperty.UnsetValue
                                    ? control.SelectedItem
                                    : returnValue;
    
        var binding = control.GetBindingExpression(SelectedItemProperty);
        var source = binding.ResolvedSource;
        var property = source.GetType().GetProperty(binding.ResolvedSourcePropertyName);
        property.SetValue(source, officialValue, null);
        return returnValue;
    }