It finally clicked. All those hours scratching my head as to why I need to put files in separate folders named “Model”, “View”, and “ViewModel” while wrapping my head around the concept of data binding (we will get to that later).
Some context:
I made an application many months ago, but I needed to make a change to some user interface graphics with minimal modifications to the functionality. When I dove back into the code which I had most certainly forgotten about, making the changes took minutes instead of hours of re-learning about how events were handled and managed.
I had a pattern in place that was easily identifiable and reproducible.
I believe this is the biggest take-away from my experience as a software developer in favor of the Model-View-ViewModel design pattern:
When you inevitably need to change or integrate something to your application in the future, it will be much easier for anyone involved.
I mentioned in another post, that I wrote a C# application for inserting columns into an existing CSV file.
Today we will work towards making that application generic with a nice convenient GUI using the the MVVM pattern, WPF, and the .NET framework.
So let’s dive in. First let’s create a new project.
We’ll call the application the CSV-Column-Inserter. Much original. Very wow.
We need to define and separate the behavior of each class in our project. Think of each folder, “Model”, “View”, and “ViewModel”, as a role for each class in your project.
Here is how each “role” breaks down:
Model – Responsible for defining how data is moved, formatted, processed, verified… Ok I’ll stop trying to find the right verbs to describe this.
Basically, this is anything that does something to your data.
View – Responsible for defining how the application’s graphics are defined and presented.
Basically, this is anything that makes the presentation nice and pretty for a human to use.
ViewModel – Responsible for defining interfaces between the View and Model responsible for triggering data changes on the models and notifying the views.
Basically, this is anything that bridges the information between the View and Model.
I’m not gonna lie, when I first started, I kept putting functions in the ViewModel that really should have been in the Model. I think this is the most difficult thing to distinguish early on. I often ask myself this question, “Is this code triggering changes to data, or is it actually performing data logic?” which usually helps me.
If it is truly performing data manipulation, you have a function for a Model class my friend.
Otherwise, you have a function to trigger a function in a Model; this function should reside inside the class of a ViewModel.
Okay enough with the definitions, let’s keep building!
By default, our project has the MainWindow.xaml as the main View of our application. We will relocate this to the “View” Folder.
But now when we try to build we get an error!
-You
You must mean this error:
That is because the App.xaml file’s “StartupUri” property, our main entry point for this application, is referencing “MainWindow.xaml” as if it were still in the same directory! We need to update it to this:
Now it knows to look the View directory for this file.
Let’s start putting in some buttons and whatnot… We won’t make this too terribly fancy for simplicity’s sake.
I like to start with this grid boilerplate-ish type xaml code…
<Grid> <Grid.ColumnDefinitions> <ColumnDefinition></ColumnDefinition> <ColumnDefinition></ColumnDefinition> <ColumnDefinition></ColumnDefinition> <ColumnDefinition></ColumnDefinition> <ColumnDefinition></ColumnDefinition> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> <RowDefinition></RowDefinition> </Grid.RowDefinitions> </Grid>
Now it’s relatively easy to specify where graphics should be.
We’ll start with a simple label that I want in the first row and spans 2 rows and all 5 columns. We will also center and make the text larger…
<Label Content="CSV-Column-Inserter" Grid.Row="0" Grid.RowSpan="2" Grid.ColumnSpan="5" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="36"/>
Alright, we’ll fast forward a bit to after adding the following graphics:
-File selector (text box and button)
-Column Header Label
-Column Text Field
-Value Label
-Value Text Field
-Default (Filename) Checkbox
-Update Button
<TextBox Height="32" Margin="10,10,0,0" Grid.Row="3" Grid.ColumnSpan="4" TextWrapping="Wrap" VerticalAlignment="Top" /> <Button Content="Browse" Grid.Column="4" HorizontalAlignment="Left" Margin="10,10,0,0" Grid.Row="3" VerticalAlignment="Top" Width="138" Height="32"/> <Label Content="Column Header:" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Row="4" /> <TextBox Height="32" Margin="10,10,10,0" Grid.Row="4" Grid.ColumnSpan="3" Grid.Column="1" TextWrapping="Wrap" VerticalAlignment="Top" /> <Label Content="Column Value:" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Row="5" /> <TextBox Height="32" Margin="10,10,10,0" Grid.Row="5" Grid.ColumnSpan="3" Grid.Column="1" TextWrapping="Wrap" VerticalAlignment="Top" /> <CheckBox Content="Default (Filename)" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Row="5" Grid.Column="5"/> <Button Content="Update" Margin="10" Grid.Row="7" Grid.ColumnSpan="5" FontWeight="Bold" FontSize="20" Grid.RowSpan="2"/>
So we should have this now:
Sure, not the greatest looking, be we have a solid starting point with our main view! Let’s go ahead and change the window title while we’re at it…
So now we need to define that interface for communicating with our CSV data manipulation logic. You remember what this thing is called? Yes, I am talking about a ViewModel for our MainWindow.xaml View. So let’s go ahead and make that.
How will the View we just made know about anything in the ViewModel?
-You
I want to start with our need to select a file and show the path in the textbox, but I will first need to describe a concept known as Data Binding which allows us to set observable properties within the ViewModel to the View. This binding of data and command functions is possible through the implementation of something known as a Binder.
Rather than struggle through the process of writing the code for a Binder, we will use existing tools. Specifically, the Prism Library can accomplish our goals.
We want to use “BindableBase” and “DelegateCommand” from the Prism Library.
We will make a DelegateCommand object called “_browsFileCommand” like so…
and create an ICommand object called “BrowseFile” using System.Windows.Input:
We will also want to use “INotifyPropertyChanged” from System.ComponentModel to notify the view when a property is set.
The code below will be necessary to notify the view when a property binding has been modified (we will talk more about this later):
public event PropertyChangedEventHandler PropertyChanged; private void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
So we can go ahead and create an encapsulated property called “SelectedFilePath” alongside our DelegateCommand, “BrowseFileCommand”.
Our code should look like this so far…
using Prism.Commands; using Prism.Mvvm; using System.ComponentModel; using System.Windows.Input; namespace CSV_Column_Inserter.ViewModel { class MainViewModel : BindableBase, INotifyPropertyChanged { private string _selectedFilePath; private DelegateCommand _browseFileCommand; public event PropertyChangedEventHandler PropertyChanged; private void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } public ICommand BrowseFileCommand { get { if (_browseFileCommand == null) { _browseFileCommand = new DelegateCommand(null); } return _browseFileCommand; } } public string SelectedFilePath { get { return _selectedFilePath; } set { _selectedFilePath = value; } } } }
So what do we really have at this point???
Right now we have three important things:
1. The ability to bind a view object to a command.
2. The ability to bind a view object source to data.
3. The ability to notify a view object of updates to data.
So let’s take full advantage of that (hopping back to the MainWindow.xaml file).
<TextBox Height="32" Margin="10,10,0,0" Grid.Row="3" Grid.ColumnSpan="4" TextWrapping="Wrap" VerticalAlignment="Top" Text="{Binding Path=SelectedFilePath}"/>
Updating our TextBox object to include ‘Text=”{Binding Path=SelectedFilePath}”‘ should force the text to always hold the value stored in the ViewModel.
Let’s test our theory by changing the default text in the MainViewModel.cs file:
When I compile, it still shows nothing in the text box?!
-You
I feel like there is some great quote about knowing the context that I could insert here, but I can’t think of it right now lol.
Without context, your view has no way of knowing what to bind to. Let’s open up the MainWindow.xaml.cs file to add the context…
It should include ‘DataContext = new MainViewModel();’ as shown below; don’t forget to include CSV_Column_Inserter.ViewModel at the top!
using CSV_Column_Inserter.ViewModel; using System.Windows; namespace CSV_Column_Inserter { /// <summary> /// Interaction logic for MainWindow.xaml /// </summary> public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); DataContext = new MainViewModel(); } } }
Hooray! It works now…
Now let’s add some functionality to the Browse button and update the selected file path when we finish selecting a file.
First let’s create a function in the MainViewModel.cs file that opens a file selector :
private void ExecuteBrowseFile() { OpenFileDialog openFD = new OpenFileDialog(); openFD.DefaultExt = ".csv"; openFD.Filter = "csv Files (*.csv)|*.csv|CSV Files (*.CSV)|*.CSV"; Nullable<bool> result = openFD.ShowDialog(); if (result.HasValue && result.Value) { // Set the selected file name SelectedFilePath = openFD.FileName; } }
We will need to use Microsoft.Win32 and System:
In the “BrowseFileCommand” we created earlier, our “new DelegateCommand(null)” will now accept our new function that we named “ExecuteBrowseFile” like so…
public ICommand BrowseFileCommand { get { if (_browseFileCommand == null) { _browseFileCommand = new DelegateCommand(ExecuteBrowseFile); } return _browseFileCommand; } }
And hopping back to our MainWindow.xaml, we can bind the command to our button!
<Button Content="Browse" Grid.Column="4" HorizontalAlignment="Left" Margin="10,10,0,0" Grid.Row="3" VerticalAlignment="Top" Width="138" Height="32" Command="{Binding Path=BrowseFileCommand}"/>
Now you should be able to pull up the Open File Dialog…
RK, when I select a CSV file, the text doesn’t update!!!
-You
You’re getting ahead of me 😉 We still need a way to notify the view that data has changed.
Remember that weird function I told you was necessary earlier, “OnPropertyChanged(string propertyName)”? We need it now.
public string SelectedFilePath { get { return _selectedFilePath; } set { _selectedFilePath = value; OnPropertyChanged("SelectedFilePath"); } }
With this, it should work now… What the OnPropertyChanged function does is allows us to notify the View when a new value is set in our setter.
Great! Let’s do something with the CSV file now!!!
Let’s verify that the file is indeed a CSV. Now we get into the Model of our data.
We will create a class called CSV_Verifier in our Models folder…
To save time, we will use TextFieldParser using Microsoft.VisualBasic.FileIO…
We technically already verified the extension by forcing the open file dialog to only accept .csv files… So we will assume that being able to open the file path with the TextFieldParser is enough to verify that the file is indeed a true CSV.
Our code should look like this in CSV_Verifier.cs
using Microsoft.VisualBasic.FileIO; namespace CSV_Column_Inserter.Model { class CSV_Verifier { private string _filePath = ""; private TextFieldParser _parser = null; public CSV_Verifier(string filePath) { _filePath = filePath; } public bool IsValidCSV() { bool testValue = true; try { _parser = new TextFieldParser(_filePath); _parser.TextFieldType = FieldType.Delimited; _parser.SetDelimiters("\t"); } catch { testValue = false; } return testValue; } } }
Now I only want the controls under the file selector to be enabled after the CSV file has been verified.
We can accomplish this by binding a boolean in our MainViewModel.cs to our MainWindow view and then using what is returned from the CSV_Verifier.cs Model file to notify the remaining view object.
Let’s add that boolean in our MainViewModel.cs file…
(Here is the new complete file with “/*** NEW BOOLEAN HERE ***/” to show the changes…
using Microsoft.Win32; using Prism.Commands; using Prism.Mvvm; using System; using System.ComponentModel; using System.Windows.Input; namespace CSV_Column_Inserter.ViewModel { class MainViewModel : BindableBase, INotifyPropertyChanged { private string _selectedFilePath = "Please selected a CSV file..."; private DelegateCommand _browseFileCommand; private bool _isValidCSV = false; /*** NEW BOOLEAN HERE ***/ public event PropertyChangedEventHandler PropertyChanged; private void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } public ICommand BrowseFileCommand { get { if (_browseFileCommand == null) { _browseFileCommand = new DelegateCommand(ExecuteBrowseFile); } return _browseFileCommand; } } private void ExecuteBrowseFile() { OpenFileDialog openFD = new OpenFileDialog(); openFD.DefaultExt = ".csv"; openFD.Filter = "csv Files (*.csv)|*.csv|CSV Files (*.CSV)|*.CSV"; Nullable<bool> result = openFD.ShowDialog(); if (result.HasValue && result.Value) { // Set the selected file name SelectedFilePath = openFD.FileName; } } public bool IsValidCSV /*** NEW BOOLEAN HERE ***/ { get { return _isValidCSV; } set { _isValidCSV = value; OnPropertyChanged("IsValidCSV"); } } public string SelectedFilePath { get { return _selectedFilePath; } set { _selectedFilePath = value; OnPropertyChanged("SelectedFilePath"); } } } }
Notice that OnPropertyChanged is called on the setter just as before!
Defaulted to false, we will bind it to the “IsEnabled” property of the remaining objects in our view that a user can interact with (input fields, the checkbox, and the update button).
<Label Content="CSV-Column-Inserter" Grid.Row="0" Grid.RowSpan="2" Grid.ColumnSpan="5" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="36"/> <TextBox Height="32" Margin="10,10,0,0" Grid.Row="3" Grid.ColumnSpan="4" TextWrapping="Wrap" VerticalAlignment="Top" Text="{Binding Path=SelectedFilePath}"/> <Button Content="Browse" Grid.Column="4" HorizontalAlignment="Left" Margin="10,10,0,0" Grid.Row="3" VerticalAlignment="Top" Width="138" Height="32" Command="{Binding Path=BrowseFileCommand}"/> <Label Content="Column Header:" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Row="4" /> <TextBox Height="32" Margin="10,10,10,0" Grid.Row="4" Grid.ColumnSpan="3" Grid.Column="1" TextWrapping="Wrap" VerticalAlignment="Top" IsEnabled="{Binding Path=IsValidCSV}"/> <Label Content="Column Value:" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Row="5" /> <TextBox Height="32" Margin="10,10,10,0" Grid.Row="5" Grid.ColumnSpan="3" Grid.Column="1" TextWrapping="Wrap" VerticalAlignment="Top" IsEnabled="{Binding Path=IsValidCSV}"/> <CheckBox Content="Default (Filename)" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Row="5" Grid.Column="5" IsEnabled="{Binding Path=IsValidCSV}"/> <Button Content="Update" Margin="10" Grid.Row="7" Grid.ColumnSpan="5" FontWeight="Bold" FontSize="20" Grid.RowSpan="2" IsEnabled="{Binding Path=IsValidCSV}"/>
Which gives us this:
The only thing left to do is actually call our verify function (in our Model class) after a file is selected and notify the View through the ViewModel.
Here is the complete MainViewModel.cs with “/*** NEW CODE ***/” to help make the changes obvious…
using CSV_Column_Inserter.Model; /*** NEW CODE ***/ using Microsoft.Win32; using Prism.Commands; using Prism.Mvvm; using System; using System.ComponentModel; using System.Windows.Input; namespace CSV_Column_Inserter.ViewModel { class MainViewModel : BindableBase, INotifyPropertyChanged { private string _selectedFilePath = "Please selected a CSV file..."; private DelegateCommand _browseFileCommand; private bool _isValidCSV = false; public event PropertyChangedEventHandler PropertyChanged; private void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } public ICommand BrowseFileCommand { get { if (_browseFileCommand == null) { _browseFileCommand = new DelegateCommand(ExecuteBrowseFile); } return _browseFileCommand; } } private void ExecuteBrowseFile() { OpenFileDialog openFD = new OpenFileDialog(); openFD.DefaultExt = ".csv"; openFD.Filter = "csv Files (*.csv)|*.csv|CSV Files (*.CSV)|*.CSV"; Nullable<bool> result = openFD.ShowDialog(); if (result.HasValue && result.Value) { // Set the selected file name SelectedFilePath = openFD.FileName; CSV_Verifier csvVerifier = new CSV_Verifier(SelectedFilePath); /*** NEW CODE ***/ IsValidCSV = csvVerifier.IsValidCSV(); /*** NEW CODE ***/ } } public bool IsValidCSV { get { return _isValidCSV; } set { _isValidCSV = value; OnPropertyChanged("IsValidCSV"); } } public string SelectedFilePath { get { return _selectedFilePath; } set { _selectedFilePath = value; OnPropertyChanged("SelectedFilePath"); } } } }
Notice that all we are doing is passing in the selected string contained in our ViewModel class to the CSV_Verifier class (our Model). Since our properties are bound to the View, the boolean returned from our CSV_Verifier function will set the boolean in our ViewModel and therefore notify the View.
The View NEVER directly interacts with anything in the Model.
Our buttons and inputs should be enabled when a valid CSV file is selected…
My hope is that this diagram makes a lot more sense now…
I challenge you to continue building on top of this example and get comfortable with the process of creating new features with the MVVM pattern in mind.
The complete source code for this project can be found here.
As always, please let me know if you notice any bugs or incorrect information.