Search code examples
c#wpflistmvvmcollections

MVVM: How to handle a List of Models in the ViewModel?


I'm trying to create a MindMap programm using WPF with MVVM. I have a Model called "NodeModel" that has a list of Child NodeModels.

public class NodeModel
{
    public string Content { get; set; }
    public Vector2 Position { get; set; }
    public NodeModel ?ParentNode { get; set; }
    public List<NodeModel> ChildrenNodes { get; }
}

Now I wanted to implement the NodeViewModel, so the view can be notified to any changes made by the Node.

public class NodeViewModel : ViewModelBase
{
    private string _content;
    public string Content
    {
        get
        {
            return _content;
        }
        set
        {
            _content = value;
            OnPropertyChanged(nameof(Content));
        }
    }

    private Vector2 _position;
    public Vector2 Position
    {
        get
        {
            return _position;
        }
        set
        {
            _position = value;
            OnPropertyChanged(nameof(Position));
        }
    }

    private NodeModel? _parentNode;
    public NodeModel ParentNode
    {
        get
        {
            return _parentNode;
        }
        set
        {
                        _parentNode = value;
            OnPropertyChanged(nameof(ParentNode));
        }
    }

    public NodeViewModel(NodeModel node)
    {
        Content = node.Content;
    Position = node.Position;
        ParentNode = node.ParentNode;
    }
}

The Problem I now have is that I don't know how I should handle the list I made in NodeModel in the ViewModel. I cannot just use NodeModel List, because then the NodeModels cannot notify the view. But I cannot use the NodeViewModels because my List only supports NodeModels. What should I do?

My solution is to just change the list type in the NodeModel to NodeViewModel. But it doesn't feel right to do so, because then the Models aren't properly encapsulated from the ViewModel.


Solution

  • The question boils down to how to construct responsive view models representing data that exists in the model in a tree hierarchy. (FYI I like to call the model "data model" to avoid confusing it with view model, so I'll do that below).

    I'll present three different approaches with increasing robustness/flexibility. The best approach will depend on your requirements.

    Read-Only Approach

    The barebones way to do this, building off of your existing code/pattern, would be to add a ChildrenNodes property to NodeViewModel which mirrors the same property on NodeModel:

        private List<NodeViewModel> _childrenNodes;
        public List<NodeViewModel> ChildrenNodes
        {
            get => _childrenNodes;
            set
            {
                _childrenNodes = value;
                OnPropertyChanged(nameof(ChildrenNodes));
            }
        }
    

    And in the constructor:

        public NodeViewModel(NodeModel node)
        {
            Content = node.Content;
            Position = node.Position;
            ParentNode = node.ParentNode;
            ChildrenNodes = new List<NodeViewModel>(
                node.ChildrenNodes?.Select(n => new NodeViewModel(n))
                ?? Enumerable.Empty<NodeViewModel>());
        }
    

    This will cause the constructor to recursively replicate in the view model the same hierarchical tree pattern that already exists in the data model. Now you for example can use a HierarchicalDataTemplate with a TreeView and bind the child items to the view model's ChildrenNodes property without exposing the data model directly.

    This is fine for a read-only view model - i.e., if the nodes will never change. However, if you want the view model to be handle user input and manipulate the data model in response, which is typical, you'd need a slightly different approach.

    Read/Write Approach

    public class NodeViewModel : ViewModelBase
    {
        private NodeModel _data;
    
        // For creating new empty nodes, if applicable
        public NodeViewModel()
        {
            _data = new NodeModel();
        }
    
        // For representing existing nodes
        public NodeViewModel(NodeModel node)
        {
            this._data = node;
        }
    
        ObservableCollection<NodeViewModel> _childrenNodes;
        public ObservableCollection<NodeViewModel> ChildrenNodes
        {
            get
            {
                if (_childrenNodes == null)
                {
                    _childrenNodes = new ObservableCollection<NodeViewModel>(
                        this._data.ChildrenNodes?.Select(n => new NodeViewModel(n))
                        ?? Enumerable.Empty<NodeViewModel>());
                    // This allows us to synchronize the data model's
                    // child list to changes to the view model's
                    _childrenNodes.CollectionChanged += this.OnChildrenChanged;
                }
                return _childrenNodes;
            }
        }
    
        public string Content
        {
            get
            {
                // Using the stored data model instance property essentially
                // as the backing field
                return this._data.Content;
            }
            set
            {
                this._data.Content = value;
                OnPropertyChanged(nameof(Content));
            }
        }
    
        // ...Other direct access properties...
    
        private void OnChildrenChanged(
            object? sender,
            NotifyCollectionChangedEventArgs e)
        {
            // Keeps the data model children list in sync with 
            // the view model children in case the ObservableCollection
            // is manipulated directly by the view.
            switch (e.Action)
            {
                case NotifyCollectionChangedAction.Add:
                    var newNodes = e.NewItems?.OfType<NodeViewModel>()
                        ?? Enumerable.Empty<NodeViewModel>();
                    foreach (var node in newNodes)
                        this._data.ChildrenNodes.Add(node._data);
                    break;
                case NotifyCollectionChangedAction.Remove:
                    var removedNodes = e.OldItems?.OfType<NodeViewModel>()
                        ?? Enumerable.Empty<NodeViewModel>();
                    foreach (var node in removedNodes)
                        this._data.ChildrenNodes.Remove(node._data);
                    break;
                case NotifyCollectionChangedAction.Reset:
                    this._data.ChildrenNodes.Clear();
                    break;
                // Handle Replace and Move if needed
            }
        }
    }
    

    The key difference here is that the view model now holds a private reference to the data model object instance and uses the data object's properties essentially as backing fields. This treats the view model as not just a copy of the data (which your current code more or less does) but instead as the "gatekeeper" between the data and the view. More importantly the data object can now be changed in response to UI actions, which wasn't possible before.

    Specifically for the child list, you can now add or remove child nodes solely by altering the view model collection, like so:

        internal void NewNode() =>
            this.ChildrenNodes.Add(new NodeViewModel());
    
        internal void RemoveNode(NodeViewModel node) =>       
            this.ChildrenNodes.Remove(node);
    

    These methods can be encapsulated in button-bound commands in the data template, for example. Or you can bind the ItemsSource of a control like DataGrid that allows for adding/removing collection elements entirely from the UI directly to the ChildrenNodes view model property. Because we're handling CollectionChanged as we have, any changes made to the view model child collection will automatically synchronize to the data model child node list without further effort.

    This will work provided all changes to the data model object during the application lifecycle - especially changes the child list - must be initiated from the view model. However, if code elsewhere might change the child list without going through the view model's properties and methods, those changes will not be reflected in the view model. To handle that situation requires yet a third approach.

    The most robust solution - cheating!

    There is another approach, however, one which I imagine may get some hate for even being suggested, but I care less about design purity and more about robustness and value. After all, why follow a design pattern unless it helps you? So here it is - cheating!

    New NodeModel:

    // ObservableObject is just a barebones INPC implementation
    public class NodeModel : ObservableObject
    {
        private string _content;
        public string Content
        {
            get
            {
                return _content;
            }
            set
            {
                if (_content == value)
                    return;
                _content = value;
                OnPropertyChanged();
            }
        }
    
        private NodeModel _parentNode;
        public NodeModel ParentNode
        {
            get
            {
                return _parentNode;
            }
            set
            {
                if (_parentNode == value)
                    return;
                _parentNode = value;
                OnPropertyChanged();
            }
        }
    
        ObservableCollection<NodeModel> _childrenNodes;
        public ObservableCollection<NodeModel> ChildrenNodes
        {
            get => _childrenNodes ?? (_childrenNodes = new ObservableCollection<NodeModel>());
        }
    
        // Other properties
    }
    

    Instead of multiple NodeViewModels, we use a single NodeTreeViewModel:

    public class NodeTreeViewModel : ViewModelBase
    {
        public NodeTreeViewModel(NodeModel rootNode)
        {
            this.Root = rootNode;
        }
    
        public NodeModel Root { get; }
    
        // A couple example commands:
    
        // Assuming a generic ICommand implementation with a typed parameter
        private Command<NodeModel> _AddNodeCommand;
        public ICommand AddNodeCommand
        {
            get
            {
                return _AddNodeCommand ?? (_AddNodeCommand = new Command<NodeModel>(
                    (parentNode) =>
                    {
                        parentNode?.ChildrenNodes?.Add(new NodeModel
                        {
                            ParentNode = parentNode
                        });
                    }));
            }
        }
    
        private Command<NodeModel> _RemoveNodeCommand;
        public ICommand RemoveNodeCommand
        {
            get
            {
                return _RemoveNodeCommand ?? (_RemoveNodeCommand = new Command<NodeModel>(
                    (removeNode) =>
                    {
                        removeNode?.ParentNode?.ChildrenNodes?.Remove(removeNode);
                    }));
            }
        }
    }
    

    By having the data model object implement INotifyPropertyChanged and using an ObservableCollection for the child list rather than a dumb List, you can now bind UI components directly to data model properties. Here's how you could create a working view of this using a TreeView (assuming TreeView.DataContext is the NodeTreeViewModel:

        <TreeView ItemsSource="{Binding Root.ChildrenNodes}">
            <TreeView.ItemTemplate>
                <HierarchicalDataTemplate ItemsSource="{Binding ChildrenNodes}"
                                          DataType="{x:Type local:NodeModel}">
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="{Binding Content}" />
                        <Button Content="New" 
                                Command="{Binding 
                                    RelativeSource={RelativeSource AncestorType=TreeView}, 
                                    Path=DataContext.AddNodeCommand}"
                                CommandParameter="{Binding}" />
                        <Button Content="Remove"
                                Command="{Binding 
                                    RelativeSource={RelativeSource AncestorType=TreeView}, 
                                    Path=DataContext.RemoveNodeCommand}"
                                CommandParameter="{Binding}" />                        
                    </StackPanel>
                </HierarchicalDataTemplate>
            </TreeView.ItemTemplate>
        </TreeView>
    

    The upside is that this is both simpler and more robust than wrapping every node in its own view model. Any changes made to data model objects - including node list changes - will be reflected in the UI without any additional scaffolding.

    The downside is that, as you noted, it weakens the separation between the view model and data model layers and allows the view to directly access the NodeModel object. To which I say - so what?

    I can think of no downside to this besides philosophical purity. INotifyPropertyChanged and ObservableCollection are fundamental .NET types available in every .NET flavor; thus your data model can still live in an assembly with no dependencies on WPF or any other specific GUI platform, and will work fine with other platforms. For example, if you were sharing data model code between the WPF app and an ASP.NET Core server app, the server app would have no problem whatsoever with data model objects that implement INPC - there simply wouldn't be any subscribers to the PropertyChanged event. The data model properties will also still serialize/deserialize just fine whether it be JSON, EntityFramework, etc.

    Of course, modifying data model objects might not always be an option. They could be third party classes or areas of the application owned by others that you aren't allowed to touch. In that case, there's no option but to wrap the data model object in a view model and be careful to ensure that data model changes don't occur during the application lifecycle except by going through the view model "gatekeeper".

    But if you are flexible when it comes to data model objects, there is nothing wrong with making them "MVVM friendly" by implementing INotifyPropertyChanged, using ObservableCollection, and directly binding to data model properties when that is the simplest and most robust approach. There is no Commandment that says thou shalt not make a DataTemplate for anything but a view model. It's called data template, after all.

    I realize this introduces more subjectivity into the architecture than you were probably hoping for. My suggestion is to always aim to follow the design separation rules until they become more of a burden than a benefit, and when you do need to bend the rules, do so only to the minimum extent possible. For example, note how I didn't put the add/remove commands in the NodeModel object, even though I might have, because it wasn't necessary and would make NodeModel look too much like a view model for my taste. On the flip side, this meant I had to use RelativeSource bindings in the node template in order to access the command on the parent view model (something which MAUI addresses much more elegantly than WPF, incidentally), which carries with it some drawbacks of its own.

    Ultimately tree hierarchies are one of the more challenging things to deal with in an "MVVM-pure" way. Hopefully this tome will give you some directions to try and you'll find the approach that works best for you.