Tab-Based Navigation: Building Professional Tabbed Interfaces in Avalonia UI with MVVM

Professional desktop applications often use tabbed interfaces to organize complex functionality. Think of your favorite code editor, web browser, or design tool – they all use tabs to help users manage multiple documents or views simultaneously. Tabs provide an intuitive way to switch between different sections while maintaining context and state.

What We’re Building: A Document Management Application

Our demo application will be a document management system featuring:

  • Dynamic tab creation for opening multiple documents
  • Closeable tabs with unsaved changes warnings
  • Tab context menus for advanced operations
  • Different tab types (text documents, image viewers, settings)
  • Tab state persistence across application sessions

This pattern works perfectly for text editors, image browsers, database tools, or any application that needs to handle multiple concurrent workspaces.

Setting Up the Project Foundation

Fire up JetBrains Rider and create a new Avalonia project. We’ll build upon the MVVM foundation from previous tutorials.

Project Structure for Tabbed Applications

Organize your project with a clear structure that supports multiple tab types:

DocumentManager/
├── ViewModels/
│   ├── MainWindowViewModel.cs
│   ├── TabViewModelBase.cs
│   ├── TextDocumentTabViewModel.cs
│   ├── ImageViewerTabViewModel.cs
│   └── SettingsTabViewModel.cs
├── Views/
│   ├── MainWindow.axaml
│   ├── TextDocumentTabView.axaml
│   ├── ImageViewerTabView.axaml
│   └── SettingsTabView.axaml
├── Models/
│   ├── Document.cs
│   └── TabModel.cs
├── Services/
│   ├── ITabService.cs
│   ├── TabService.cs
│   └── IDocumentService.cs
└── Converters/
    └── TabTypeToIconConverter.cs

This structure separates different concerns and makes it easy to add new tab types as your application grows.

Installing Required Package

Make sure you have the Community Toolkit MVVM package installed. Open the terminal in Rider (Alt+F12) and run:

dotnet add package Avalonia.Controls.DataGrid  

Creating the Tab Base Classes

The foundation of our tabbed system is a base class that all tab ViewModels inherit from. This ensures consistency and provides common functionality.

TabViewModelBase

Create the base class that all tab ViewModels will inherit from:

using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace DocumentManager.ViewModels
{
    public abstract partial class TabViewModelBase : ObservableObject
    {
        [ObservableProperty]
        private string _title = "New Tab";

        [ObservableProperty]
        private string _iconPath = "/Assets/Icons/document.png";

        [ObservableProperty]
        private bool _isModified = false;

        [ObservableProperty]
        private bool _canClose = true;

        [ObservableProperty]
        private bool _isActive = false;

        public string TabId { get; } = Guid.NewGuid().ToString();

        public event EventHandler<TabCloseRequestedEventArgs>? CloseRequested;
        public event EventHandler? ActivationRequested;

        [RelayCommand]
        private async Task CloseTab()
        {
            if (IsModified)
            {
                var canClose = await ConfirmClose();
                if (!canClose) return;
            }

            CloseRequested?.Invoke(this, new TabCloseRequestedEventArgs(TabId));
        }

        [RelayCommand]
        private void ActivateTab()
        {
            ActivationRequested?.Invoke(this, EventArgs.Empty);
        }

        protected virtual async Task<bool> ConfirmClose()
        {
            // Override in derived classes to show custom confirmation dialogs
            return await Task.FromResult(true);
        }

        public abstract Task<bool> SaveAsync();
        public abstract void OnActivated();
        public abstract void OnDeactivated();
    }

    public class TabCloseRequestedEventArgs : EventArgs
    {
        public string TabId { get; }
        
        public TabCloseRequestedEventArgs(string tabId)
        {
            TabId = tabId;
        }
    }
}

Tab Model for Data Binding

Create a simple model to represent tab information:

namespace DocumentManager.Models
{
    public class TabModel
    {
        public string Id { get; set; } = string.Empty;
        public string Title { get; set; } = string.Empty;
        public string IconPath { get; set; } = string.Empty;
        public bool IsModified { get; set; }
        public bool CanClose { get; set; } = true;
        public object? Content { get; set; }
    }
}

Building the Tab Service

The tab service manages all tab operations – opening, closing, switching, and maintaining state.

ITabService Interface

Define the contract for tab management:

using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using DocumentManager.ViewModels;

namespace DocumentManager.Services
{
    public interface ITabService
    {
        ObservableCollection<TabViewModelBase> Tabs { get; }
        TabViewModelBase? ActiveTab { get; set; }
        
        event EventHandler<TabChangedEventArgs>? TabChanged;
        event EventHandler<TabClosedEventArgs>? TabClosed;

        void OpenTab(TabViewModelBase tabViewModel);
        Task<bool> CloseTab(string tabId);
        Task<bool> CloseAllTabs();
        void SwitchToTab(string tabId);
        TabViewModelBase? FindTab(string tabId);
        void MoveTab(int fromIndex, int toIndex);
    }

    public class TabChangedEventArgs : EventArgs
    {
        public TabViewModelBase? NewTab { get; }
        public TabViewModelBase? OldTab { get; }

        public TabChangedEventArgs(TabViewModelBase? newTab, TabViewModelBase? oldTab)
        {
            NewTab = newTab;
            OldTab = oldTab;
        }
    }

    public class TabClosedEventArgs : EventArgs
    {
        public string TabId { get; }
        
        public TabClosedEventArgs(string tabId)
        {
            TabId = tabId;
        }
    }
}

TabService Implementation

Implement the tab service with full functionality:

using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using DocumentManager.ViewModels;

namespace DocumentManager.Services
{
    public partial class TabService : ObservableObject, ITabService
    {
        [ObservableProperty]
        private ObservableCollection<TabViewModelBase> _tabs = new();

        [ObservableProperty]
        private TabViewModelBase? _activeTab;

        public event EventHandler<TabChangedEventArgs>? TabChanged;
        public event EventHandler<TabClosedEventArgs>? TabClosed;

        public TabService()
        {
            Tabs.CollectionChanged += (_, _) => UpdateTabStates();
        }

        public void OpenTab(TabViewModelBase tabViewModel)
        {
            // Check if tab is already open
            var existingTab = FindTab(tabViewModel.TabId);
            if (existingTab != null)
            {
                SwitchToTab(existingTab.TabId);
                return;
            }

            // Subscribe to tab events
            tabViewModel.CloseRequested += OnTabCloseRequested;
            tabViewModel.ActivationRequested += (sender, _) => 
            {
                if (sender is TabViewModelBase tab)
                    SwitchToTab(tab.TabId);
            };

            Tabs.Add(tabViewModel);
            SwitchToTab(tabViewModel.TabId);
        }

        public async Task<bool> CloseTab(string tabId)
        {
            var tab = FindTab(tabId);
            if (tab == null) return false;

            if (tab.IsModified)
            {
                var canClose = await tab.ConfirmClose();
                if (!canClose) return false;
            }

            // Unsubscribe from events
            tab.CloseRequested -= OnTabCloseRequested;

            // Determine next active tab
            var currentIndex = Tabs.IndexOf(tab);
            var nextTab = GetNextActiveTab(currentIndex);

            Tabs.Remove(tab);
            tab.OnDeactivated();

            if (nextTab != null)
            {
                SwitchToTab(nextTab.TabId);
            }
            else
            {
                SetActiveTab(null);
            }

            TabClosed?.Invoke(this, new TabClosedEventArgs(tabId));
            return true;
        }

        public async Task<bool> CloseAllTabs()
        {
            var tabsToClose = Tabs.ToList();
            
            foreach (var tab in tabsToClose)
            {
                var closed = await CloseTab(tab.TabId);
                if (!closed) return false; // User cancelled close operation
            }
            
            return true;
        }

        public void SwitchToTab(string tabId)
        {
            var tab = FindTab(tabId);
            if (tab == null || tab == ActiveTab) return;

            SetActiveTab(tab);
        }

        public TabViewModelBase? FindTab(string tabId)
        {
            return Tabs.FirstOrDefault(t => t.TabId == tabId);
        }

        public void MoveTab(int fromIndex, int toIndex)
        {
            if (fromIndex < 0 || fromIndex >= Tabs.Count ||
                toIndex < 0 || toIndex >= Tabs.Count || 
                fromIndex == toIndex) return;

            var tab = Tabs[fromIndex];
            Tabs.RemoveAt(fromIndex);
            Tabs.Insert(toIndex, tab);
        }

        private void SetActiveTab(TabViewModelBase? newTab)
        {
            var oldTab = ActiveTab;
            
            if (oldTab != null)
            {
                oldTab.IsActive = false;
                oldTab.OnDeactivated();
            }

            ActiveTab = newTab;

            if (newTab != null)
            {
                newTab.IsActive = true;
                newTab.OnActivated();
            }

            TabChanged?.Invoke(this, new TabChangedEventArgs(newTab, oldTab));
        }

        private TabViewModelBase? GetNextActiveTab(int closedTabIndex)
        {
            if (Tabs.Count <= 1) return null;

            // Try to activate the tab to the right
            if (closedTabIndex < Tabs.Count - 1)
                return Tabs[closedTabIndex + 1];

            // Otherwise, activate the tab to the left
            if (closedTabIndex > 0)
                return Tabs[closedTabIndex - 1];

            return null;
        }

        private void UpdateTabStates()
        {
            // Update any global tab states here if needed
        }

        private async void OnTabCloseRequested(object? sender, TabCloseRequestedEventArgs e)
        {
            await CloseTab(e.TabId);
        }
    }
}

Creating Specific Tab Types

Now let’s create different types of tabs that inherit from our base class.

Text Document Tab

TextDocumentTabViewModel.cs:

using System.IO;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace DocumentManager.ViewModels
{
    public partial class TextDocumentTabViewModel : TabViewModelBase
    {
        [ObservableProperty]
        private string _content = string.Empty;

        [ObservableProperty]
        private string _filePath = string.Empty;

        private string _originalContent = string.Empty;

        public TextDocumentTabViewModel(string? filePath = null)
        {
            if (filePath != null)
            {
                FilePath = filePath;
                Title = Path.GetFileName(filePath);
                LoadFile();
            }
            else
            {
                Title = "Untitled Document";
            }

            IconPath = "/Assets/Icons/text-document.png";
        }

        partial void OnContentChanged(string value)
        {
            IsModified = value != _originalContent;
        }

        [RelayCommand]
        private async Task SaveDocument()
        {
            await SaveAsync();
        }

        [RelayCommand]
        private async Task SaveAsDocument()
        {
            // In a real application, you'd show a save file dialog here
            var newPath = $"document_{System.DateTime.Now:yyyyMMdd_HHmmss}.txt";
            FilePath = newPath;
            Title = Path.GetFileName(newPath);
            await SaveAsync();
        }

        public override async Task<bool> SaveAsync()
        {
            try
            {
                if (string.IsNullOrEmpty(FilePath))
                {
                    // Show save as dialog
                    await SaveAsDocument();
                    return true;
                }

                await File.WriteAllTextAsync(FilePath, Content);
                _originalContent = Content;
                IsModified = false;
                return true;
            }
            catch
            {
                return false;
            }
        }

        public override void OnActivated()
        {
            // Focus text editor when tab becomes active
            System.Diagnostics.Debug.WriteLine($"Text document '{Title}' activated");
        }

        public override void OnDeactivated()
        {
            // Save cursor position, etc.
            System.Diagnostics.Debug.WriteLine($"Text document '{Title}' deactivated");
        }

        protected override async Task<bool> ConfirmClose()
        {
            if (!IsModified) return true;

            // In a real application, show a proper dialog
            // For demo purposes, we'll just log and return true
            System.Diagnostics.Debug.WriteLine($"Document '{Title}' has unsaved changes");
            return await Task.FromResult(true);
        }

        private async void LoadFile()
        {
            try
            {
                if (File.Exists(FilePath))
                {
                    Content = await File.ReadAllTextAsync(FilePath);
                    _originalContent = Content;
                    IsModified = false;
                }
            }
            catch
            {
                Content = $"Error loading file: {FilePath}";
            }
        }
    }
}

TextDocumentTabView.axaml:

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="DocumentManager.Views.TextDocumentTabView">
    
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <!-- Toolbar -->
        <Border Grid.Row="0" 
                Background="#f5f5f5" 
                BorderBrush="#ddd" 
                BorderThickness="0,0,0,1" 
                Padding="10,5">
            <StackPanel Orientation="Horizontal" Spacing="10">
                <Button Content="Save" 
                        Command="{Binding SaveDocumentCommand}"
                        IsEnabled="{Binding IsModified}"/>
                <Button Content="Save As..." 
                        Command="{Binding SaveAsDocumentCommand}"/>
                <Separator/>
                <TextBlock Text="{Binding FilePath}" 
                           VerticalAlignment="Center" 
                           Opacity="0.7"/>
            </StackPanel>
        </Border>

        <!-- Text Editor -->
        <TextBox Grid.Row="1" 
                 Text="{Binding Content}"
                 AcceptsReturn="True"
                 TextWrapping="Wrap"
                 ScrollViewer.VerticalScrollBarVisibility="Auto"
                 ScrollViewer.HorizontalScrollBarVisibility="Auto"
                 FontFamily="Consolas, Monaco, 'Courier New', monospace"
                 FontSize="14"
                 Padding="10"/>

        <!-- Status Bar -->
        <Border Grid.Row="2" 
                Background="#f5f5f5" 
                BorderBrush="#ddd" 
                BorderThickness="0,1,0,0" 
                Padding="10,5">
            <StackPanel Orientation="Horizontal" Spacing="15">
                <TextBlock Text="{Binding Content.Length, StringFormat='Characters: {0}'}" 
                           Opacity="0.7"/>
                <TextBlock Text="{Binding IsModified, StringFormat='Modified: {0}'}" 
                           Opacity="0.7"/>
            </StackPanel>
        </Border>
    </Grid>
</UserControl>

Settings Tab

SettingsTabViewModel.cs:

using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace DocumentManager.ViewModels
{
    public partial class SettingsTabViewModel : TabViewModelBase
    {
        [ObservableProperty]
        private bool _enableAutoSave = true;

        [ObservableProperty]
        private int _autoSaveInterval = 30;

        [ObservableProperty]
        private string _defaultTheme = "Light";

        [ObservableProperty]
        private bool _showLineNumbers = true;

        [ObservableProperty]
        private string _fontFamily = "Consolas";

        [ObservableProperty]
        private int _fontSize = 14;

        public SettingsTabViewModel()
        {
            Title = "Settings";
            IconPath = "/Assets/Icons/settings.png";
            CanClose = true;
            LoadSettings();
        }

        [RelayCommand]
        private async Task SaveSettings()
        {
            await SaveAsync();
        }

        [RelayCommand]
        private void ResetSettings()
        {
            EnableAutoSave = true;
            AutoSaveInterval = 30;
            DefaultTheme = "Light";
            ShowLineNumbers = true;
            FontFamily = "Consolas";
            FontSize = 14;
            IsModified = true;
        }

        public override async Task<bool> SaveAsync()
        {
            // In a real application, save settings to configuration file
            await Task.Delay(100); // Simulate save operation
            IsModified = false;
            return true;
        }

        public override void OnActivated()
        {
            System.Diagnostics.Debug.WriteLine("Settings tab activated");
        }

        public override void OnDeactivated()
        {
            if (IsModified)
            {
                // Auto-save settings when leaving the tab
                _ = SaveAsync();
            }
        }

        private void LoadSettings()
        {
            // In a real application, load from configuration file
            // For demo, we'll use default values
        }

        partial void OnEnableAutoSaveChanged(bool value) => IsModified = true;
        partial void OnAutoSaveIntervalChanged(int value) => IsModified = true;
        partial void OnDefaultThemeChanged(string value) => IsModified = true;
        partial void OnShowLineNumbersChanged(bool value) => IsModified = true;
        partial void OnFontFamilyChanged(string value) => IsModified = true;
        partial void OnFontSizeChanged(int value) => IsModified = true;
    }
}

SettingsTabView.axaml:

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="DocumentManager.Views.SettingsTabView">
    
    <ScrollViewer Padding="20">
        <StackPanel Spacing="20" MaxWidth="600">
            
            <TextBlock Text="Application Settings" 
                       FontSize="24" 
                       FontWeight="Bold" 
                       Margin="0,0,0,10"/>

            <!-- Auto Save Settings -->
            <Border Background="#f9f9f9" 
                    CornerRadius="5" 
                    Padding="15">
                <StackPanel Spacing="10">
                    <TextBlock Text="Auto Save" 
                               FontWeight="SemiBold" 
                               FontSize="16"/>
                    
                    <CheckBox IsChecked="{Binding EnableAutoSave}" 
                              Content="Enable auto save"/>
                    
                    <StackPanel Orientation="Horizontal" 
                                Spacing="10" 
                                IsEnabled="{Binding EnableAutoSave}">
                        <TextBlock Text="Save interval (seconds):" 
                                   VerticalAlignment="Center"/>
                        <NumericUpDown Value="{Binding AutoSaveInterval}" 
                                       Minimum="5" 
                                       Maximum="300" 
                                       Width="100"/>
                    </StackPanel>
                </StackPanel>
            </Border>

            <!-- Appearance Settings -->
            <Border Background="#f9f9f9" 
                    CornerRadius="5" 
                    Padding="15">
                <StackPanel Spacing="10">
                    <TextBlock Text="Appearance" 
                               FontWeight="SemiBold" 
                               FontSize="16"/>
                    
                    <StackPanel Orientation="Horizontal" Spacing="10">
                        <TextBlock Text="Theme:" 
                                   VerticalAlignment="Center" 
                                   Width="100"/>
                        <ComboBox SelectedItem="{Binding DefaultTheme}" 
                                  Width="150">
                            <ComboBoxItem Content="Light"/>
                            <ComboBoxItem Content="Dark"/>
                            <ComboBoxItem Content="Auto"/>
                        </ComboBox>
                    </StackPanel>

                    <CheckBox IsChecked="{Binding ShowLineNumbers}" 
                              Content="Show line numbers in text editor"/>
                </StackPanel>
            </Border>

            <!-- Font Settings -->
            <Border Background="#f9f9f9" 
                    CornerRadius="5" 
                    Padding="15">
                <StackPanel Spacing="10">
                    <TextBlock Text="Font" 
                               FontWeight="SemiBold" 
                               FontSize="16"/>
                    
                    <StackPanel Orientation="Horizontal" Spacing="10">
                        <TextBlock Text="Font Family:" 
                                   VerticalAlignment="Center" 
                                   Width="100"/>
                        <ComboBox SelectedItem="{Binding FontFamily}" 
                                  Width="200">
                            <ComboBoxItem Content="Consolas"/>
                            <ComboBoxItem Content="Courier New"/>
                            <ComboBoxItem Content="Monaco"/>
                            <ComboBoxItem Content="Menlo"/>
                        </ComboBox>
                    </StackPanel>

                    <StackPanel Orientation="Horizontal" Spacing="10">
                        <TextBlock Text="Font Size:" 
                                   VerticalAlignment="Center" 
                                   Width="100"/>
                        <NumericUpDown Value="{Binding FontSize}" 
                                       Minimum="8" 
                                       Maximum="32" 
                                       Width="100"/>
                    </StackPanel>
                </StackPanel>
            </Border>

            <!-- Action Buttons -->
            <StackPanel Orientation="Horizontal" 
                        Spacing="10" 
                        HorizontalAlignment="Right">
                <Button Content="Reset to Defaults" 
                        Command="{Binding ResetSettingsCommand}"/>
                <Button Content="Save Settings" 
                        Command="{Binding SaveSettingsCommand}"
                        Classes="accent"
                        IsEnabled="{Binding IsModified}"/>
            </StackPanel>

        </StackPanel>
    </ScrollViewer>
</UserControl>

Building the Main Window with Tab Controls

Now let’s create the main window that hosts our tabbed interface.

MainWindowViewModel

MainWindowViewModel.cs:

using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DocumentManager.Services;
using DocumentManager.ViewModels;

namespace DocumentManager.ViewModels
{
    public partial class MainWindowViewModel : ObservableObject
    {
        private readonly ITabService _tabService;

        public ObservableCollection<TabViewModelBase> Tabs => _tabService.Tabs;
        public TabViewModelBase? ActiveTab => _tabService.ActiveTab;

        public MainWindowViewModel()
        {
            _tabService = new TabService();
            _tabService.TabChanged += (_, _) => OnPropertyChanged(nameof(ActiveTab));
        }

        [RelayCommand]
        private void NewTextDocument()
        {
            var textTab = new TextDocumentTabViewModel();
            _tabService.OpenTab(textTab);
        }

        [RelayCommand]
        private async Task OpenDocument()
        {
            // In a real application, show file open dialog
            // For demo, create a document with some sample content
            var textTab = new TextDocumentTabViewModel();
            textTab.Content = "Sample document content...";
            textTab.Title = "Sample Document.txt";
            textTab.IsModified = true;
            
            _tabService.OpenTab(textTab);
            await Task.CompletedTask;
        }

        [RelayCommand]
        private void OpenSettings()
        {
            // Check if settings tab is already open
            var existingSettingsTab = _tabService.Tabs
                .OfType<SettingsTabViewModel>()
                .FirstOrDefault();

            if (existingSettingsTab != null)
            {
                _tabService.SwitchToTab(existingSettingsTab.TabId);
            }
            else
            {
                var settingsTab = new SettingsTabViewModel();
                _tabService.OpenTab(settingsTab);
            }
        }

        [RelayCommand]
        private async Task CloseActiveTab()
        {
            if (ActiveTab != null)
            {
                await _tabService.CloseTab(ActiveTab.TabId);
            }
        }

        [RelayCommand]
        private async Task CloseAllTabs()
        {
            await _tabService.CloseAllTabs();
        }

        [RelayCommand]
        private async Task SaveActiveTab()
        {
            if (ActiveTab != null)
            {
                await ActiveTab.SaveAsync();
            }
        }
    }
}

MainWindow XAML

MainWindow.axaml:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:DocumentManager.ViewModels"
        x:Class="DocumentManager.MainWindow"
        x:DataType="vm:MainWindowViewModel"
        Title="Document Manager - Tabbed Interface"
        Width="1200"
        Height="800"
        Icon="/Assets/Icons/app.ico">

    <Design.DataContext>
        <vm:MainWindowViewModel/>
    </Design.DataContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <!-- Menu Bar -->
        <Menu Grid.Row="0">
            <MenuItem Header="_File">
                <MenuItem Header="_New Document" 
                          Command="{Binding NewTextDocumentCommand}"
                          InputGesture="Ctrl+N"/>
                <MenuItem Header="_Open..." 
                          Command="{Binding OpenDocumentCommand}"
                          InputGesture="Ctrl+O"/>
                <Separator/>
                <MenuItem Header="_Save" 
                          Command="{Binding SaveActiveTabCommand}"
                          InputGesture="Ctrl+S"/>
                <Separator/>
                <MenuItem Header="_Close Tab" 
                          Command="{Binding CloseActiveTabCommand}"
                          InputGesture="Ctrl+W"/>
                <MenuItem Header="Close _All Tabs" 
                          Command="{Binding CloseAllTabsCommand}"/>
                <Separator/>
                <MenuItem Header="E_xit" 
                          InputGesture="Alt+F4"/>
            </MenuItem>
            
            <MenuItem Header="_View">
                <MenuItem Header="_Settings" 
                          Command="{Binding OpenSettingsCommand}"/>
            </MenuItem>
        </Menu>

        <!-- Toolbar -->
        <Border Grid.Row="1" 
                Background="#f5f5f5" 
                BorderBrush="#ddd" 
                BorderThickness="0,0,0,1" 
                Padding="10,5">
            <StackPanel Orientation="Horizontal" Spacing="10">
                <Button Content="New" 
                        Command="{Binding NewTextDocumentCommand}"/>
                <Button Content="Open" 
                        Command="{Binding OpenDocumentCommand}"/>
                <Button Content="Save" 
                        Command="{Binding SaveActiveTabCommand}"
                        IsEnabled="{Binding ActiveTab, Converter={x:Static ObjectConverters.IsNotNull}}"/>
                <Separator/>
                <Button Content="Settings" 
                        Command="{Binding OpenSettingsCommand}"/>
            </StackPanel>
        </Border>

        <!-- Tab Control -->
        <TabControl Grid.Row="2" 
                    Items="{Binding Tabs}"
                    SelectedItem="{Binding ActiveTab}"
                    TabStripPlacement="Top">
            
            <TabControl.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal" 
                                Spacing="8" 
                                Margin="8,4">
                        
                        <!-- Tab Icon -->
                        <Image Source="{Binding IconPath}" 
                               Width="16" 
                               Height="16"
                               VerticalAlignment="Center"/>
                        
                        <!-- Tab Title -->
                        <TextBlock Text="{Binding Title}" 
                                   VerticalAlignment="Center"
                                   MaxWidth="150"
                                   TextTrimming="CharacterEllipsis"/>
                        
                        <!-- Modified Indicator -->
                        <Ellipse Width="6" 
                                 Height="6" 
                                 Fill="Orange"
                                 VerticalAlignment="Center"
                                 IsVisible="{Binding IsModified}"/>
                        
                        <!-- Close Button -->
                        <Button Width="16" 
                                Height="16" 
                                Padding="0"
                                Background="Transparent"
                                BorderThickness="0"
                                Content="✕"
                                FontSize="10"
                                Command="{Binding CloseTabCommand}"
                                IsVisible="{Binding CanClose}"
                                VerticalAlignment="Center">
                            <Button.Styles>
                                <Style Selector="Button:pointerover">
                                    <Setter Property="Background" Value="#ffcccc"/>
                                </Style>
                            </Button>
                    </StackPanel>
                </DataTemplate>
            </TabControl.ItemTemplate>

            <TabControl.ContentTemplate>
                <DataTemplate>
                    <ContentControl Content="{Binding}"/>
                </DataTemplate>
            </TabControl.ContentTemplate>
        </TabControl>

        <!-- Empty State (when no tabs are open) -->
        <Border Grid.Row="2" 
                IsVisible="{Binding !Tabs.Count}"
                Background="#fafafa">
            <StackPanel HorizontalAlignment="Center" 
                        VerticalAlignment="Center" 
                        Spacing="20">
                <TextBlock Text="No Documents Open" 
                           FontSize="24" 
                           FontWeight="Light" 
                           HorizontalAlignment="Center"
                           Opacity="0.6"/>
                <StackPanel Orientation="Horizontal" 
                            HorizontalAlignment="Center" 
                            Spacing="10">
                    <Button Content="New Document" 
                            Command="{Binding NewTextDocumentCommand}"
                            Padding="15,8"/>
                    <Button Content="Open Document" 
                            Command="{Binding OpenDocumentCommand}"
                            Padding="15,8"/>
                    <Button Content="Settings" 
                            Command="{Binding OpenSettingsCommand}"
                            Padding="15,8"/>
                </StackPanel>
            </StackPanel>
        </Border>
    </Grid>
</Window>

Setting Up Data Templates

Add the DataTemplates to your App.axaml to connect ViewModels to their Views:

<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="DocumentManager.App"
             xmlns:vm="using:DocumentManager.ViewModels"
             xmlns:views="using:DocumentManager.Views">
    
    <Application.DataTemplates>
        <DataTemplate DataType="{x:Type vm:TextDocumentTabViewModel}">
            <views:TextDocumentTabView/>
        </DataTemplate>
        
        <DataTemplate DataType="{x:Type vm:SettingsTabViewModel}">
            <views:SettingsTabView/>
        </DataTemplate>
    </Application.DataTemplates>

    <Application.Styles>
        <FluentTheme Mode="Light"/>
        
        <!-- Custom Tab Styles -->
        <Style Selector="TabControl">
            <Setter Property="Background" Value="#ffffff"/>
        </Style>
        
        <Style Selector="TabItem">
            <Setter Property="Padding" Value="0"/>
            <Setter Property="Margin" Value="0"/>
        </Style>
        
        <Style Selector="TabItem:selected">
            <Setter Property="Background" Value="#ffffff"/>
            <Setter Property="BorderBrush" Value="#0078d4"/>
            <Setter Property="BorderThickness" Value="0,0,0,2"/>
        </Style>
        
        <Style Selector="Button.accent">
            <Setter Property="Background" Value="#0078d4"/>
            <Setter Property="Foreground" Value="White"/>
        </Style>
    </Application.Styles>
</Application>

Advanced Tab Features

Implementing Tab Context Menus

Add right-click context menus to your tabs for advanced operations:

Enhanced Tab Item Template:

<TabControl.ItemTemplate>
    <DataTemplate>
        <StackPanel Orientation="Horizontal" 
                    Spacing="8" 
                    Margin="8,4">
            
            <!-- Add context menu to the stack panel -->
            <StackPanel.ContextMenu>
                <ContextMenu>
                    <MenuItem Header="Close" 
                              Command="{Binding CloseTabCommand}"
                              InputGesture="Ctrl+W"/>
                    <MenuItem Header="Close Others" 
                              Command="{Binding CloseOtherTabsCommand}"/>
                    <MenuItem Header="Close All" 
                              Command="{Binding CloseAllTabsCommand}"/>
                    <Separator/>
                    <MenuItem Header="Save" 
                              Command="{Binding SaveCommand}"
                              IsEnabled="{Binding IsModified}"/>
                    <MenuItem Header="Duplicate Tab" 
                              Command="{Binding DuplicateTabCommand}"
                              IsEnabled="{Binding CanDuplicate}"/>
                </ContextMenu>
            </StackPanel.ContextMenu>
            
            <!-- Rest of tab content... -->
            <Image Source="{Binding IconPath}" Width="16" Height="16"/>
            <TextBlock Text="{Binding Title}" MaxWidth="150"/>
            <Ellipse Width="6" Height="6" Fill="Orange" IsVisible="{Binding IsModified}"/>
            <Button Content="✕" Command="{Binding CloseTabCommand}" IsVisible="{Binding CanClose}"/>
        </StackPanel>
    </DataTemplate>
</TabControl.ItemTemplate>

Tab Drag and Drop Reordering

For tab reordering, you can extend the TabService with drag/drop support:

public partial class TabService : ObservableObject, ITabService
{
    // ... existing code ...

    public void StartDragTab(TabViewModelBase tab)
    {
        // Implement drag start logic
        var index = Tabs.IndexOf(tab);
        // Store drag state
    }

    public void DropTab(TabViewModelBase draggedTab, int targetIndex)
    {
        var currentIndex = Tabs.IndexOf(draggedTab);
        if (currentIndex >= 0 && currentIndex != targetIndex)
        {
            MoveTab(currentIndex, targetIndex);
        }
    }

    public bool CanDropTab(int index)
    {
        return index >= 0 && index < Tabs.Count;
    }
}

Tab Persistence and State Management

Add methods to save and restore tab state:

public partial class TabService : ObservableObject, ITabService
{
    public async Task SaveTabState(string filePath)
    {
        var tabStates = Tabs.Select(tab => new
        {
            Type = tab.GetType().Name,
            TabId = tab.TabId,
            Title = tab.Title,
            IsModified = tab.IsModified,
            // Add tab-specific data
            Data = SerializeTabData(tab)
        }).ToArray();

        var json = System.Text.Json.JsonSerializer.Serialize(tabStates, new JsonSerializerOptions 
        { 
            WriteIndented = true 
        });
        
        await File.WriteAllTextAsync(filePath, json);
    }

    public async Task RestoreTabState(string filePath)
    {
        if (!File.Exists(filePath)) return;

        try
        {
            var json = await File.ReadAllTextAsync(filePath);
            var tabStates = System.Text.Json.JsonSerializer.Deserialize<dynamic[]>(json);

            foreach (var state in tabStates)
            {
                var tab = CreateTabFromState(state);
                if (tab != null)
                {
                    OpenTab(tab);
                }
            }
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Failed to restore tab state: {ex.Message}");
        }
    }

    private object SerializeTabData(TabViewModelBase tab)
    {
        return tab switch
        {
            TextDocumentTabViewModel textTab => new { textTab.Content, textTab.FilePath },
            SettingsTabViewModel settingsTab => new { 
                settingsTab.EnableAutoSave, 
                settingsTab.AutoSaveInterval,
                settingsTab.DefaultTheme 
            },
            _ => new { }
        };
    }

    private TabViewModelBase? CreateTabFromState(dynamic state)
    {
        var typeName = (string)state.Type;
        
        return typeName switch
        {
            nameof(TextDocumentTabViewModel) => new TextDocumentTabViewModel(),
            nameof(SettingsTabViewModel) => new SettingsTabViewModel(),
            _ => null
        };
    }
}

Running Your Tabbed Application

Time to see your tabbed interface in action! Build and run your project:

dotnet build
dotnet run

You should see:

  • A clean interface with menu bar and toolbar
  • Empty state when no tabs are open
  • Dynamic tab creation when opening documents or settings
  • Tab close buttons with modified indicators
  • Proper keyboard shortcuts (Ctrl+N, Ctrl+O, Ctrl+S, Ctrl+W)

Performance Optimization Tips

Lazy Loading Tab Content

For applications with many tabs, implement lazy loading:

public abstract partial class TabViewModelBase : ObservableObject
{
    [ObservableProperty]
    private bool _isContentLoaded = false;

    public virtual async Task LoadContentAsync()
    {
        if (IsContentLoaded) return;
        
        await OnLoadContentAsync();
        IsContentLoaded = true;
    }

    protected virtual async Task OnLoadContentAsync()
    {
        // Override in derived classes
        await Task.CompletedTask;
    }

    public override void OnActivated()
    {
        if (!IsContentLoaded)
        {
            _ = LoadContentAsync();
        }
    }
}

Memory Management

Implement proper cleanup for closed tabs:

public partial class TextDocumentTabViewModel : TabViewModelBase, IDisposable
{
    private bool _disposed = false;

    public void Dispose()
    {
        if (!_disposed)
        {
            // Cleanup resources
            // Unsubscribe from events
            // Clear large content
            _disposed = true;
        }
    }
}

Virtual Tab Lists

For applications with hundreds of tabs, consider implementing virtualization:

public class VirtualizedTabService : ITabService
{
    private readonly Dictionary<string, TabViewModelBase> _allTabs = new();
    private readonly ObservableCollection<TabViewModelBase> _visibleTabs = new();

    public ObservableCollection<TabViewModelBase> Tabs => _visibleTabs;

    public void ShowTab(string tabId)
    {
        if (_allTabs.TryGetValue(tabId, out var tab) && !_visibleTabs.Contains(tab))
        {
            _visibleTabs.Add(tab);
        }
    }

    public void HideTab(string tabId)
    {
        if (_allTabs.TryGetValue(tabId, out var tab))
        {
            _visibleTabs.Remove(tab);
        }
    }
}

Troubleshooting Common Issues

Tabs Not Displaying Content

Check these common problems:

  • Ensure DataTemplates are properly defined in App.axaml
  • Verify ViewModels are inheriting from TabViewModelBase
  • Check that namespaces match between DataTemplates and ViewModels

Tab Closing Issues

  • Verify that CloseRequested events are properly subscribed/unsubscribed
  • Check that ConfirmClose() is being called for modified tabs
  • Ensure tab references are cleaned up to prevent memory leaks

Performance Problems

  • Implement lazy loading for tab content
  • Use virtualization for large numbers of tabs
  • Profile memory usage and implement proper disposal patterns

Extending Your Tabbed Application

Adding New Tab Types

To add a new tab type (like an image viewer), follow this pattern:

  1. Create a ViewModel inheriting from TabViewModelBase
  2. Create the corresponding View (UserControl)
  3. Add the DataTemplate to App.axaml
  4. Register the tab type in your TabService

Implementing Tab Groups

For advanced applications, you might want tab groups:

public class TabGroup : ObservableObject
{
    public string Name { get; set; } = string.Empty;
    public ObservableCollection<TabViewModelBase> Tabs { get; set; } = new();
    public TabViewModelBase? ActiveTab { get; set; }
}

public class GroupedTabService : ITabService
{
    public ObservableCollection<TabGroup> TabGroups { get; } = new();
    
    public void CreateGroup(string name)
    {
        TabGroups.Add(new TabGroup { Name = name });
    }
    
    public void MoveTabToGroup(TabViewModelBase tab, TabGroup targetGroup)
    {
        // Implementation for moving tabs between groups
    }
}

Plugin Architecture

Design your tab system to support plugins:

public interface ITabPlugin
{
    string Name { get; }
    string Description { get; }
    bool CanHandle(string fileExtension);
    TabViewModelBase CreateTab(string filePath);
}

public class PluginManager
{
    private readonly List<ITabPlugin> _plugins = new();
    
    public void RegisterPlugin(ITabPlugin plugin)
    {
        _plugins.Add(plugin);
    }
    
    public TabViewModelBase? CreateTabForFile(string filePath)
    {
        var extension = Path.GetExtension(filePath);
        var plugin = _plugins.FirstOrDefault(p => p.CanHandle(extension));
        return plugin?.CreateTab(filePath);
    }
}

The tabbed interface pattern you’ve learned here is incredibly versatile and forms the backbone of many professional desktop applications. With this foundation, you can build complex, user-friendly applications that handle multiple documents, views, or workspaces efficiently.

Mohammed Chami
Mohammed Chami
Articles: 45

Leave a Reply

Your email address will not be published. Required fields are marked *