Tabbed user interfaces are very common. Building them in WPF is very easy. But MVVM makes it a little tougher. And, if you can build a one in MVVM, you've probably covered half the distance to the road on learning MVVM.
If you're new to MVVM, you should have a look my previous post,
Getting Started With MVVM in WPF. This post assumes that you know a little about MVVM.
You can download code
here.
I've written this article for people like me who can't understand
Josh Smith's article on MSDN very easily. If you think you can understand it, you better read it.
Let's have a look at our requirements:
- A menu with "New Tab", "Close Current Tab" and "Exit" options
- A TabControl in which tabs appear. The tab headers should have a close button.
- Ctrl + N should create a new tab and Ctrl + F4 should close the current tab. Ctrl + X should close the application.
Well, this seems to be a pretty simple application. For this, you'd need an MVVM framework. You can create your own (I've shown you how to create a simple one in my previous post) or use an open-source one. I prefer
MVVM Toolkit (It has an installer only for VS2008. VS 2010 Beta 2 users can download the code
here). Now, if you've installed it, create a new Model-View application and name it TabbedLayout. It gives you a MVVM solution structure and a Delegate Command. Press F5 to see it running.
You see that it already has a menu and the "Ctrl + X to close" functionality. But we do not need that Grid. Instead we need a TabControl. Replace it with a TabControl.
[xml]<TabControl />[/xml]
Now we need a collection of TabItems that can be bound to the TabControl. We saw how to implement it in the previous post. But, have a closer look here. If we are maintaining an ObservableCollection of TabItems, we are telling the ViewModel that the View uses a TabControl. So, the ViewModel
does know something about the View. This wouldn't be purely MVVM. In order for our application to be purely MVVM, our ViewModel should expose a collection of ViewModels that the View renders. Let's implement this.
We need a new ViewModel that will be shown as a TabItem (or something of your choice) in the View. Let's call it WorkspaceViewModel. Create a new class in the ViewModels folder and name it WorkspaceViewModel. Since, a tab will have a Header, we add a property Header to our class. Note that it inherits from the ViewModelBase class, and the Header property raises the PropertyChanged event.
[csharp]
public class WorkspaceViewModel : ViewModelBase
{
private string _header;
public string Header
{
get { return _header; }
set
{
_header = value;
OnPropertyChanged("Header");
}
}
}
[/csharp]
The MainViewModel maintains a list of WorkspaceViewModels. Create a new public property and initialize it from the constructor.
[csharp]
public ObservableCollection<WorkspaceViewModel> Workspaces { get; set; }
public MainViewModel()
{
Workspaces = new ObservableCollection<WorkspaceViewModel>();
}
[/csharp]
Now the TabControl needs to be bound to this collection.
[xml]<TabControl ItemsSource="{Binding Workspaces}" />[/xml]
Now, we have a TabControl and a collection of tabs. Now, if you add/remove from the Workspaces collection, the changes get reflected in the view. But, there is nothing in the view or the ViewModel that adds/removes Workspaces from the collection. Let's create a NewWorkspaceCommand in the ViewModel that adds a new Workspace to the Workspaces collection. A MenuItem in the View can be bound to the command to add a new Workspace. Add this code to the MainViewModel.
[csharp]
private DelegateCommand _newWorkspaceCommand;
public ICommand NewWorkspaceCommand
{
get { return _newWorkspaceCommand ?? (_newWorkspaceCommand = new DelegateCommand(NewWorkspace)); }
}
private void NewWorkspace()
{
Workspaces.Add(new WorkspaceViewModel { Header = "New Workspace" });
}
[/csharp]
Now, let's create a new MenuItem that binds its Command to the NewWorkspaceCommand. Add this MenuItem under the File MenuItem.
[xml]<MenuItem Command="{Binding NewWorkspaceCommand}" Header="_New Tab" />[/xml]
Press F5 and run the program. Click the "File" Menu and then "New Tab". You see a tab is added to the TabControl. Click it to bring focus on it. You see that the Header and the Content of the TabItem are "TabbedLayout.ViewModels.WorkspaceViewModel". What's this? You may be freaking out, but before you blame WPF or MVVM or me, ask yourself, did you tell the View how to render a WorkspaceViewModel? WPF can't assume what to display! It does what you tell it to do. If WPF does not how to render an element, it simply calls it ToString method and renders it. So what you see is justified. But you do need your users to see that. You'd present them with something more interesting. You need to tell WPF how to render a WorkspaceViewModel. You'll need DataTemplate to do that.
For the TabHeader, let's display a TextBlock. The text of the TextBlock will be the Header property of the WorkspaceViewModel. Modify the TabControl to look like this. It's simple: we set the ItemTemlate property of the TabControl to a DataTemplate which displays the TextBlock.
[csharp]
<TabControl ItemsSource="{Binding Workspaces}">
<TabControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Header}" />
</DataTemplate>
</TabControl.ItemTemplate>
</TabControl>
[/csharp]
Run the Program and you'll see that the Tab Header has changed. It now shows "New Workspace". But the content is still the same. We need to tell the View what to do with a WorkspaceViewModel. Let's create a new DataTemplate for type WorkspaceViewModel and have it display again a TextBlock. Add the following code to the Window.Resources section.
[csharp]
<DataTemplate DataType="{x:Type vm:WorkspaceViewModel}">
<TextBlock Text="Hello to Tabbed Layout!" />
</DataTemplate>
[/csharp]
You need to add a reference to the assembly in the Window tag:
[xml]xmlns:vm="clr-namespace:TabbedLayout.ViewModels"[/xml]
Run the application and see it running smoothly. But there's some problem. When, you want add a new tab, it should get focused. If it were a simple TabItem, you would have called the Focus() method on it. But here, you need a different approach. Let's maintain a SelectedIndex property which gives you the index of the Workspace to be focused and the TabControl can bind it's SelectedIndex property to it.
[csharp]
private int _selectedIndex = 0;
public int SelectedIndex
{
get { return _selectedIndex; }
set
{
_selectedIndex = value;
OnPropertyChanged("SelectedIndex");
}
}
[/csharp]
We need to update the SelectedIndex property when a new workspace is added.
[csharp]
private void NewWorkspace()
{
var workspace = new WorkspaceViewModel { Header = "New Workspace" };
Workspaces.Add(workspace);
SelectedIndex = Workspaces.IndexOf(workspace);
}
[/csharp]
And then bind the SelectedIndex property of the TabControl to the SelectedIndex of the ViewModel.
[xml]<TabControl ItemsSource="{Binding Workspaces}" SelectedIndex="{Binding SelectedIndex}">[/xml]
This makes closing a workspace easier ( That's why I haven't discussed it till now). Let's create a new command in the MainViewModel that removes the selected workspace from the Workspaces collection and bind a MenuItem to this command. There's one more point to be discussed: if there is no Workspace open, the command must not execute. We can go as far as disabling the MenuItem if there is no Workspace open. We must also set the SelectedIndex to point to another open tab.
[csharp]
private DelegateCommand _closeWorkspaceCommand;
public ICommand CloseWorkspaceCommand
{
get{return _closeWorkspaceCommand ?? (_closeWorkspaceCommand = new DelegateCommand(CloseWorkspace, ()=>Workspaces.Count > 0));}
}
private void CloseWorkspace()
{
Workspaces.RemoveAt(SelectedIndex);
SelectedIndex = 0;
}
[/csharp]
The CloseWorkspaceCommand delegates the task of removing the the Workspace to the CloseWorkspace only if Workspaces.Count > 0. Now, we need to bind a new MenuItem to this command:
[xml]<MenuItem Command="{Binding CloseWorkspaceCommand}" Header="_Close Tab" />[/xml]
Now run the application and see it is running perfectly fine. Let's add some shortcuts. All we need to do is create input bindings in the window.
[xml]
<KeyBinding Key="N" Modifiers="Control" Command="{Binding NewWorkspaceCommand}" />
<KeyBinding Key="F4" Modifiers="Control" Command="{Binding CloseWorkspaceCommand}" />
[/xml]
Let's indicate the shortcuts in the MenuItems.
[xml]
<MenuItem Command="{Binding NewWorkspaceCommand}" Header="_New Tab" InputGestureText="Ctrl + N" />
<MenuItem Command="{Binding CloseWorkspaceCommand}" Header="_Close Tab" InputGestureText="Ctrl + F4" />
[/xml]
Now, we are done with the tabbed layout. Run the application and check out what it does.
The last thing left out is the "Close" button on the Tab Headers. Let's try to implement it. Naturally, the Close button must be bound to a command. Since, it will be on a TabItem whose DataContext is the WorkspaceViewModel, we need to create the CloseCommand in the WorkspaceViewModel.
[csharp]
private DelegateCommand _closeCommand;
public ICommand CloseCommand
{
get { return _closeCommand ?? (_closeCommand = new DelegateCommand(CloseWorkspace);}
}
private void CloseWorkspace()
{
throw new NotImplementedException();
}
[/csharp]
Here we have a problem. Our CloseWorkspaceCommand is in the WorkspaceViewModel and it is the MainViewModel that holds the Workspaces collection. How can it remove itself from the collection without having a reference to it? We can pass the MainViewModel in the constructor of the WorkspaceViewModel so that it has a reference to it. But, there is a more elegant solution. The CloseWorkspace command raises an event, RequestClose which the MainViewModel listens to and removes the sender of the event from the collection. This way, we need not introduce between WorkspaceViewModel and MainViewModel. Replace the CloseCommand which you added above with the following command an EventHandler.
[csharp]
private DelegateCommand _closeCommand;
public ICommand CloseCommand
{
get { return _closeCommand ?? (_closeCommand = new DelegateCommand(OnRequestClose);}
}
public event EventHandler RequestClose;
private void OnRequestClose()
{
if (RequestClose != null)
RequestClose(this, EventArgs.Empty);
}
[/csharp]
Now, the MainViewModel must listen to the RequestClose event of every Workspace in Workspaces collection. And if we add or remove Workspaces, it must act accordingly. The simplest way to do this is to listen to the CollectionChanged event of the Workspaces collection and subscribe to the events of the NewItems and unsubscribe to the events of the OldItems. Modify the constructor and add event handlers as follows:
[csharp]
public MainViewModel()
{
Workspaces = new ObservableCollection<WorkspaceViewModel>();
Workspaces.CollectionChanged += Workspaces_CollectionChanged;
}
void Workspaces_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null && e.NewItems.Count != 0)
foreach (WorkspaceViewModel workspace in e.NewItems)
workspace.RequestClose += this.OnWorkspaceRequestClose;
if (e.OldItems != null && e.OldItems.Count != 0)
foreach (WorkspaceViewModel workspace in e.OldItems)
workspace.RequestClose -= this.OnWorkspaceRequestClose;
}
private void OnWorkspaceRequestClose(object sender, EventArgs e)
{
CloseWorkspace();
}
[/csharp]
Note that we already have a CloseWorkspace method that removes the workspace. Now, we have a CloseCommand in the WorkspaceViewModel that has the capability to close itself. But, we haven't bound the command to any element in the View. Let's add a button to the Header of the TabItem and bind it's Command property to the CloseCommand of the WorkspaceViewModel. Replace the TabControl with this code.
[csharp]
<TabControl ItemsSource="{Binding Workspaces}" SelectedIndex="{Binding SelectedIndex}">
<TabControl.ItemTemplate>
<DataTemplate>
<WrapPanel>
<TextBlock Text="{Binding Header}" />
<Button Command="{Binding CloseCommand}" Content="X" Margin="4,0,0,0" FontFamily="Courier New" Width="17" Height="17" VerticalContentAlignment="Center" />
</WrapPanel>
</DataTemplate>
</TabControl.ItemTemplate>
</TabControl>
[/csharp]
And, finally run it. You'll see a button beside the Header text of the TabItem. Click the button to close the tab. We, now have a fully functional application with Tabbed Layout implemented in MVVM. It has all the features we discussed at beginning. But there is something missing. When you close a TabItem, the first tab gets focused. You can change that to focus the previous Tab that was focused. I leave that to you.
You can now check out
Josh Smith's article now and understand it easily. There's something more in it than would have been appropriate for this post. Now, I take a leave wishing you once again good luck in the amazing world of WPF and MVVM.