Mohammed Chami
.NET Developer | Content Creator
Mohammed Chami
.NET Developer | Content Creator

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.


Our demo application will be a document management system featuring:
This pattern works perfectly for text editors, image browsers, database tools, or any application that needs to handle multiple concurrent workspaces.
Fire up JetBrains Rider and create a new Avalonia project. We’ll build upon the MVVM foundation from previous tutorials.
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.
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
The foundation of our tabbed system is a base class that all tab ViewModels inherit from. This ensures consistency and provides common functionality.
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;
}
}
}
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; }
}
}
The tab service manages all tab operations – opening, closing, switching, and maintaining state.
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;
}
}
}
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);
}
}
}
Now let’s create different types of tabs that inherit from our base class.
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>
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>
Now let’s create the main window that hosts our tabbed interface.
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.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>
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>
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>
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;
}
}
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
};
}
}
Time to see your tabbed interface in action! Build and run your project:
dotnet build
dotnet run
You should see:
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();
}
}
}
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;
}
}
}
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);
}
}
}
Check these common problems:
To add a new tab type (like an image viewer), follow this pattern:
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
}
}
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.