White Labeling .NET MAUI Apps

White Labeling .NET MAUI Apps

A starting point for customized client apps using a single code base

Introduction

Have you ever come across two (or more) products that looked almost exactly the same and only differed in things like color and the company logo printed on them? This is a common practice called white labeling where one company develops a product and then rebrands it for other companies to sell it under their own brand. A lot of products you can find in popular online shops are actually white-labeled.

So, let me rephrase my initial question: Have you ever come across two (or more) apps that looked almost exactly the same and only differed in things like color and the company logo on them? What you saw might indeed have been the same app just with a different icon, different images and different colors but based on exactly the same code base.

This blog post is my contribution to Matt Goldman's MAUI UI July 2024. Check it out, there are many great blog posts about .NET MAUI to be found there.

Today, I will show you how you can develop a single .NET MAUI app and easily rebrand it for different clients that share the same (or very similar) requirements. We'll see an example setup for two clients: ClientA and ClientB, as well as an unbranded "default" version of the app. We'll cover client-specific logos, images, colors, styles, app names and identifiers, as well as fonts, and also quickly touch on custom behavior.

The goal is achieve two or three differently styled apps with the same code base under the hood:

Disclaimer: This blog post can only serve as a starting point, because this topic is too large to cover all aspects in a single write-up. For example, customer-specific resource files (for strings and translations) are a topic that might be addressed in a follow-up post. I do not guarantee that the concepts presented here will work for every scenario. There are various different ways to achieve white labeling and this is just one of many approaches.

As always, there is a companion repository where you can find the full source code for this blog post (and more). To quote a popular YouTuber: "it is enough talking, so let's do it!"

Project Setup

First, let's look at the project structure that we'll work with. A new MAUI single project app created using one of the available templates will usually result in a project structure that looks similar to this:

Things like the app identifier, app name, app icon, images, fonts, etc. are all defined or referenced in the SDK-style project (.csproj) file of a MAUI app project. This is typically done between <PropertyGroup> and <ItemGroup> tags:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <!-- Display name -->
    <ApplicationTitle>MauiSamples</ApplicationTitle>
    <!-- App Identifier -->
    <ApplicationId>com.companyname.mauisamples</ApplicationId>
  </PropertyGroup>
  <ItemGroup>
    <!-- App Icon -->
    <MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4"/>
    <!-- Splash Screen -->
    <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128"/>
    <!-- Images -->
    <MauiImage Include="Resources\Images\*"/>
    <!-- Custom Fonts -->
    <MauiFont Include="Resources\Fonts\*"/>
    <!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
    <MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)"/>
  </ItemGroup>
</Project>

Note: To keep things simple, I'm only showing relevant bits of the .csproj file

This structure assumes that there's ever only a single source of truth when it comes to icons, images, styles and so on, which in this context usually is the Resources folder. For single-client apps, this is perfect, but for white labeling this let's us face a couple of problems. For example, how would we replace the app icons or fonts for each client?

We can work with this, but we need to make some changes, that I will describe in the following paragraphs.

Resources Structure

For white labeling, I suggest using a slightly altered project structure with an additional level of subfolders right under Resources with the usual folders for icons, fonts, images and so on separated by client:

The main benefit of this structure is that we can keep all the client-specific assets separate without losing the logical grouping we're used to from other MAUI apps.

Now, this will not do anything on its own yet. In a MAUI single project app, the different asset types have their own specific build actions, as we've already seen above:

<!-- excerpt from .csproj file -->
<ItemGroup>
    <!-- App Icon -->
    <MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4"/>
    <!-- Splash Screen -->
    <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128"/>
    <!-- Images -->
    <MauiImage Include="Resources\Images\*"/>
    <!-- Custom Fonts -->
    <MauiFont Include="Resources\Fonts\*"/>
    <!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
    <MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)"/>
</ItemGroup>

Now, you might be asking yourself "but, how do I get the client-specific resources in there?". Well, we could of course replace the paths to each resource subfolder each time we build the app for a different client. This, however, would quickly become a massive hassle and wouldn't work with automated build pipelines.

A common solution to this type of problem is to use different build configurations, two for each client (one Debug and one Release configuration) and then use build conditions to select the correct set of resources to be included in the build process. We'll have a look at that next before coming back to using the correct assets.

Build Configurations

To add new build configurations, we need to select the drop down with the active build configuration:

In the drop down that appears, we then select "Configuration Manager", which opens the following dialog:

Now, we select the "Active solution configuration" drop down and then hit "New", which will open the "New Solution Configuration" dialog:

We can now provide a name to the new configuration and copy the settings from an existing one. We also need to create new project configurations (make sure to tick the checkbox). When we're done, we hit "OK" and come back to the "Configuration Manager" dialog.

Now, we need to select the newly created solution configuration and select the companion project configuration that we created, so that it looks like this:

We repeat these steps for every client and for both Debug and Release configurations. Provided that we use two clients (ClientA, ClientB) and a default configuration in this example, we will end up with the following six configurations:

  • Debug

  • Debug-ClientA

  • Debug-ClientB

  • Release

  • Release-ClientA

  • Release-ClientB

Note how these conditions also get added to your project's .csproj file:

<PropertyGroup>
    <TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst</TargetFrameworks>
    <!-- skipping other properties -->

    <!-- new configurations we just added in the Configuration Manager -->
    <Configurations>Debug;Release;Debug-ClientA;Debug-ClientB;Release-ClientA;Release-ClientB</Configurations>
</PropertyGroup>

Note: The .sln file will also get, too, some new GUIDs and configurations are added in there, as well. Please do not meddle with this file, as it's very easy to break the entire solution this way.

Now, we can use these configurations together with build conditions in order to decide which assets to include in the build process.

Build Conditions and Properties

When working with a default MAUI app template, we will eventually come across some build conditions. If you have a little bit of experience with MAUI, you will have seen lines like this in the .csproj file of other apps plenty of times already:

<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net8.0-windows10.0.19041.0</TargetFrameworks>

The Condition in the above example will ensure that the Windows target is only used when the operating system on which the solution is compiled is Windows, because MAUI apps for Windows cannot be compiled on a Mac.

We can use this same mechanism also for our white labeling purposes, which is what we created the build configurations for in the previous step. We can filter the assets to be included using the active build configuration as follows:

<PropertyGroup Condition="'$(Configuration)' == 'Debug' OR '$(Configuration)' == 'Release'">
   <!-- define default properties -->
</PropertyGroup>

<PropertyGroup Condition="$(Configuration.EndsWith('ClientA'))">
   <!-- define ClientA properties -->
</PropertyGroup>

<PropertyGroup Condition="$(Configuration.EndsWith('ClientB'))">
   <!-- define ClientA properties -->
</PropertyGroup>

Above, we use the Configuration build property, which will have the value of one of the six build configurations we created earlier (e.g. "Debug", "Debug-ClientA" or "Release-ClientB", ...). The value of this property can be evaluated. There are even methods that can be called, such as EndsWith() to check for a substring, e.g. Condition="$(Configuration.EndsWith('ClientB'))".

Now, if we would define the properties and project items all in the .csproj file, things would get messy pretty quickly. Having a clean, manageable structure is key when it comes to rebranding. Therefore, instead of having all client-specific data in one massive .csproj file, we can split this into separate project property (.props) files, one for each of the clients, which for ClientA could look like this, for example:

<!-- ClientA.props file -->
<Project>
  <PropertyGroup>
    <!-- Assembly Name -->
    <ApplicationAssemblyName>SuperDuperApp</ApplicationAssemblyName>
    <!-- Display name -->
    <ApplicationTitle>Super Duper App</ApplicationTitle>
    <!-- App Identifier -->
    <ApplicationId>com.ClientA.SuperDuperApp</ApplicationId>
  </PropertyGroup>
  <ItemGroup>
    <!-- App Icon -->
    <MauiIcon Include="Resources\ClientA\AppIcon\appicon.svg" ForegroundFile="Resources\ClientA\AppIcon\appiconfg.svg" Color="#123456" ForegroundScale="0.65"/>
    <!-- Splash Screen -->
    <MauiSplashScreen Include="Resources\ClientA\Splash\splash.svg" Color="#123456" BaseSize="128,128"/>
    <!-- Images -->
    <MauiImage Include="Resources\ClientA\Images\*"/>
    <!-- Custom Fonts -->
    <MauiFont Include="Resources\ClientA\Fonts\*"/>
    <!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
    <MauiAsset Include="Resources\ClientA\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)"/>
    <!-- Privacy Manifest for iOS -->
    <BundleResource Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'" Include="Platforms\iOS\PrivacyInfo.xcprivacy" LogicalName="PrivacyInfo.xcprivacy"/>
  </ItemGroup>
</Project>

This file contains all the client-specific properties and items that should be included only when building the app for ClientA. It is referencing the files and folders from the Resources/ClientA subfolder for the app icon, splash screen, etc., and it also defines the client-specific app title and package identifier (= ApplicationId).

If we do this for all clients, we can then include the correct .props file based on the selected configuration in our .csproj file using our build conditions:

<Project Sdk="Microsoft.NET.Sdk">

  <Import Condition="'$(Configuration)' == 'Debug' OR '$(Configuration)' == 'Release'" Project="Resources\Default\default.props" />
  <Import Condition="$(Configuration.EndsWith('ClientA'))" Project="Resources\ClientA\ClientA.props" />
  <Import Condition="$(Configuration.EndsWith('ClientB'))" Project="Resources\ClientB\ClientB.props" />

  <!-- this is required to give the output files (.dll) a different assembly name per client -->
  <PropertyGroup>
    <AssemblyName>$(ApplicationAssemblyName)</AssemblyName>
  </PropertyGroup>

</Project>

This just looks so much cleaner than having one massive .csproj file with lots of client-specific stuff in it, don't you agree? It also makes the onboarding of new clients more straightforward.

Intermediate Build

If we would build and deploy each of the three different client apps to a device now, we will see three different installed apps each with a unique app icon and name, all based on the same code base:

This is already pretty cool! However, we're not done yet, because if we would run each of the apps, apart from the app icon and app name, their content would still look identical. This is because we haven't made any other changes so far.

Important: For this to work, the App.xaml file needs to reference valid paths to the Colors.xaml and Styles.xaml resource dictionaries. However, in our setup above, these files have been moved to a different location and we haven't modified the App.xaml file. However, I don't actually want to change the App.xaml file at all, it should remain exactly the way it is. We will take a look at this next.

Styles and Colors

In a MAUI app, the App.xaml file typically references the resource dictionaries that contain definitions for the colors and styles like this:

<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiWhiteLabelling.App">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
                <ResourceDictionary Source="Resources/Styles/Styles.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

Note that there is a relative path to each of the XAML resource dictionaries. This path is problematic, because, unfortunately, XAML does not support conditional compilation. Therefore, we cannot provide different paths for different clients and unfortunately, MAUI also doesn't allow us to set the Source property from the code-behind, either.

So, how can we reference the correct colors and styles from our separate client asset subfolders? We shouldn't have to modify the App.xaml file every time we build an app for a specific client. That's too risky and only makes things complicated.

A common Xamarin.Forms way for this was to load these resource dictionaries dynamically during the app start. However, this introduces a new problem: The StaticResource markup will not be usable, because the styles are not known at the time of construction. Hence, we would have to resort to using the DynamicResource markup instead, which would introduce a performance penalty and we would lose the preview capabilities of the XAML editor (at least when using Visual Studio on Windows).

💡 However, fret not, I found a solution for this, as well. Since we have the .props files for each client in place already, we can use this mechanism to also copy some assets like the XAML resource dictionaries to a shared location, more specifically the location where the App.xaml file expects those resources to be: the Resources/Styles subfolder.

All we need to do for this to work is to update the .props files and add the InitialTargets attribute to the <Project> tag that points to a named Target with an action that copies the required files to the correct location:

<Project InitialTargets="CopyResourceFiles">
  <!-- skipping PropertyGroups and ItemGroups -->
  <Target Name="CopyResourceFiles">
    <Copy SourceFiles="Resources\ClientA\Styles\Styles.xaml;Resources\ClientA\Styles\Colors.xaml" DestinationFolder="Resources\Styles" />
  </Target>
</Project>

Basically, with this approach, we replace the two resource dictionary files every time we select a different build configuration. This is fine, because it's very fast and any build targets included in the InitialTargets will run before compilation. This is what allows us to still use the StaticResource markup, thus avoiding sweeping changes and performance hits.

I recommend adding the paths of the shared Styles.xaml and Colors.xaml file locations under Resources/Styles to the .gitignore file of your repository, so that they don't get included in any commits, similar to any auto-generated files that also don't need to be included, either:

# Explicit file exclusions
MauiWhiteLabelling/Resources/Styles/Colors.xaml
MauiWhiteLabelling/Resources/Styles/Styles.xaml

Styles and colors: Check ✅.

Let's have a look at custom fonts before finally running the app for the first time.

Fonts

In order to use custom fonts, we usually need to add the font files to the project and set the build action to MauiFont. Normally, this is already the case, as long as the font files are located in the directory specified for the <MauiFont> tag, e.g.:

<MauiFont Include="Resources\Fonts\*"/>

For white labeling to work, a simple trick to use different fonts per client is to add the font files using the exact same name for all clients inside the client-specific folders and then to register them using the exact same alias:

In the custom .props file we already specified the path to the client-specific font files:

<!-- ClientA.props -->
<MauiFont Include="Resources\ClientA\Fonts\*"/>

This way, we can reference a font directly by its shared alias and the correct client font file will always be used. We then don't have to specify which client we're registering which font for, we only need to modify the font registration in the MauiProgram.cs file as follows:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .UseMauiCommunityToolkit()
        .ConfigureFonts(fonts =>
        {
            // use the same font filename and alias for every client
            fonts.AddFont("FontRegular.ttf", "FontRegular");
            fonts.AddFont("FontSemibold.ttf", "FontSemibold");
        });

    return builder.Build();
}

As you can see, we don't use the actual font name here, we just use a shared naming convention for the font file and alias.

This approach is not mandatory, the FontFamily can also be specified in a style definition in the client-specific Styles.xaml resource dictionary using the actual names of the fonts. However the approach described here will make our lives a lot easier, since it allows us to use fonts by a shared naming convention everywhere in the app equally. We can simply specify the FontFamily like we normally would anywhere in our app and see the text in a different font depending on the selected client configuration:

<Label
    Text="Welcome!"
    FontFamily="FontSemibold"
    FontSize="36" />

In the next section we'll see what it all looks like when we actually run the app after quickly setting up a page that uses the client-specific resources we've supplied.

Running the app

Let's put it all together with a simple ContentPage that has the client's app logo, a label and a button, with a custom font and a client-specific background color:

<ContentPage
  x:Class="MauiWhiteLabelling.Views.MainPage"
  xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
  xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
  BackgroundColor="{StaticResource Primary}"
  Shell.BackgroundColor="{StaticResource Primary}"
  Shell.NavBarIsVisible="False">

  <Grid>
    <VerticalStackLayout
      Padding="20,40"
      Spacing="40"
      VerticalOptions="Start"
      HorizontalOptions="Center">
      <Image
        Source="logo.png"
        WidthRequest="80"
        HeightRequest="80"
        HorizontalOptions="Center"
        VerticalOptions="Center" />
      <Label
        Text="Welcome!"
        FontFamily="FontSemibold"
        FontSize="36"
        HorizontalOptions="Center"
        VerticalOptions="Center" />
      <Button Text="Press me" Clicked="Button_OnClicked" />
    </VerticalStackLayout>
  </Grid>

</ContentPage>

Running this app now will yield three different results for the different client configurations, which is exactly what we wanted:

🎉 Isn't this awesome? With a simple setup and a few tricks, we managed to white-label an app for three different client configurations. Rock and roll 🤘!

However, sometimes, clients also want some unique or custom behavior. Now, that can be done just as well. Let me show you how in the next section.

Custom Behavior

We can create custom behavior for every client, e.g. by using compile constants or feature toggles. Compile constants can be defined in the client .props file as follows:

<Project InitialTargets="CopyResourceFiles">
  <PropertyGroup>
    <!-- skipping other stuff for brevity -->
    <!-- Compilation Constants for Client A -->
    <DefineConstants>$(DefineConstants);CLIENT_A</DefineConstants>
  </PropertyGroup>
  <ItemGroup>
    <!-- skipping assets for brevity -->
  </ItemGroup>
</Project>

With this in place, we can then instruct the compiler to modify the behavior of a method or anything else (even enable or disable entire features altogether) for a specific client. To keep things simple, I'll only show a small example here. The following code shows an event handler for a button's Clicked event on the MainPage:

public partial class MainPage : ContentPage
{
    public MainPage()
    {
        InitializeComponent();
    }

    private async void Button_OnClicked(object? sender, EventArgs e)
    {
#if DEFAULT_APP
        await DisplayAlert("Default App", "This is the default app", "OK");
#elif CLIENT_A
        await DisplayAlert("Client A", "This is client A", "OK");
#elif CLIENT_B
        await DisplayAlert("Client B", "This is client B", "OK");
#endif
    }
}

When the button is clicked, it will show a different text message based on the selected client configuration:

✨Whoohoo, we have a fully customized app!

Summary and next steps

I'm super excited about how easy it was to white-label a .NET MAUI app once I figured out all the necessary steps. I've demonstrated that it's possible to rebrand an app for different clients while maintaining the same code base and without having to frequently modify files and configurations.

A next step would be to include client-specific translation resources using .resx files, which could either be achieved by also copying the resource files into a single, shared location using the InitialTargets step or by using some kind of lookup mechanism (e.g. with a custom markup extension). If I find the time, I might investigate how to achieve this in the most straightforward way I can think of.

If you're wondering how I've set the client-specific status bar color on Android without having modified or included the AndroidManifest.xml file in my demonstration, check out the App.xaml.cs file in the repository that goes along with this post.

I hope this is useful to you. If you enjoyed this blog post, then you may follow me on LinkedIn, subscribe to this blog and star the GitHub repository for this post as well as my MAUI Samples repository, so you don't miss out on any future posts and developments.

Thanks for reading until here, and remember: sharing is caring!

Attributions

MauiWhiteLabelling project created using Matt Lacey's MAUI App Accelerator.

Jar with while label image generated using Microsoft Copilot/DALL-E.