L15 — .NET MAUI pt.3: Data Binding & MVVM
Stop writing code-behind for everything. We introduce data binding, INotifyPropertyChanged, ViewModels, Commands, and ObservableCollection — the MVVM pattern that scales.
🎯Learning Objectives
- Explain what MVVM is and why XAML apps are built around it
- Bind a
Label/Entryto a property via{Binding PropertyName} - Implement
INotifyPropertyChangedso the UI updates when properties change - Trigger logic from a button using
ICommandinstead of aClickedevent - Display a list of items using
CollectionViewandObservableCollection<T>
📖Theory
1. The Problem With Code-Behind
In L14 every button click was handled in the page's .xaml.cs:
C#
private void OnSaveClicked(object sender, EventArgs e)
{
string name = NameEntry.Text;
// ... validate, save, update labels ...
}That works for three controls. With thirty, the code-behind becomes a tangle of UI references. You can't unit-test it — tests can't touch NameEntry without launching the UI. And every screen change forces a C# edit.
2. MVVM — Separating UI From Logic
MVVM stands for Model–View–ViewModel. The idea:
- Model — plain C# classes that represent your data (e.g.
Student,Product). No UI knowledge. - View — the XAML file. Purely describes what's on screen.
- ViewModel — a C# class that holds the state the View needs and the commands the View can trigger. The View binds to the ViewModel.
Code
User types in Entry → bound to ViewModel.Name (View updates ViewModel)
User taps Save button → triggers ViewModel.SaveCommand (View asks ViewModel to act)
ViewModel changes data → ViewModel notifies → View (ViewModel updates View)The View and the ViewModel don't reference each other by name. They're glued together by data binding.
3. Your First Binding
Here's a minimal ViewModel:
C#
public class MainViewModel
{
public string Greeting { get; set; } = "Hello from the ViewModel!";
}In MainPage.xaml.cs, attach the ViewModel as the page's BindingContext:
C#
public MainPage()
{
InitializeComponent();
BindingContext = new MainViewModel();
}In MainPage.xaml, bind a Label to Greeting:
XML
<Label Text="{Binding Greeting}" FontSize="24" />{Binding Greeting} means: "find a property called Greeting on the current BindingContext and show its value."
Run it. The label says "Hello from the ViewModel!". No x:Name, no code-behind assignment.
4. Two-Way Binding — Entry That Talks Back
For an Entry, you want binding to work in both directions:
- ViewModel → View (show initial value)
- View → ViewModel (when user types, update the property)
That's called two-way binding and is the default for Entry.Text:
XML
<Entry Text="{Binding Name}" Placeholder="Your name" />C#
public class MainViewModel
{
public string Name { get; set; } = "";
}Now whatever the user types is automatically stored in ViewModel.Name. No OnTextChanged, no manual read.
5. INotifyPropertyChanged — Push Updates to the UI
So far binding works in one direction: the ViewModel starts with a value and the View shows it. But if the ViewModel changes the value later, the View doesn't know.
The fix is the INotifyPropertyChanged interface. The ViewModel fires an event saying "property X changed!" and the binding layer re-reads it.
C#
using System.ComponentModel;
using System.Runtime.CompilerServices;
public class MainViewModel : INotifyPropertyChanged
{
private string _name = "";
public string Name
{
get => _name;
set
{
if (_name == value) return;
_name = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}The [CallerMemberName] attribute fills in the property name for you — so you just write OnPropertyChanged() without an argument.
Most teams put this boilerplate in a BaseViewModel class and have every ViewModel inherit from it:
C#
public abstract class BaseViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
return true;
}
}
public class MainViewModel : BaseViewModel
{
private string _name = "";
public string Name
{
get => _name;
set => SetField(ref _name, value);
}
}6. Commands — Buttons Without Clicked
A ViewModel shouldn't see Button or EventArgs. Instead it exposes commands — ICommand objects that the XAML can invoke.
C#
public class MainViewModel : BaseViewModel
{
private string _name = "";
public string Name
{
get => _name;
set { SetField(ref _name, value); SaveCommand.ChangeCanExecute(); }
}
private string _status = "";
public string Status
{
get => _status;
set => SetField(ref _status, value);
}
public Command SaveCommand { get; }
public MainViewModel()
{
SaveCommand = new Command(
execute: () => Status = $"Saved '{Name}'!",
canExecute: () => !string.IsNullOrWhiteSpace(Name)
);
}
}In XAML:
XML
<VerticalStackLayout Padding="20" Spacing="10">
<Entry Text="{Binding Name}" Placeholder="Enter a name" />
<Button Text="Save" Command="{Binding SaveCommand}" />
<Label Text="{Binding Status}" />
</VerticalStackLayout>canExecute is automatic: when it returns false, MAUI greys out the button.
7. Lists — CollectionView + ObservableCollection<T>
To show a list that updates when items are added or removed, bind a CollectionView to an ObservableCollection<T>.
C#
public class StudentsViewModel : BaseViewModel
{
public ObservableCollection<Student> Students { get; } = new();
public Command AddStudentCommand { get; }
private string _newName = "";
public string NewName
{
get => _newName;
set => SetField(ref _newName, value);
}
public StudentsViewModel()
{
AddStudentCommand = new Command(() =>
{
if (string.IsNullOrWhiteSpace(NewName)) return;
Students.Add(new Student { Name = NewName });
NewName = "";
});
}
}XML
<VerticalStackLayout Padding="20" Spacing="10">
<HorizontalStackLayout Spacing="10">
<Entry Text="{Binding NewName}" Placeholder="Student name" WidthRequest="220" />
<Button Text="Add" Command="{Binding AddStudentCommand}" />
</HorizontalStackLayout>
<CollectionView ItemsSource="{Binding Students}">
<CollectionView.ItemTemplate>
<DataTemplate>
<Grid Padding="10" ColumnDefinitions="*,Auto">
<Label Text="{Binding Name}" Grid.Column="0" />
<Label Text="👤" Grid.Column="1" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>Two bindings with different meanings:
ItemsSource="{Binding Students}"— bound against the page's ViewModel.Text="{Binding Name}"inside theDataTemplate— bound against one Student item. Inside a template,BindingContextchanges to the current item.
8. When to Use Code-Behind vs MVVM
MVVM is the default in production MAUI apps. But it's not a religion. Use code-behind when:
- You need one-off UI tricks (focus a specific entry, scroll to a position)
- The logic is purely visual (animations, purely-UI state toggles)
- A tiny prototype where MVVM scaffolding costs more than it saves
Everything data-related — forms, lists, save/load, validation — belongs in a ViewModel.
💻Code Examples
Example A — Full greeting app with MVVM
BaseViewModel.cs:
C#
public abstract class BaseViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
return true;
}
}MainViewModel.cs:
C#
public class MainViewModel : BaseViewModel
{
private string _name = "";
private string _greeting = "";
public string Name
{
get => _name;
set { if (SetField(ref _name, value)) GreetCommand.ChangeCanExecute(); }
}
public string Greeting
{
get => _greeting;
set => SetField(ref _greeting, value);
}
public Command GreetCommand { get; }
public MainViewModel()
{
GreetCommand = new Command(
execute: () => Greeting = $"Hello, {Name}! ({Name.Length} letters)",
canExecute: () => !string.IsNullOrWhiteSpace(Name)
);
}
}MainPage.xaml:
XML
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="HelloMvvm.MainPage"
Title="Greet">
<VerticalStackLayout Padding="20" Spacing="10" VerticalOptions="Center">
<Entry Text="{Binding Name}" Placeholder="Your name" />
<Button Text="Greet" Command="{Binding GreetCommand}" />
<Label Text="{Binding Greeting}" FontSize="24" HorizontalOptions="Center"/>
</VerticalStackLayout>
</ContentPage>MainPage.xaml.cs:
C#
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
BindingContext = new MainViewModel();
}
}Notice: code-behind is now three lines. All logic is in the ViewModel, which is a pure C# class with no UI types.
✏️Practice Tasks
Refactor the counter app from L13 to MVVM:
- Create
CounterViewModel : BaseViewModelwith:int Countproperty (with change notification)string Displayproperty that returns$"Taps: {Count}"IncrementCommandthat doesCount++and re-raisesDisplayResetCommandthat setsCount = 0
- In XAML, bind a label to
Displayand two buttons to the commands - Set
BindingContext = new CounterViewModel()in the page constructor
💡 Hint
Count changes, Display also needs to re-fire its own PropertyChanged — because bindings don't know that Display depends on Count. Call OnPropertyChanged(nameof(Display)) from within the Count setter, or just have Display as a read-only computed property and manually notify it from commands.Build a simple to-do app using MVVM + ObservableCollection:
TodoItemmodel:string Title,bool IsDoneTodosViewModelwith:ObservableCollection<TodoItem> Itemsstring NewTitle(two-way bound to an Entry)AddCommandthat addsnew TodoItem { Title = NewTitle }and clearsNewTitle
CollectionViewwith anItemTemplateshowing aCheckBox(bound toIsDone) and aLabel(bound toTitle)
💡 Hint
CheckBox + Label row, use a Grid with ColumnDefinitions="Auto,*". Two-way binding on CheckBox: IsChecked="{Binding IsDone}". The Label binds against the item, so Text="{Binding Title}" refers to TodoItem.Title, not the ViewModel.Build a form + list:
- Model:
Student { string Name; double Gpa; } - ViewModel with:
ObservableCollection<Student> StudentsNewName,NewGpaentries (bind as string for Gpa andTryParsewhen adding)AddCommandthat validates Name is non-empty and Gpa is 0.0–4.0; otherwise sets anErrorMessagepropertyTopStudentcomputed property that returns the student with the highest GPA (or"—"if list is empty)
UI:
- Grid form at the top (Name, Gpa, Add button, error label)
CollectionViewof students showing name and GPA- A "Top student" label below the list bound to
TopStudent
💡 Hint
TopStudent has to re-compute whenever the collection changes. Subscribe to Students.CollectionChanged in the ViewModel constructor and call OnPropertyChanged(nameof(TopStudent)) inside. This is the same "notify dependent property" trick as in Task 1.⚠️Common Mistakes
Forgetting to set BindingContext
If no BindingContext is assigned, all {Binding ...} expressions silently resolve to nothing. Nothing shows, nothing crashes. Check that BindingContext = new MainViewModel(); runs in the page constructor.
Using auto-properties without INotifyPropertyChanged
A plain public string Name { get; set; } won't notify the UI when changed from code. The UI shows the initial value and never updates. You need a backing field and a SetField call in the setter.
Using List<T> instead of ObservableCollection<T>
A plain List<T> doesn't raise change notifications. Adding an item won't refresh the CollectionView. Always use ObservableCollection<T> for list properties bound to the UI.
Mixing commands and Clicked on the same button
If you set both Command="..." and Clicked="...", MAUI runs both, which usually isn't what you want. Pick one: Command for MVVM, Clicked for code-behind.
Referencing UI controls inside the ViewModel
If your ViewModel imports Microsoft.Maui.Controls or references Entry, something went wrong — the ViewModel is supposed to be UI-agnostic. Undo it and bind via properties instead.
🎓Instructor Notes
⚡ How to run this lesson (~80 min)
- [5 min] Motivation. Show the L14 calculator. Ask: "Where does the logic live?" Point at the code-behind. "What happens when we want to test
CalculateBmi?" → can't without the UI. Introduce MVVM. - [10 min] Diagram on the board. Draw View ↔ ViewModel ↔ Model. Emphasise: bindings glue View to VM; VM doesn't know about View.
- [15 min] Live code: first binding. Build the tiny greeting example. Use one-way first (Label), then two-way (Entry). Turn off binding → show UI stops working. Restore.
- [15 min] Live code:
INotifyPropertyChanged. This is the part that confuses students most. Walk throughBaseViewModelline by line. Then show what happens withoutOnPropertyChanged. - [10 min] Commands. Replace a
Clickedhandler with a Command. ShowcanExecutegreying the button out live. - [5 min] ObservableCollection + CollectionView. Build the student list from section 7 on the projector.
- [15 min] Task 1 in class. The trap: computed property (
Display) not updating whenCountchanges. Have theOnPropertyChanged(nameof(Display))fix ready. - [5 min] Wrap up + assign homework. Preview L16: connecting the same ViewModel to a real PostgreSQL database. Assign Tasks 2 and 3.
💬 Discussion questions
- "If the View doesn't reference the ViewModel by name, how does
{Binding Name}know where to look?" - "What's the benefit of putting logic in a ViewModel instead of code-behind?"
- "Why does changing a
List<T>not update the UI, but changing anObservableCollection<T>does?"