J23
J23

Reputation: 3221

MVVM: Two-Way Binding To Array In The Model (w/o INotifyPropertyChanged)

Let's say my Model has a property that's an array of primitives:

class Model
{
    public static int MaxValue { get { return 10; } }

    private int[] _array = {1,2,3,4};
    public int[] MyArray
    {
       get
       { 
          return _array; 
       }
       set
       {
           for (int i = 0; i < Math.Min(value.Length, _array.Length); i++)
               _array[i] = Math.Min(MaxValue, value[i]);
       }
}

The ViewModel then wraps this property this for consumption by the View:

class ViewModel
{
    Model _model = new Model();
    public int[] MyArray
    {
        get { return _model.MyArray; }
        set { _model.MyArray = value; }
    }
}

And finally, I present an ItemsControl so the user can set each value with a Slider:

<ItemsControl ItemsSource="{Binding MyArray}" >
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <Slider Value="{Binding Path=., Mode=TwoWay}" Minimum="0" Maximum="{Binding Source={x:Static Model.MaxValue}, Mode=OneWay}" />
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>

In this simple example, TwoWay binding won't work - the slider doesn't update the properties. This is solved by wrapping each array element in an object, like:

class Wrap
{
    public int Value{ get; set; }
}

The MyArray properties then become a Wrap[], and we bind to Path=Value. While it does feel a little "messy" to wrap arrays in the Model this way (note: the model is also used by other WinForms applications), it does solve the problem and the sliders can now update the array.

Next, let's add a button to reset the array values. When clicked:

for( int i = 0; i < MyArray.Length; i++ )
    MyArray[i].Value = i;

Another problem has arisen: after clicking the button, the sliders don't reflect the new values. This can be solved by changing the array to an ObservableCollection, and updating the values with object replacement:

   //MyArray is now ObservableCollection<Wrap>
   for( int i = 0; i < MyArray.Count; i++ )
        MyArray[i] = new Wrap() { Value = i };

First question: My understanding is that in MVVM, the Model classes aren't supposed to have anything to do with notification - that should be handled entirely by the VM. Usually such discussion surrounds INotifyPropertyChanged, but it seems to me like changing arrays to ObservableCollection is really starting to muck up the Model for the sake of...well...notification. Would this not be considered bad practice? All the other (WinForms) apps that use the Model will now have to be updated to understand this wacky collection-of-objects-wrapping-primitives, just to make it work with databinding. Is there perhaps some other way around this?

Second Question: My real issue comes from one more requirement. In the ViewModel, the array values (and other properties) are actually used to generate an image - and every time a property is updated, the image needs to be refreshed. For my other (non-array) properties, I do:

class ViewModel
{
    public int SomeProperty
    {
      get { return _model.SomeProperty; }
      set { _model.SomeProperty = value; RefreshImage(); }
    }
}

But I can't figure out how to update the image in the case of changing MyArray values. I've tried subscribing to the ObservableCollection's CollectionChanged event, but it only gets triggered by the button click (object replacement), not by the sliders (as they update the Value within the object within the collection). I could make the Wrap objects INotifyPropertyChanged and subscribe to ObservableCollection.PropertyChanged...but then I'm sending off notifications from within the model, which (according to everything I've ever read) you're not supposed to do in MVVM. So I'm stuck either unable to refresh the image when the user manipulates the sliders, or having to basically break MVVM - with INotifyPropertyChanged, ObservableCollections, and wrappers all in my Model. Surely there must be a cleaner way to go about this...?

Upvotes: 2

Views: 1948

Answers (2)

J23
J23

Reputation: 3221

In order to avoid changing my model (& all the other projects that depend on it), I ended up solving this by essentially storing a duplicate collection in my VM, and pushing/pulling changes to the model. It's probably not all that elegant, but it gets the job done nicely: I can update the array from the sliders, from the button, and refresh the image after each update - all while leaving the Model's array as regular old primitives.

I ended up with something like the following. TrulyObservableCollection is from here (modified to report NotifyCollectionChangedAction.Replace instead of Reset), and BindableUint is just a wrapper that implements INotifyPropertyChanged.

class ViewModel
{
    Model _model = new Model();
    TrulyObservableCollection<BindableUInt> _myArrayLocal = null;

    //Bind the ItemsControl to this:
    public TrulyObservableCollection<BindableUInt> MyArray
    {
        get
        {
            //If this is the first time we're GETTING this property, make a local copy
            //from the Model
            if (_myArrayLocal == null)
            {
                _myArrayLocal = new TrulyObservableCollection<BindableUInt>();
                for (var i = 0; i < _model.MyArray.Length; i++)
                    _myArrayLocal.Add(new BindableUInt(_model.MyArray[i]));

                _myArrayLocal.CollectionChanged += myArrayLocal_CollectionChanged;
            }

            return _myArrayLocal;
        }
        set 
        {
            //If we're SETTING, push the new (local) array back to the model
            _myArrayLocal = value;
            for (var i = 0; i < value.Count; i++) 
                _model.MyArray[i] = value[i].Value;

            RefreshImage();
        }
    }

    /// <summary>
    /// Called when one of the items in the local collection changes;
    /// push the changed item back to the model, & update the display.
    /// </summary>
    private void myArrayLocal_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        //Sanity checks here
        int changedIndex = e.NewStartingIndex;
        if (_model.MyArray[changedIndex] != _myArrayLocal[changedIndex].Value)
        {
            _model.MyArray[changedIndex] = _myArrayLocal[changedIndex].Value;
            RefreshImage();
        }
    }

    /// <summary>
    /// When using the button to modify the array, make sure we pull the
    /// updated copy back to the VM
    /// </summary>
    private void ButtonClick()
    {
        _model.ResetMyArray();
        for (var i = 0; i < _model.MyArray.Length; i++)
            _myArrayLocal[i].Value = _model.MyArray[i];
        RefreshImage();
    }
}

Upvotes: 0

Mark Feldman
Mark Feldman

Reputation: 16128

Good questions.

Question #1. One thing I consistently see on SO is people claiming that models aren't supposed to do INPC. I'm an absolute MVVM purist, to the point where I have been involved in very large full-stack applications and insisted (successfully) that there be not a single line of code-behind. This is a position that even Josh Smith considers too extreme, and yet even a hard-core purist like myself concedes that adding this feature to the model layer is a practical necessity. INPC is not a view-specific concept; views, along with their corresponding view models, are by design transient in nature. This means you have one of two choices: either you duplicate your model layer almost entirely in the view model layer (error-prone and utterly impractical when you're on a tight schedule and budget), or you provide the model layer with a mechanism to do change notification up to the view model. And if you do choose the second method then why on earth would you roll your own instead of using the tried-and-tested mechanism already built into WPF? Every modern ORM worth its salt allows you to substitute in proxy objects with INPC, in cases of code-first design you can even add it at compile time automatically with a post-processing step.

Question #2: I'd need to see more of your code but in general you just need to call RaisePropertyChange (or whatever, depending on the framework you're using) a second time passing in the name of the property for your image.

Upvotes: 1

Related Questions