At an MVVM workshop a question was asked how tabs can be opened dynamically within a tab control – using the MVVM pattern. During the workshop I’ve done some live-coding to create a sample app. This blog article shows this WPF application and adds explanations. Here you can read about using the TabControl with the MVVM pattern to open tabs dynamically as items from a ListBox are selected.
Technologies, tools, and frameworks used in this article:
- WPF – Windows Presenation Framework
- Microsoft.Extensions.DependencyInjection – a DI container framework from .NET Core
- Visual Studio 2017 – some of the code makes use of C# 7.0. You can build and run the sample application with Visual Studio 2017 RC.
The Application
Within the application a large list of items should be shown on the left side. Every item contains a list of sub-items. The item is defined by the class ItemInfo
. The sub-items are defined by the ItemDetail
class that is specified by a property of type IEnumerable
:
public class ItemInfo { public int ItemId { get; set; } public string Title { get; set; } public IEnumerable<ItemDetail> Details { get; set; } }
ItemDetail
just contains information for the header and the content:
public class ItemDetail { public string ItemDetailId { get; set; } public string Title { get; set; } public string Content { get; set; } }
You can find the model of the application in the library DynamicTabLib, along with the view-models and services.
The main window of the application contains two user controls:
<Grid> <Grid.ColumnDefinitions> <ColumnDefinition /> <ColumnDefinition /> </Grid.ColumnDefinitions> <views:ListViewUC /> <views:TabViewUC Grid.Column="1" /> </Grid>
The first user control contains the list of items, the second user control all the tabs of the currently opened details.
Creating and Showing the Items
The items are created from the ItemsService
class. This class creates 1000 items that contain a random number (1 to 5) of details:
public class ItemsService : IItemsService { private readonly List<ItemInfo> _itemInfos; public ItemsService() { var random = new Random(); _itemInfos = Enumerable.Range(1, 1000) .Select(x => new ItemInfo { ItemId = x, Title = $"title {x}", Details = Enumerable.Range(1, random.Next(1, 5)).Select(x1 => new ItemDetail { ItemDetailId = Guid.NewGuid().ToString(), Title = $"{x} detail {x1}" }).ToList() }) .ToList(); } public IEnumerable<ItemInfo> GetItemInfos() => _itemInfos; }
The interface IItemsService
is used for dependency injection to the view-model, and just contains the method GetItemInfos
to return the large list of items:
public interface IItemsService { IEnumerable<ItemInfo> GetItemInfos(); }
The class ListViewModel
will be bound to the first user control. With the constructor, the previously defined interface IItemsService
will be injected to retrieve the items. The returned items are stored within an ObservableCollection
which allows adding items at a later time and automatically refresh the list with the user interface.
public class ListViewModel { private readonly IOpenItemsDetailService _openItemsDetailService; private readonly IItemDetailViewModelFactory _itemDetailViewModelFactory; private readonly ObservableCollection<ItemInfo> _itemInfos; public ListViewModel(IItemsService itemsService, IOpenItemsDetailService openItemsDetailService, IItemDetailViewModelFactory itemDetailViewModelFactory) { _openItemsDetailService = openItemsDetailService; _itemDetailViewModelFactory = itemDetailViewModelFactory; _itemInfos = new ObservableCollection<ItemInfo>(itemsService.GetItemInfos()); } public IEnumerable<ItemInfo> Info => _itemInfos; // ... }
For the user interface, a ListBox
control is defined that binds to the Info
property of the view-model. The ItemTemplate
of the ListBox
defines a TextBlock
that binds to the Title
property of the item:
<ListBox ItemsSource="{Binding ViewModel.Info, Mode=OneTime}" SelectedItem="{Binding ViewModel.CurrentItemInfo, Mode=TwoWay}"> <ListBox.ItemTemplate> <DataTemplate> <StackPanel> <TextBlock Text="{Binding Title, Mode=OneWay}" /> </StackPanel> </DataTemplate> </ListBox.ItemTemplate> </ListBox>
To connect the view to the view-model, in the code-behind file a ViewModel
property is defined which is of type ListViewModel
that was previously defined. The view-model is created using the Microsoft.Extensions.DependencyInjection container:
public ListViewUC() { InitializeComponent(); ViewModel = (Application.Current as App).Container.GetService<ListViewModel>(); this.DataContext = this; } public ListViewModel ViewModel { get; private set; }
You can also automatically map view-models to views as supported by different MVVM frameworks. I prefer having a strongly typed view-model which also supports compiled binding with the Universal Windows Platform (UWP).
The Microsoft.Extensions.DependencyInjection container is created in the App
class on startup of the application. In the RegisterServices
method, all the needed services and view-models are registered:
public partial class App : Application { protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); RegisterServices(); } private void RegisterServices() { var services = new ServiceCollection(); services.AddSingleton<IItemsService, ItemsService>(); services.AddSingleton<IEventAggregator, EventAggregator>(); services.AddSingleton<IOpenItemsDetailService, OpenItemsDetailService>(); services.AddSingleton<IItemDetailViewModelFactory, ItemDetailViewModelFactory>); services.AddTransient<ListViewModel>(); services.AddTransient<TabViewModel>(); services.AddTransient<ItemDetailViewModel>(); Container = services.BuildServiceProvider(); } public IServiceProvider Container { get; private set; } }
Starting the application at this point, the items are shown in the ListBox:
Selecting an Item
On selection of an item, the information from the view must be represented in the service. For this, the SelectedItem
property of the ListBox is bound two-way to the CurrentItemInfo
property of the view-model:
<ListBox ItemsSource="{Binding ViewModel.Info, Mode=OneTime}" SelectedItem="{Binding ViewModel.CurrentItemInfo, Mode=TwoWay}">
Within the view-model, the set accessor of the CurrentItemInfo
property invokes the AddItemDetails
method of the OpenItemsDetailService
. This service will mainly be used from the tab-view to display the details:
private ItemInfo _currentItemInfo; public ItemInfo CurrentItemInfo { get => _currentItemInfo; set { _currentItemInfo = value; _openItemsDetailService.AddItemDetails(_itemDetailViewModelFactory.GetItemDetailViewModels(_currentItemInfo.Details)); } }
The class OpenItemsDetailService
adds all the details that are passed to the AddItemDetails
method to an ObservableCollection
– as long as the item is not already listed within this collection. The user might select the same item on the left side multiple times, and the item only needs to be added once. The service also defines the event CurrentItemChanged
which allows the user interface to change the currently opened tab item with the corresponding detail:
public class OpenItemsDetailService : IOpenItemsDetailService { public event EventHandler<ItemDetailViewModelEventArgs> CurrentItemChanged; private ObservableCollection<ItemDetailViewModel> _currentItemDetails = new ObservableCollection<ItemDetailViewModel>(); public IEnumerable<ItemDetailViewModel> CurrentItemDetails => _currentItemDetails; public void AddItemDetails(IEnumerable<ItemDetailViewModel> details) { if (details == null) throw new ArgumentNullException(nameof(details)); foreach (var detail in details) { if (!_currentItemDetails.Contains(detail)) { _currentItemDetails.Add(detail); CurrentItemChanged?.Invoke(this, new ItemDetailViewModelEventArgs { Item = detail }); } } } //... }
The contract for the OpenItemsDetailService
is defines with the interface IOpenItemsDetailService
:
public interface IOpenItemsDetailService { event EventHandler<ItemDetailViewModelEventArgs> CurrentItemChanged; IEnumerable<ItemDetailViewModel> CurrentItemDetails { get; } void AddItemDetails(IEnumerable<ItemDetailViewModel> details); void RemoveItemDetail(ItemDetailViewModel detail); }
Showing the Details in a TabControl
The second user control contains a TabControl
that binds to the Details
property of the corresponding view-model. How the content of the items are displayed is defined by the ContentTemplate
property. The heading of the TabItem is defined by the ItemTemplate
. Here, an DataTemplate
specifies to show the Title
as well as a Button
to close the tab item:
<TabControl ItemsSource="{Binding ViewModel.Details}" SelectedItem="{Binding ViewModel.CurrentItem, Mode=TwoWay}"> <TabControl.ItemTemplate> <DataTemplate> <StackPanel Orientation="Horizontal"> <TextBlock Text="{Binding ItemDetail.Title, Mode=OneWay}" /> <Button Content="X" Command="{Binding CloseCommand, Mode=OneTime}" /> </StackPanel> </DataTemplate> </TabControl.ItemTemplate> <TabControl.ContentTemplate> <DataTemplate> <StackPanel Orientation="Vertical"> <TextBlock Text="{Binding ItemDetail.ItemDetailId, Mode=OneWay}" /> <TextBlock Text="{Binding ItemDetail.Title, Mode=OneWay}" /> </StackPanel> </DataTemplate> </TabControl.ContentTemplate> </TabControl>
As with the previously defined code-behind code, the DI container is used to create the corresponding view-model, here it is the TabViewModel
type:
public partial class TabViewUC : UserControl { public TabViewUC() { InitializeComponent(); ViewModel = (Application.Current as App).Container.GetService<TabViewModel>(); this.DataContext = this; } public TabViewModel ViewModel { get; private set; } }
The TabViewModel
defines the Details
property that is bound with the XAML code. With the implemenation, this property just returns the previoiusly defined ObservableCollection that is specified by the service IOpenItemsDetailService
.
public class TabViewModel : BindableBase { private readonly IOpenItemsDetailService _openItemsDetailService; public TabViewModel(IOpenItemsDetailService openItemsDetailService) { _openItemsDetailService = openItemsDetailService; _openItemsDetailService.CurrentItemChanged += (sender, e) => { CurrentItem = e.Item; }; } public IEnumerable<ItemDetailViewModel> Details => _openItemsDetailService.CurrentItemDetails; private ItemDetailViewModel _currentItem; public ItemDetailViewModel CurrentItem { get => _currentItem; set => SetProperty(ref _currentItem, value); } }
Running the application at this state, the tab items are opened as the items are selected from the list:
Closing Tab Items
To close the tab items, WPF allows changing the tab header to add a button to close the item. In the DataTemplate
of the tab item, a button is defined that binds to the CloseCommand
:
<TabControl.ItemTemplate> <DataTemplate> <StackPanel Orientation="Horizontal"> <TextBlock Text="{Binding ItemDetail.Title, Mode=OneWay}" /> <Button Content="X" Command="{Binding CloseCommand, Mode=OneTime}" /> </StackPanel> </DataTemplate> </TabControl.ItemTemplate>
For adding commands to the detail, the ItemDetail
type is wrapped within the ItemDetailViewModel
. ItemDetailViewModel
specifies the CloseCommand
. This command maps to the OnClose
method that in turn invokes the RemoveItemDetail
method of the service IOpenItemsDetailService
:
public class ItemDetailViewModel { private readonly IOpenItemsDetailService _openItemsDetailService; public ItemDetailViewModel(IOpenItemsDetailService openItemsDetailService) { _openItemsDetailService = openItemsDetailService; CloseCommand = new DelegateCommand(OnClose); } public ICommand CloseCommand { get; } public void OnClose() { _openItemsDetailService.RemoveItemDetail(this); } public ItemDetail ItemDetail { get; set; } }
The RemoveItemDetail
method removes the item from the observable collection and fires notification to change the current item:
public void RemoveItemDetail(ItemDetailViewModel detail) { if (detail == null) throw new ArgumentNullException(nameof(detail)); bool updateCurrentChanged = false; if (_currentItemDetails.IndexOf(detail) == _currentItemDetails.Count - 1) { updateCurrentChanged = true; } _currentItemDetails.Remove(detail); if (updateCurrentChanged) { if (_currentItemDetails.Count == 0) { CurrentItemChanged?.Invoke(this, new ItemDetailViewModelEventArgs { Item = null}); } else { ItemDetailViewModel lastItem = _currentItemDetails[_currentItemDetails.Count - 1]; CurrentItemChanged?.Invoke(this, new ItemDetailViewModelEventArgs { Item = lastItem }); } } }
Summary
Using an ObservableCollection that is bound to the TabControl, you can easily add and remove tab items dynamically. To add command functionality to the items, e.g. to close the items from within the tabs, the items can be wrapped with additional functionality, such as offering ICommand properties.
Using view-models to isolate the functionality within libraries, the same library can be used from WPF, UWP, and Xamarin apps.
You can download the complete source code from GitHub
Have fun with programming and learning!
Christian
More Information
You can read more about WPF, XAML, and the MVVM pattern in my new book Professional C# 6 and .NET Core 1.0. Also, I’m delivering workshops with WPF and the MVVM pattern.
Microsoft.Extensions.DependencyInjection is explained in various blog posts starting with Dependency Injection with .NET Core.
Organizer image © Peter Galbraith | Dreamstime.com Organizer
One thought on “Open Tab Items Dynamically with WPF”