Open Tab Items Dynamically with WPF

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.

Tabs

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:

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&lt;TabViewModel&gt;();
        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:

Dynamic Tabs Opened

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

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s