.NET MAUI – Tab Control with underlinement

with No Comments

July 2025

I have written this post as part of the MAUI UI July 2025 edition. Looking at the previous editions of MAUI UI July, I am sure you might find useful and fun articles, all around MAUI UI. I strongly suggest to have a look. This post describes how I created a tab control with an underlined title on the active tab. The tab bar and the tabs can be positioned independently.

Many developers have created a Tab Control and there are many ways to achieve the goal. My challenge was to write a control that makes it possible to have multiple tabs on one page. The standard MAUI approach is differently, namely a TabbedPage that contains multiple pages. Furthermore, I wanted to underline the active tab, because that gives a good user experience.

The source code for this solution can be found on my github account: Byte217.MAUI on GitHub.

In this post I will focus on the parts that are important for the TabControl and TabBar:

The TabBar show the titles of the tabs with an underlined active tab
The TabControl is a container that hold the tabs and their content.

1. Storage of the active tab

Storage of the active tabs is done in the Viewmodel.

File: Byte217.MAUI.ViewModels/MainViewModel.cs
The MainViewModel controls the display of the active tab using RelayCommands and ObservableProperties. Each tab is bound to a RelayCommand and is shown when the Observable property indicates it. As an example,
the DictionaryPress() command sets the ShowDictionary property to true and the others to false. For the observability I use the CommunitiyToolkit.Mvvm package.

MainViewModel.cs

namespace Byte217.MAUI.ViewModels
{
    [ObservableObject]
    public partial class MainViewModel : ViewModelBase
    {
        [ObservableProperty]
        public bool _showKeyboardSettings = true;
        [ObservableProperty]
        public bool _showDictionary = false;
        [ObservableProperty]
        public bool _showHistory = false;

        public MainViewModel()
            : base()
        {
        }

        [RelayCommand]
        public void KeyboardSettingsPress()
        {
            ShowKeyboardSettings = true;
            ShowDictionary = false;
            ShowHistory = false;
        }

        [RelayCommand]
        public void DictionaryPress()
        {
            ShowKeyboardSettings = false;
            ShowDictionary = true;
            ShowHistory = false;
        }

        [RelayCommand]
        public void HistoryPress()
        {
            ShowKeyboardSettings = false;
            ShowDictionary = false;
            ShowHistory = true;
        }

        public void SaveSettingsPress()
        {
            // Save settings
        }

        public void CancelSettingsPress()
        {
            // Cancel changes
        }
    }
}

File: Byte217.MAUI.Application.Maui/Controls/TabBar.cs
The TabBar contains a button for each tab. Each button is bound to its corresponding RelayCommand
Underneath each button, there is a Rectangle that only shows if the tab is active. The display of the Rectangle is controlled by its corresponding ObservableProperty. I had to add some platform-specific code to make this work on all platforms, but it is still very few.

File: Byte217.MAUI.Application.Maui/Controls/DictionaryTab.cs
File: Byte217.MAUI.Application.Maui/Controls/HistoryTab.cs
File: Byte217.MAUI.Application.Maui/Controls/KeyboardSettings.cs
These are the tabs with their own content and functionality. I have bound them all to the same ViewModel, because this allows me to make changes on all the different tabs and save the changes by pressing a Save button outside of the tabs.

File: Byte217.MAUI.Application.Maui/Controls/TabControl.cs
The TabControl itself is simply a container that holds the tabs and can be used to position them on the page.

File: Byte217.MAUI.Application.Maui/Pages/SettingsPage.cs
As you can see on the SettingsPage, the TabBar and the TabControl are positioned at different locations. In my case, the TabBar is on top of the tabs, but switching it to a different row would place it underneath the tabs.
I had to add some platform-specific code to the OnAppearing() method to decrease the TabBar row for iOS. For other platforms, I had to ensure that my ScrollView was positioned correctly.

As you can see, the Save and Cancel buttons are handled on page level and not on tab level.

## How it works
1. The TabBar shows the tabs with the text of the active tab underlined.
2. Pressing another tab call a RelayCommand of the MainViewModel
3. The RelayCommand changes the active tab by setting observable properties
4. The observable properties determine which tab is visible and which underline is shown.

1. How to get the ‘right’ dimensions

Fortunately, I read a post mentioning the OnSizeAllocated() method of the ContentPage, which forms the base of the solution.
The width and the height that get passed into this method represent the drawable area of the page. This means that hen you use these dimensions, you don’t need to make up for any bars anymore. An additional benefit is that it is called with every change in the page’s size. However, be aware not to perform any performance-intensive actions in OnSizeAllocated because it is called many times when resizing. Therefore, alls to a database or web service will give you problems.

Be aware, the plugin for displaying code, doesn’t support xaml and chnaged all the tags to lowercase. On GitHub, the code is as it should be.

TabBar.xaml



    
    
        
            
            
        
        
            
            
        
        
            
            
        
    

2. How to size the UI elements according to the screen size

When I started adding buttons to the page, I looked at the Styles.xaml and noticed that buttons were displayed with a width and height of 44 pixels by default. Other elements had their own defaults. As described above, I had three configurations in mind. First, I had to calculate how much space this would take up using the default dimensions. The next step would be to calculate the multiplier; the number needed to show them on a larger scale.
For example, a multiplier with a value of 2 would show the buttons at 88px.

This calculation of the Multiplier is done in the PageLayoutFactory. In the sample code, I only calculate the value for one PageLayout. In Smart Letter Board I calculate the Multiplier for all three configuration and choose the best one for the device. I have left this out in this example, because it doesn’t add value for the understanding of the mechanism.

PageLayoutFactory.cs

public PageLayout CreateMinimalPageLayout()
{
	// Setting the SafeArea is important, because it used to calculate the Padding
	PageLayout layout = new()
	{
		PageLayoutType = PageLayoutType.Minimal,
		SafeArea = _safeArea
	};

	// Calculate the required minimal dimensions
	// Width = Padding Left | Button 1 | InnerSpacing | Button 2 | InnerSpacing | ... | Button 10 | OuterSpacing | Right Column Width | Padding Right
	// Height = Padding Top | Row Height | InnerSpacing | Button Height for Suggestions | InnerSpacing | Key Row 1 | InnerSpacing | ... | Key Row 4 | Padding Bottom
	layout.Width = layout.Padding.Left + (10 * layout.ButtonWidth) + (9 * layout.InnerSpacing) + layout.OuterSpacing + layout.RightColumnWidth + layout.Padding.Right;
	layout.Height = layout.Padding.Top + (5 * layout.ButtonHeight) + layout.RowHeight + (5 * layout.InnerSpacing) + layout.Padding.Bottom;

	// Calcute the multipliers by dividing the actual size with the minimal size
	double horizontalMultiplier = _width / layout.Width;
	double verticalMultiplier = _height / layout.Height;

	// Choose the smallest multiplier to ensure that all the layout elements will fit
	double multiplier = Math.Min(horizontalMultiplier, verticalMultiplier);

	layout.Multiplier = multiplier;
	layout.IsValid = (multiplier >= 1.0);

	return layout;
}

To keep the XAML code clear, I created a custom button class with a Bindable Property called PageLayout. When this property is set or changed, it sets the width, height and other properties of the actual button. Without this additional Bindable Property, I would have had to set all the button properties individually in XAML.
Noticed that binding is of the type ObservablePageLayout. More about that in the next section.

Button.cs

 public class Button : Microsoft.Maui.Controls.Button
 {
     public static readonly BindableProperty PageLayoutProperty =
         BindableProperty.Create(nameof(PageLayout), typeof(ObservablePageLayout), typeof(Button), new ObservablePageLayout(), propertyChanged: OnPageLayoutChanged);

     public static void OnPageLayoutChanged(BindableObject bindable, object oldValue, object newValue)
     {
         if (bindable is Button button)
         {
             if (newValue is ObservablePageLayout pageLayout)
             {
                 button.MinimumWidthRequest = pageLayout.ButtonWidth;
                 button.MinimumHeightRequest = pageLayout.ButtonHeight;
                 button.Padding = new Thickness(pageLayout.ButtonPaddingHorizontal, pageLayout.ButtonPaddingVertical);
                 button.FontSize = pageLayout.FontSize;
                 button.CornerRadius = pageLayout.ButtonCornerRadius;
             }
         }
     }

     public ObservablePageLayout PageLayout
     {
         get => (ObservablePageLayout)GetValue(PageLayoutProperty);
         set => SetValue(PageLayoutProperty, value);
     }
 }
 

3. Adding an ObservablePageLayout

To make the bindable property on the button work, I would have to add the INotifyPropertyChanged interface to the PageLayout class. However, I want to keep my architecture clean. I consider observability closer to the presentation layer, and I don’t want to add observability to my models, which are also used to persist data. In the ‘Smart Letter Board’ solution, I have an extra Services project that uses the Models and EF Core to store the data in a SQLite database.

When you look at the source code, you will find an ObservableModels project. This contains all the models that need to be Observable for instant refresh in the UI.
The additional benefit of having a derived class ObservablePageLayout is that I can multiply the base values of the PageLayout with its Multiplier to give me the values that I want to use in the page.

ObservablePageLayout.cs

public class ObservablePageLayout : PageLayout, INotifyPropertyChanged
{
    public new double OuterSpacing
    {
        get
        {
            return (double)Math.Floor(base.OuterSpacing * base.Multiplier);
        }
        set
        {
            base.OuterSpacing = value;
            NotifyPropertChanged();
        }
    }

    // More properties
}

Conclusion

Override ContentPage.OnSizeAllocated() to get the dimensions of the drawable area of your device. Once you have the dimensions of the drawable area you don’t have to take into account the dimensions of status, navigation and tab bars.

I have added the source code of this example to my github account. The README.md contains somewhat more documentation. This is only an example to show a solution for sizing in MAUI. The buttons don’t have event handlers or commands connected. In this example I have simplified the code as much as possible to focus only on the resizing.

Byte217.MAUI on GitHub