Wpf – Pushing read-only GUI properties back into ViewModel

data-bindingmvvmreadonlywpf

I want to write a ViewModel that always knows the current state of some read-only dependency properties from the View.

Specifically, my GUI contains a FlowDocumentPageViewer, which displays one page at a time from a FlowDocument. FlowDocumentPageViewer exposes two read-only dependency properties called CanGoToPreviousPage and CanGoToNextPage. I want my ViewModel to always know the values of these two View properties.

I figured I could do this with a OneWayToSource databinding:

<FlowDocumentPageViewer
    CanGoToNextPage="{Binding NextPageAvailable, Mode=OneWayToSource}" ...>

If this was allowed, it would be perfect: whenever the FlowDocumentPageViewer's CanGoToNextPage property changed, the new value would get pushed down into the ViewModel's NextPageAvailable property, which is exactly what I want.

Unfortunately, this doesn't compile: I get an error saying 'CanGoToPreviousPage' property is read-only and cannot be set from markup. Apparently read-only properties don't support any kind of databinding, not even databinding that's read-only with respect to that property.

I could make my ViewModel's properties be DependencyProperties, and make a OneWay binding going the other way, but I'm not crazy about the separation-of-concerns violation (ViewModel would need a reference to the View, which MVVM databinding is supposed to avoid).

FlowDocumentPageViewer doesn't expose a CanGoToNextPageChanged event, and I don't know of any good way to get change notifications from a DependencyProperty, short of creating another DependencyProperty to bind it to, which seems like overkill here.

How can I keep my ViewModel informed of changes to the view's read-only properties?

Best Answer

Yes, I've done this in the past with the ActualWidth and ActualHeight properties, both of which are read-only. I created an attached behavior that has ObservedWidth and ObservedHeight attached properties. It also has an Observe property that is used to do the initial hook-up. Usage looks like this:

<UserControl ...
    SizeObserver.Observe="True"
    SizeObserver.ObservedWidth="{Binding Width, Mode=OneWayToSource}"
    SizeObserver.ObservedHeight="{Binding Height, Mode=OneWayToSource}"

So the view model has Width and Height properties that are always in sync with the ObservedWidth and ObservedHeight attached properties. The Observe property simply attaches to the SizeChanged event of the FrameworkElement. In the handle, it updates its ObservedWidth and ObservedHeight properties. Ergo, the Width and Height of the view model is always in sync with the ActualWidth and ActualHeight of the UserControl.

Perhaps not the perfect solution (I agree - read-only DPs should support OneWayToSource bindings), but it works and it upholds the MVVM pattern. Obviously, the ObservedWidth and ObservedHeight DPs are not read-only.

UPDATE: here's code that implements the functionality described above:

public static class SizeObserver
{
    public static readonly DependencyProperty ObserveProperty = DependencyProperty.RegisterAttached(
        "Observe",
        typeof(bool),
        typeof(SizeObserver),
        new FrameworkPropertyMetadata(OnObserveChanged));

    public static readonly DependencyProperty ObservedWidthProperty = DependencyProperty.RegisterAttached(
        "ObservedWidth",
        typeof(double),
        typeof(SizeObserver));

    public static readonly DependencyProperty ObservedHeightProperty = DependencyProperty.RegisterAttached(
        "ObservedHeight",
        typeof(double),
        typeof(SizeObserver));

    public static bool GetObserve(FrameworkElement frameworkElement)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        return (bool)frameworkElement.GetValue(ObserveProperty);
    }

    public static void SetObserve(FrameworkElement frameworkElement, bool observe)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        frameworkElement.SetValue(ObserveProperty, observe);
    }

    public static double GetObservedWidth(FrameworkElement frameworkElement)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        return (double)frameworkElement.GetValue(ObservedWidthProperty);
    }

    public static void SetObservedWidth(FrameworkElement frameworkElement, double observedWidth)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        frameworkElement.SetValue(ObservedWidthProperty, observedWidth);
    }

    public static double GetObservedHeight(FrameworkElement frameworkElement)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        return (double)frameworkElement.GetValue(ObservedHeightProperty);
    }

    public static void SetObservedHeight(FrameworkElement frameworkElement, double observedHeight)
    {
        frameworkElement.AssertNotNull("frameworkElement");
        frameworkElement.SetValue(ObservedHeightProperty, observedHeight);
    }

    private static void OnObserveChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
    {
        var frameworkElement = (FrameworkElement)dependencyObject;

        if ((bool)e.NewValue)
        {
            frameworkElement.SizeChanged += OnFrameworkElementSizeChanged;
            UpdateObservedSizesForFrameworkElement(frameworkElement);
        }
        else
        {
            frameworkElement.SizeChanged -= OnFrameworkElementSizeChanged;
        }
    }

    private static void OnFrameworkElementSizeChanged(object sender, SizeChangedEventArgs e)
    {
        UpdateObservedSizesForFrameworkElement((FrameworkElement)sender);
    }

    private static void UpdateObservedSizesForFrameworkElement(FrameworkElement frameworkElement)
    {
        // WPF 4.0 onwards
        frameworkElement.SetCurrentValue(ObservedWidthProperty, frameworkElement.ActualWidth);
        frameworkElement.SetCurrentValue(ObservedHeightProperty, frameworkElement.ActualHeight);

        // WPF 3.5 and prior
        ////SetObservedWidth(frameworkElement, frameworkElement.ActualWidth);
        ////SetObservedHeight(frameworkElement, frameworkElement.ActualHeight);
    }
}