.NET MAUI: Implementing platform behavior

.NET MAUI is right around the corner. Like its predecessor it offers sharing most code while building for multiple platforms. This includes the UI part. Of course there are still elements that require access to the platform, like accessing the camera, or something less common like customizing HttpMessageHandler behavior on iOS.

For common use-cases Xamarin.Essentials was offered to Xamarin Forms users. Now with MAUI this library will be included in your application by default (named MAUI Essentials), making it easy to use SecureStorage or implement permissions on Android and iOS. Additionally there is a MAUI Community Toolkit which, as the name implies, provides free tools: effects, converters, controls and more.

Manual platform implementation

In some apps .NET MAUI with the Essentials pack and the Xamarin Community Toolkit can be sufficient. For most apps though, there is a point where some custom adjustment is necessary. A good example would be control adjustment using Handlers or Effects. We’ll describe Handlers and their value in more detail in a future blogpost.

There are several ways to go about defining platform specific behavior in MAUI. Because of multi-targeting the code will be in the single shared project. With Xamarin Forms this was also possible using the Shared Project architecture, but the recommended and most common approach was using a PCL and implementing platform behavior using dependency injection. Let’s run through some options!

Using #if

To add a few lines of platform-specific code to an otherwise shared class we can use compiler directives like #if __IOS__. This is a quick and easy solution and keeps the related code close together, in the same file. For smaller handler mappings this could be a nice and easy to manage approach. It could also make Effects easier, because we don’t need the routed effects and DI registration of every platform implementation. With larger sections of platform-specific code this can get messy and harder to read though.
				
					//SecureSettingsProvider.cs

namespace MyApp.Settings;

public class SecureSettingsProvider
{
    public string GetSecureSetting(string context, string key)
    {
        return GetLocalSetting($"{context}-{key}");
    }

    private string GetLocalSetting(string contextSpecificKey)
    {
        #if __IOS__
        // retrieve value from secure storage / keychain
        #elif __ANDROID__
        // do the same for android
        #endif
    }
}
				
			

Using platform folders

MAUI projects using the single project structure will provide platform folders out-of-the-box. They will be compiled conditionally by default, e.g. the Android folder will only be included when building for Android. Within this option there are still 2 distinctive approaches:

Dependency injection using interfaces

Like the old-school Xamarin Forms pattern, using an interface in the cross-platform code, and explicit implementations in each platform folder. Any logic that could be shared will have to be copied between the implementations, although to make it slightly more complex, one could also define a cross-platform base class.

Partial classes

Partial classes are not a new concept in C#. Check the docs if you want to read more about them. We can define one cross-plaform partial class and another partial class with the same name and namespace in each platform folder. This is a pretty clean alternative to using #if directives when working with larger classes/code sections. A potential disadvantage of this approach is that both partial classes live quite far-away from each other, which could hinder discoverability and increase cognitive load, if there is a lot of shared code. If the partial class has little shared code, the experience is a lot like using interfaces.
				
					//SecureSettingsProvider.cs in shared code

namespace MyApp.Settings;
public partial class SecureSettingsProvider.cs
{
    public string GetSecureSetting(string context, string key)
    {
        var settingValue = GetLocalSetting($"{context}-{key}");

        // do some work that's shared between implementations
        if(String.IsNullOrEmtpy(something))
          return "nothing";
        else
          return settingValue;
    }
}
				
			
				
					//SecureSettingsProvider.cs in Android platform folder

namespace MyApp.Settings;
public partial class SecureSettingsProvider.cs
{
    private string GetLocalSetting(string contextSpecificKey)
    {
        // retrieve secure setting value for Android
    }
}
				
			

Using filename based conditional compiling

This pattern is used in the MAUI github repository itself. Concretely this again means using partial classes, but this time the platform specific implementation is defined in the same folder in a separate file. For example for our SecureSettingsProvider, we would have a partial class in SecureSettingsProvider.cs and another one in SecureSettingsProvider.Android.cs, SecureSettingsProvider.iOS.cs , etc.
A potential advantage of this is that platform implementations are directly next to the shared part, which improves discoverability and can reduce cognitive load.

This approach is not supported out of the box though. The compiler needs to be informed which files to include or more accurately, which NOT to include, so we need to add this information to our project file. For an example of how this could look, see the first +-30 lines in MultiTargeting.targets file in the MAUI repository. The following sample is taken from line 2-7 there and slightly adjusted:
				
					<ItemGroup Condition="$(TargetFramework.StartsWith('Xamarin.iOS')) != true AND $(TargetFramework.StartsWith('net6.0-ios')) != true AND $(TargetFramework.StartsWith('net6.0-maccatalyst')) != true ">
  <Compile Remove="**\**\*.iOS.cs" />
  <None Include="**\**\*.iOS.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
				
			
This ItemGroup definition informs the compiler to ignore all *.iOS.cs files in the project unless the current build target is Xamarin.iOS, net6.0-ios or .net6.0-maccatalyst. Similar project configuration can also be used to allow for custom platform folders thoughout the code:
				
					  <Compile Remove="**\iOS\**\*.cs" />
  <None Include="**\iOS\**\*.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
				
			

Which option is best?

As always in software development, the answer here is “it depends”. For multitargeting libraries, like MAUI itself, it is likely a good option to look at the the filename or nested folder approach we discussed. With a little project setup this allows you to group code by functionality, keeping related code close together.

For apps, it depends on how much platform-specific code really needs to be written. For some apps only a few adjustments may be needed using Effects, Behaviors and handler mappings. It is very possible for these to be only a few lines which allows for keeping everying in one file using #if directives. If these grow it is convenient to use the existing platform folders. In both these alternatives you can forego the need to use interfaces and register the platform services for dependency injection which can be convenient.

Please note that we’ve been looking at these patterns fairly strictly from MAUI perspective. Of course it is possible that you prefer to use interfaces to making testing and mocking easier. In that case you will be setting up the DI container anyway. It may still be easier to register one implementation and use above options, instead of registering every platform service separately, but keep in mind your overall architecture before choosing any of these patterns.

[elementor-template id="14073"]