Category Archives: MAUI

Android notifications using MAUI (Part 2 of 10)

In “Android notifications using MAUI (Part 1 of 10)” we created a simple MAUI application and implemented the Android specific code for notifications. In part 2 we mirror Notifications Tutorial Part 2 – ACTION BUTTONS & COLOR – Android Studio Tutorial by adding colour to our notifications along with action buttons.

Overview

We can customize our notifications by changing some parts of the notification’s foreground colour, we can also add buttons (and more) to the notification.

In some cases, when using a foreground service (for example) our application may not no longer be visible, but we want the user to be able to still interact with our application via the notifications. So let’s imagine a stopwatch or better still don’t imagine one, instead go and look at the Android Clock applet, when it goes into the background the notification and foreground service are made available, from here we can see the stop watch running but also press buttons to pause and reset the stop watch, these are action buttons and if your application is designed well, then these can carry out functionality within the foreground service that will appear in the main application when it reappears in the foreground.

Let’s get started

To ensure we’re overwriting our previous application/notification channels etc. uninstall the application it’s it’s on your emulator.

Let’s begin by editing the MainActivity SendOnChannel1 method from the previous post. Here’s what the code should look like

var activityIntent = new Intent(this, typeof(MainActivity));
var contentIntent = PendingIntent.GetActivity(this, 0, activityIntent, 0);

var broadcastIntent = new Intent(this, typeof(NotificationReceiver));
broadcastIntent.PutExtra(MainApplication.ToastMessage, message);
var actionIntent = PendingIntent.GetBroadcast(this, 0, broadcastIntent, PendingIntentFlags.UpdateCurrent);

var notification = new NotificationCompat.Builder(this, MainApplication.Channel1Id)
  .SetSmallIcon(Resource.Drawable.abc_ab_share_pack_mtrl_alpha)
  .SetContentTitle(title)
  .SetContentText(message)
  .SetPriority(NotificationCompat.PriorityHigh)
  .SetCategory(NotificationCompat.CategoryMessage)
  // set the fore colour for the button etc.
  .SetColor(Colors.Red.ToInt())
  .SetContentIntent(contentIntent)
  // when we tap the notification it will close
  .SetAutoCancel(true)
  // only show/update first time
  .SetOnlyAlertOnce(true)
  // can add upto three action buttons
  .AddAction(Resource.Drawable.abc_edit_text_material, "Toast", actionIntent)
  .Build();

_notificationManager.Notify(1, notification);

The first change (the first couple of lines) is that when the user clicks on a notification it does nothing in the original code, but by adding the content intent (as per the code above) we essentially tell the notification that when clicked go to this activity. In our case we create an intent for the MainActivity, but the notification requires a PendingIntent so we create that using PendingIntent.GetActivity. So basically clicking on the notification will bring the application to the foreground.

We’re going to also add a button to our notification, which you can see being set via AddAction (we’re again just reusing an existing resource here), the action/button name is “Toast” and we need to supply an intent for when it’s clicked (null will just not do anything). So we set the actionIntent to the one we created at the top of the method.

The actionIntent is going to be a BroadcastReceiver. So, we first create an intent for typeof(NotificationReceiver) (we’ll look at this type soon) and we add a key/value pair. The key is currently just a const in MainApplication (again for a real world application you’d probably have a class specific for these), it looks like this in MainApplication

public static readonly string ToastMessage = "toastMessage";

Back to the SendOnChannel1 code, we pass the message along with the ToastMessage key, so as you’d probably expect we’re going to popup up a toast message in Android with our message when the action button “Toast” is clicked.

The other changes to this code include setting the foreground colour. Actually this only seems to set the colour of progress bars and actions etc. not the main title and message. We SetAutoCancel to true to close the notification when tapped, we also add SetOnlyAlertOnce so only the first message of high importance on this channel causes the sound to be played and popup display, subsequent messages are just sent to the notification – the is less intrusive especially if you have potentially lots of notification updates on a high priority channel.

Oh, and I almost forgot, we use SetColor to set our colour. Now I’m using the MAUI colours, but ofcourse you may have a colour set as a resource i.e. Resource.Color.notificationColour taken from your Platform/Andourse/Resources/values/colors.xml file.

Adding a BroadcastReceiver

Android would normally require that we add the receiver to the application’s AndroindManifest.xml (application section). But MAUI allows us to declare the received using attributes, so create a new class named NotificationReceiver and it should look like this

[BroadcastReceiver(Enabled = true, Exported = false)]
public class NotificationReceiver : BroadcastReceiver
{
    public override void OnReceive(Context context, Intent intent)
    {
        var message = intent.GetStringExtra(MainApplication.ToastMessage);
        Toast.MakeText(context, message, ToastLength.Short).Show();
    }
}

Beautifully simple. We need to override the OnReceive method, which as the name suggests, receives messages. We get the value for the given key (ToastMessage) and then use the Android Toast code to display a popup message at the bottom of the Android screen). The only other thing to point out is that, as mentioned, we use the BroadcastReceiver attribute to register our receiver.

Code

Code for this an subsequent posts is found on my blog project.

Android notifications using MAUI (Part 1 of 10)

In my previous post Android Foreground Service using MAUI we looked at implementing a foreground service for Android using MAUI. We touched on notification channels and notifications in general but I decided to go a bit deeper into learning about what we can do with notifications…

I found a good way to learn what Android had to offer was by going through the excellent set of tutorials on the “Coding in Flow” channel on youtube (starting with Notifications Tutorial Part 1 – NOTIFICATION CHANNELS – Android Studio Tutorial).

We’re going to follow through those tutorials in the this and the next nine posts. The intention is to recreate the tutorials but using MAUI and C#, however I know (as I’ve already tried the code out) that there are several areas I was not able to get things to work 100% the same way as the tutorials. Now, this may be due to my code or the current lack of support in MAUI or simply that things have changed in the four years since those tutorials were posted – anyway so upfront, I had problems with some of the notification styles and also with the large icon – if I do figure a way to implement the same functionality I will update this and the subsequent post.

Getting Started

  • Create a MAUI application
  • We’ll add the package CommunityToolkit.Mvvm just so we can use the WeakReferenceMessenger to send messages to our platform specific code. We could ofcourse do this using an interface and register platform specific implementations of the interface, amongst other ways. But for simplicity here, we’ll pass messages around
  • Add the MessageData record
    public record MessageData(int Channel, string Title, string Message);
    

    This will just be used in the IMessenger to pass commands to our platform specific code.

  • In MauiProgram.cs before builder.Build() add the following code
    builder.Services.AddSingleton<MainPage>();
    builder.Services.AddSingleton<IMessenger, WeakReferenceMessenger>();
    
  • We’re going to use code-behind (again to keep things simple), so in the MainPage.xaml, within the ContentPage content, add the following
    <VerticalStackLayout>
       <Entry Text="Title" x:Name="Title" Margin="10" />
       <Entry Text="Message" x:Name="Message" Margin="10" />
    
       <Button Text="Send on Channel 1" Clicked="Channel1_OnClicked" Margin="10" />
       <Button Text="Send on Channel 2" Clicked="Channel2_OnClicked" Margin="10" />
    </VerticalStackLayout>
  • Now in the code behind MainPage.xaml.cs add the following code
    private readonly IMessenger _messenger;
    
    public MainPage(IMessenger messenger)
    {
       InitializeComponent();
    
       _messenger = messenger;
    }
    
    private void Channel1_OnClicked(object sender, EventArgs e)
    {
       _messenger.Send(new MessageData(1, Title.Text, Message.Text));
    }
    
    private void Channel2_OnClicked(object sender, EventArgs e)
    {
       _messenger.Send(new MessageData(2, Title.Text, Message.Text));
    }
    

At this point we have a basic test application with ways to enter a title, a message and send to notification channel 1 and/or channel 2 (we’ll discuss these in the next section).

Android platform specific code

So the MAUI shared code is complete, but now we need to start writing code specific to the Android platform. Note that there are alternatives to writing the code (as already suggests) such as creating platform specific services, but for this post we’re going to do things as simple as possible and reduce things (hopefully) to the bare minimum.

Go to the Platforms/Android folder and in MainApplication.cs add the OnCreate method and again for simplicity, we’ll just have this method set up our channels. So our code looks like this

public override void OnCreate()
{
  base.OnCreate();

  if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
  {
#pragma warning disable CA1416
    var channel1 = new NotificationChannel(Channel1Id, "Channel 1", NotificationImportance.High)
    {
      Description = "This is Channel 1"
    };

    var channel2 = new NotificationChannel(Channel2Id, "Channel 2", NotificationImportance.Low)
    {
      Description = "This is Channel 2"
    };

    if (GetSystemService(NotificationService) is NotificationManager manager)
    {
      manager.CreateNotificationChannel(channel1);
      manager.CreateNotificationChannel(channel2);
    }
#pragma warning restore CA1416
  }
}

We’ll also need to add the const values for the channel id’s, so add the following to the MainApplication class

public static readonly string Channel1Id = "channel1";
public static readonly string Channel2Id = "channel2";

These are public to make them accessible from our MainActivity, in a real world application you might prefer to have these in a separate class, but we’re not going to worry too much about such things here.

With the OnCreate method we need to check that we’re using API >= 21 (or Oreo) to use notification channels and I’m just disabling warnings from the compiler with the pragma’s.

We create two channels, the first we give a high importance, which means that by default when it receives a message it will make a sound and popup the message, unless the user changes their settings. If the user does change the settings, either they need to be reinstated or you need to uninstall and reinstall the application to get them back to default – you may find yourself doing this through the post to reset the application in the emulator.

The code to create the notification channels is fairly simple and hopefully self-explanatory.

Now, you don’t need to create the channels at start-up. If your application (as the one I’m working on that prompted the necessity to learn this stuff) only needs the channels when it goes into the background or when a button is clicked, then you can create the channels as and when required.

Finally for this section, we need to amend the MainActivity.cs to both receive messages via our MAUI UI and to then send the message through to the appropriate notification channel. We will add code to the constructor which simply routes messages from IMessenger like this

public MainActivity()
{
  var messenger = MauiApplication.Current.Services.GetService<IMessenger>();

  messenger.Register<MessageData>(this, (recipient, message) =>
  {
    if (message.Channel == 1)
    {
      SendOnChannel1(message.Title, message.Message);
    }
    else
    {
      SendOnChannel2(message.Title, message.Message);
    }
  });
}

We’re also going to need access to the NotificationManagerCompat so declare the following in the MainActivity class and override the OnCreate to set it, like this

private NotificationManagerCompat _notificationManager;

protected override void OnCreate(Bundle savedInstanceState)
{
  base.OnCreate(savedInstanceState);

  _notificationManager = NotificationManagerCompat.From(this);
}

Finally we’ll need the code for the SendOnChannel1 and SendOnChannel2 methods, which look like this

private void SendOnChannel1(string title, string message)
{
  var notification = new NotificationCompat.Builder(this, MainApplication.Channel1Id)
    .SetSmallIcon(Resource.Drawable.abc_ab_share_pack_mtrl_alpha)
    .SetContentTitle(title)
    .SetContentText(message)
    .SetPriority(NotificationCompat.PriorityHigh)
    .SetCategory(NotificationCompat.CategoryMessage)
    .Build();

    _notificationManager.Notify(1, notification);
}

private void SendOnChannel2(string title, string message)
{
  var notification = new NotificationCompat.Builder(this, MainApplication.Channel2Id)
    .SetSmallIcon(Resource.Drawable.abc_btn_check_material)
    .SetContentTitle(title)
    .SetContentText(message)
    .SetPriority(NotificationCompat.PriorityLow)
    .Build();

    _notificationManager.Notify(2, notification);
}

The two methods are currently pretty much the same, each Builder is supplied with a unique channel id (we’re reusing ours from the MainApplication). A notification requires a small icon, so we’re just reusing built in icons here (which may not show up very well) but feel free to add your own icon (which we will do in a subsequent post if you’re not sure how to). We’re setting priorities differently. If I recall these are duplicated from the code where we create the channels due to different versions of Android API support. So, we’re setting a category on channel 1 which just creates a category association with the channel – we’ll see more on this in a later post. Lastly we essentially add the notification to the NotificationManager with a unique id and we’re done.

Running our application

Now if you run your application, I’m using the Pixel 3 emulator and click the “Send on Channel 1” button, you should see a popup with the title you supplied (or defaults to Title in the text entry) and message you supplied (again defaulted to Message in the text entry) and an audible (annoying) sound, if you click it again you’ll get the same again. If, on the other hand you now click “Send on Channel 2” you’ll see another icon on the status bar appear for the channel 2 messages, but no popup or sound, this is because anything above low importance makes the sound (again unless disabled by the user). Low priority just displays the icon and you can view the message(s) by dragging down the status bar to view notifications.

So, that’s it for part 1. We now have the core MAUI code, we can pass messages around which are received by the platform specific code and which turns those messages from the UI into notifications for Android to display.

Code

Code for this an subsequent posts is found on my blog project.

Android Foreground Service using MAUI

In some situations you’ll want to keep some part of your application running even if the main application goes to sleep. For example the Android Clock applet when started will keep running even if you essentially close the Clock applet. It will display an icon in the status bar at the top right of the Android device and also include notifications.

Note: Code for this solution is available via my blog projects repository.

Getting Started

  • Create a MAUI application
  • We’ll add the package CommunityToolkit.Mvvm just so we can use the WeakReferenceMessenger to send messages to our platform specific code. We could ofcourse do this using an interface and register platform specific implementations of the interface, amongst other ways. But for simplicity here, we’ll pass messages around
  • Add the MessageData record
    public record MessageData(string Message, bool Start);
    

    This will just be used in the IMessenger to pass commands to our platform specific code.

  • In MauiProgram.cs before builder.Build() add the following code
    builder.Services.AddSingleton<MainPage>();
    builder.Services.AddSingleton<IMessenger, WeakReferenceMessenger>();
    
  • We’re going to simply use code-behind (again to keep things simple), so in the MainPage.xaml, within the ContentPage content, add the following
    <VerticalStackLayout>
      <Entry x:Name="Input" Margin="10" />
      <Button Text="Start Service" Clicked="Start_OnClicked" Margin="10" />
      <Button Text="Stop Service" Clicked="Stop_OnClicked" Margin="10" />
    </VerticalStackLayout>
    
  • Now in the code behind MainPage.xaml.cs add the following code
    private readonly IMessenger _messenger;
    
    public MainPage(IMessenger messenger)
    {
       InitializeComponent();
    
       _messenger = messenger;
    }
    
    private void Start_OnClicked(object sender, EventArgs e)
    {
       _messenger.Send(new MessageData(Input.Text, true));
    }
    
    private void Stop_OnClicked(object sender, EventArgs e)
    {
       _messenger.Send(new MessageData(Input.Text, false));
    }
    

At this point we have a basic test application and a way to start/stop the foreground service.

Android platform specific code

The first thing we’ll need to do is edit the Platforms/Android/MainApplication.cs file. We need to start by adding a notification channel which will be displayed in the statusbar and act as the interaction point with our foreground service.

The first thing we need is a channel id, this is a unique identifier within our application to denote the notification channel we’ll want to communicate with. It’s possible for us to have multiple notification channels, but in this instance we’ll just use the one. So in MainApplication add

public static readonly string ChannelId = "exampleServiceChannel";

Next, override OnCreate so it looks like this

public override void OnCreate()
{
  base.OnCreate();

  if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
  {
#pragma warning disable CA1416
    var serviceChannel =
       new NotificationChannel(ChannelId, "Example Service Channel", NotificationImportance.Default);

       if (GetSystemService(NotificationService) is NotificationManager manager)
       {
          manager.CreateNotificationChannel(serviceChannel);
       }
#pragma warning restore CA1416
   }
}

As you can see, this code is targeting API >= 21 (Oreo). We create a NotificationChannel with the ChannelId we declare earlier. The notification has a name “Example Service Channel” and an importance level. This needs to be set to Low or higher. If we set it anything above Low we’ll hear a sound when the notification is sent/updated. Whilst the user can disable this, you may or may not prefer a low importance so your service is less intrusive. Ofcourse it’s all dependent upon your requirements.

Before we get to the foreground service itself, let’s update the MainActivity.cs to handler our IMessenger messages from the UI.

In the MainActivity constructor add the following

var messenger = MauiApplication.Current.Services.GetService<IMessenger>();

messenger.Register<MessageData>(this, (recipient, message) =>
{
  if (message.Start)
  {
    StartService(message.Message);
  }
  else
  {
    StopService();
  }
});

This is just some plumbing code, basically when we press the start service button we’ll receive a message with Start as true and a message from our entry control. Obviously if we get start true we’ll call StartService and if not, we’ll call StopService.

Here’s the start and stop service code

private void StartService(string input)
{
  var serviceIntent = new Intent(this, typeof(ExampleService));
  serviceIntent.PutExtra("inputExtra", input);

  StartService(serviceIntent);
}

private void StopService()
{
  var serviceIntent = new Intent(this, typeof(ExampleService));
  StopService(serviceIntent);
}

Start service is passed the entry text and creates an intent associated with our service code (ExampleService). We add a key/value in the PutExtra method. The key is whatever we want and obviously the value is whatever we want to associated with the key. This will be handled via our service, so the key would best be a const in a real world application.

The StartService(serviceIntent) call is used on an activity that is already in the foreground – so in our case to be able to press the button the application must be in the foreground. If your application might be in the background you can call StartForegroundService(serviceIntent) to both force the application into the foreground and start the service.

Two more things before we get to the service code. The service will require a small icon, so create the folder Platforms/Android/Resources/drawable and add a .PNG into this. Mine’s named AppIcon.png as it’s a duplicate of the actual application icon.

Now we need to declare the requirement of the user permission FOREGROUND_SERVICE, so edit the AndroindManifest.xml file and add the following within the manifest section

<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

The Service code

To recap, at this point we have a basic user interface, which allows us to start and stop a service. We have a notification channel created along with the code for starting and stopping a service. We now need the service. Mine’s named ExampleService.cs and is within the Platforms/Android folder alongside the MainActivity.cs etc.

Create a basic service with the following code

[Service]
internal class ExampleService : Service
{
    public override IBinder OnBind(Intent intent)
    {
        return null;
    }
}

and here’s the usings, just in case you need to check

using Android.App;
using Android.Content;
using Android.OS;
using AndroidX.Core.App;

We’re not using the OnBind method which is used for “Bound Services” so simply return null here. We’ll also need the Service attribute on the class to register our service.

We now need to override the OnStartCommand which will get information for the key/value we added in the MainActivity (via PutExtra). We’ll also want to create an Intent back to our MainActivity so when the user clicks on the notification it can bring the application back into the foreground. Hence we create the notificationIntent.

The NotificationCompat.Builder needs to use the same channel id that we want to send messages to and it requires a PendingIntent within the builder. In the builder we must supply the icon image via SetSmallIcon and we’ll need the content title set. You can see we’re sending the message to the SetContentText.

Finally, unless we’re aiming to reuse this notification we call Build on it and pass this along with an id to the StartForeground.

public override StartCommandResult OnStartCommand(Intent intent, StartCommandFlags flags, int startId)
{
  var input = intent.GetStringExtra("inputExtra");

  var notificationIntent = new Intent(this, typeof(MainActivity));
  var pendingIntent = PendingIntent.GetActivity(this, 0, notificationIntent, 0);

  var notification = new NotificationCompat.Builder(this, MainApplication.ChannelId)
     .SetContentTitle("Example Service")
     .SetContentText(input)
     .SetSmallIcon(Resource.Drawable.AppIcon)
     .SetContentIntent(pendingIntent)
     .Build();

  StartForeground(1, notification);

  return StartCommandResult.NotSticky;
}

If we are intending to send multiple messages you would want to create the notification (without the Build call) and reuse this to update a notification channel, hence you’d call Build within StartForeground code, like this

var notification = new NotificationCompat.Builder(this, MainApplication.ChannelId)
     .SetContentTitle("Example Service")
     .SetContentText(input)
     .SetSmallIcon(Resource.Drawable.AppIcon)
     .SetContentIntent(pendingIntent)

StartForeground(1, notification.Build());

If we imagine that after StartForeground we start some task, for example downloading a file, then remember this should be run on a background thread and it could then update the notification before again calling the StartForeground(1, notification.Build()) code, ofcourse assuming you’re running the process from the service.

Finally this method returns StartCommandResult.NotSticky. This tells the OS to not bother recreating the application if (for example) the device runs out of memory. StartCommandResult.Sticky is used to tell the OS to recreate the service when it has enough memory. There’s also the option StartCommandResult.RedeliverIntent which is like the NotSticky but if the service is killed before calling stopSelf() for a given intent then the intent will be re-delivered until it completes.

References

This code presented here is based on How to Start a Foreground Service in Android (With Notification Channels) but translated to C# and MAUI code.

ImageButton or Image with TapGestureRecognizer in MAUI ?

MAUI includes a Button (as you’d expect) as well as an ImageButton which allows us to display (as I’m sure you guessed) and image instead of text. Sadly (at least in the current version of .NET 7.0) this does not work nicely on Windows and I want my MAUI app to deploy to iOS, Android and Windows.

An Image Button takes and image source and we can supply a command etc. like this

<ImageButton 
   Source="start.png" 
   Command="{Binding StartCommand}" />

I’ve not tested this yet on a device, but on an Android emulator the image button doesn’t have the pressed, ripple effect that a standard button has, so it’s not that obvious that it’s been clicked. However it displays the image correctly and with transparency. Much to my surprise though, on Windows, the transparency is lost and instead we get a button with some percentage of opacity, however on Windows it does look more like a button – so good and bad points.

So, at this time, the best way to display an image and respond to tap events appears to still be using an Image and TapGestureRecognizer, like this

<Image Source="start.png">
   <Image.GestureRecognizers>
      <TapGestureRecognizer Command="{Binding StartStopCommand}"/>
   </Image.GestureRecognizers>
</Image>

This still lacks the button-like feedback (such as a ripple). Using something like the AlohaKit.Animations NuGet package we could add a Tapped event to our TapGestureRecognizer and in code behind have something like

private void TapGestureRecognizer_OnTapped(object? sender, TappedEventArgs e)
{
   if (sender is View view)
   {
      view.Animate(new StoryBoard(new List<AnimationBase>
      {
         new ScaleToAnimation { Scale = 1.2, Duration = "200" },
         new ScaleToAnimation { Scale = 1, Duration = "100" }
      }));
   }
}

Whilst it’s not a button ripple effect, it does give some feedback on the image being tapped – obviously it would be worth looking at alternative animations to best suit your needs.

Unit testing your MAUI project

Note: I found this didn’t work correctly on Visual Studio for Mac, I’ll update the post further if I do get it working.

This post is pretty much a duplicate of Adding xUnit Test to your .NET MAUI Project but just simplified for me to quickly repeat the steps which allow me to write unit tests against my MAUI project code.

So, you want to unit test some code in your MAUI project. It’s not quite as simple as just creating a test project then referencing the MAUI project. Here are the steps to create an NUnit test project with .NET 7 (as my MAUI project has been updated to .NET 7).

  • Add a new NUnit Test Project to the solution via right mouse click on the solution, Add | Project and select NUnit Test Project
  • Open the MAUI project (csproj) file and prepend the net7.0 to the TargetFrameworks so it looks like this
    <TargetFrameworks>net7.0;net7.0-android;net7.0-ios;net7.0-maccatalyst</TargetFrameworks>
    
  • Replace <OutputType>Exe</OutputType> with the following
    <OutputType Condition="'$(TargetFramework)' != 'net7.0'">Exe</OutputType>
    

    Yes, this is TargetFramework singular. You may need to reload the project.

  • Now in your unit test project you can reference this MAUI project and write your tests

So, why did we carry out these steps?

Our test project was targeting .NET 7.0 but our MAUI project was targeting different platform implementations of the .NET 7 frameworks, i.e those for Android etc. We need the MAUI project to build a .NET 7.0 compatible version hence added the net7.0 to the TargetFrameworks.

The change to add the OutputType ensures that we only build an EXE output for those other frameworks, and therefore for .NET 7.0 we’ll have a DLL to reference instead in our tests.

Now we can build and run our unit tests.

AdaptiveTrigger working in MAUI 7.x

This is just a quick update to my post Responsive, Reactive, Adaptive design in MAUI. The AdaptiveTrigger now works, so we can create adaptive UI’s like this

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="AdaptiveTriggerTest.MainPage">
    <VisualStateManager.VisualStateGroups>
        <VisualStateGroup x:Name="Responsive">
            <VisualState x:Name="Large">
                <VisualState.StateTriggers>
                    <AdaptiveTrigger MinWindowWidth="1200" />
                </VisualState.StateTriggers>
                <VisualState.Setters>
                    <Setter Property="BackgroundColor" Value="Blue"/>

                    <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Green"/>
                    <Setter TargetName="MainLabel" Property="Label.FontSize" Value="128"/>
                </VisualState.Setters>
            </VisualState>
            <VisualState x:Name="Default">
                <VisualState.StateTriggers>
                    <AdaptiveTrigger MinWindowWidth="0" />
                </VisualState.StateTriggers>
                <VisualState.Setters>
                    <Setter Property="BackgroundColor" Value="Azure"/>

                    <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Red"/>
                    <Setter TargetName="MainLabel" Property="Label.FontSize" Value="48"/>
                </VisualState.Setters>
            </VisualState>
        </VisualStateGroup>
    </VisualStateManager.VisualStateGroups>

    <VerticalStackLayout
            Spacing="25"
            Padding="30,0"
            VerticalOptions="Center">

        <Label
                x:Name="MainLabel"
                Text="Hello, World!"
                SemanticProperties.HeadingLevel="Level1"
                FontSize="32"
                HorizontalOptions="Center" />
    </VerticalStackLayout>
</ContentPage>

The AdaptiveTrigger MinWindowWidth=”1200″ basically designates what the UI style etc. is for Window’s with a width >= 1200. The second AdaptiveTrigger is for anything smaller.

Responsive, Reactive, Adaptive design in MAUI

Why do we need to be responsive, reactive/adaptive?

Initially, when Apple introduced the first iPhone (and to be honest way before that with things like Windows CE) we had a fairly small and fixed sized device. Implementing applications for this, from a UI perspective, was fairly simple in that the constraints of the device size meant our applications wouldn’t need to dynamically change to different device sizes etc.

As time went by, we got tablets. Some applications weren’t rewritten for tablet and the UI would simply get scaled up and hence the UI looked like the phone UI just on a larger screen. In truth we can see the same issue with portrait and landscape modes. Some applications simply turn off the option to support landscape to force the user to stick with the UI design for portrait, some applications simply ignore the fact the screen has been rotated and use the same layout for portrait and landscape.

Now with OS’s supporting side by side docking, we can no longer just think of our device screen size as being static. Instead docking an application on something like my Samsung S6, where the application was designed for a tablet now needs to also take into account the potential of the application being docked.

Okay, that’s all a long-winded way of saying. To make truly cross platform UI and usable applications we need to think about our, previously static dimensioned, applications and resizable. So, basically just like on a desktop application.

What do we need our application to do?

Let’s look at some goals for our MAUI (or any framework) application to start to fulfill our needs. These are discussed in my previous post Introduction to Responsive, Reactive, Adaptive design but let’s now look into these concepts with a view to how we might implement such things.

  • Element styles – this may relate to any style used, but to be specific, let’s look at fonts as an example. We need to change our font sizes for the different sized devices. I mean a 24 pt font might look great on mobile but becomes a small island of text in a larger landscape of a tablet or desktop app.
  • Element Sizes – we need a way to change the sizes of elements to suit the size of the device. For example, a button with a fixed size for mobile will become lost on larger screens. Ofcourse MAUI, WPF etc. come with grid and other layouts that can help here.
  • Element Positioning – we need a way to move elements around the screen. Displaying buttons on the bottom of the screen in portrait mode may look great, but when switch to landscape, maybe they need to be repositioned to the side of the screen (for example).
  • Changing layouts – taking a mobile app that has two section, one page with a list with navigation items and another page that displays when click by the navigation items is great on mobile but for tablet or desktop, would be better if the navigation page become a docked panel on the left of the screen and clicking links shows the navigated page on the right of the screen – this is a pretty standard layout change you’ll see in lots of apps.

Note: This post is primarily aimed at MAUI, however the concepts etc. are valid for other UI frameworks and also are not limited to mobile devices. Desktop applications can also offer better user experiences if they can adapt to the size of the window displaying them.

Before looking into some code example etc. Create yourself a MAUI application (if you want to follow along) and we’re going to solely. I’m going to run on Windows and run the MAUI application as a Windows application, because I can easily resize it to see the effects of my code. Ofcourse if we get things working on a Windows desktop application, we should have no trouble with the same code on mobile devices (as long as we choose the right style options etc.).

Element Styles

This should be the simplest thing to implement. We’ve got a MainPage (in MAUI) with some text on it, so the code looks like this

<Grid>
   <Label 
      Text="My Application"
      VerticalOptions="Center" 
      HorizontalOptions="Center" />
</Grid>

Now we’ll stick to running this as a Windows application as this will allow us to dynamically resize our window/view. If you run your MAUI app the text will be an okay size in the middle of the screen, but we want to be able to change the label’s font size based upon the size of the window/device.

We know that we can use OnIdiom along with a resource to set the FontSize like this

Note: I’ve extracted only the code which changes, so obviously you’ll need to copy this code to the correct places if you’re following along.

<ContentPage.Resources>
    <OnIdiom x:Key="FontSize" x:TypeArguments="x:Double" 
      Default="24" Phone="48" Tablet="96" Desktop="128"/>
</ContentPage.Resources>

<Label 
   Text="My Application"
   VerticalOptions="Center" 
   HorizontalOptions="Center" 
   FontSize="{StaticResource FontSize}"/>

In the above, if you run the app. in Windows you’ll get the Desktop assigned FontSize (128) and on a phone (or phone emulator) the text will displayed using that assigned FontSize. However, as you’ll have noticed, this only partially fulfills our requirements. It does display with different font sizes but it’s static. If you dock an app side by side on a tablet with this, the FontSize remains the same – as you probably expect as this is solely checking what the device/idiom is.

However, we now see that we can use resources to change things based upon some device value. We just need a way to respond to our Window size dynamically.

Responding the page size changes

MAUI 6.x does not have anything that handles this sort of thing for us, there is the AdaptiveTrigger, but in the Maui 6.x releases this does not work, so we will look at it later and it may eventually be the best solution, but for now I’m on 6.x and hence it’s unusable, so let’s proceed with what we currently have available.

The simplest way to achieve this, for now, is to write some code in our page’s code behind. If we change our MainPage code to look like this

public MainPage()
{
   InitializeComponent();
   SizeChanged += OnSizeChanged;
}

private void OnSizeChanged(object sender, EventArgs e)
{
   // code for resizing goes here
}

Let’s remove the OnIdiom code and the FontSize from the label in our previous example and now add a name to the label so it looks like this

<Label 
   x:Name="MainLabel"
   Text="My Application"
   VerticalOptions="Center" 
   HorizontalOptions="Center" />

Now change our new OnSizeChange method to have the following

MainLabel.FontSize = Width > 400 ? 128 : 48;

This is basically what we want to achieve, but this isn’t very reusable by other components or properties, but it works. Let’s move a step further towards our goals and change things so we can have our XAML change values for us. To do this, we’re going to switch to using the Visual State Manager (VSM). Change the OnSizeChange code to the following

VisualStateManager.GoToState(MainLabel, Width > 400 ? "Large" : "Default");

At this point we’re effectively moving the logic for setting the sizes etc. into our XAML. This is a good step forward, but we are still (at this time) tied to the MainLabel element (and this is not good). Bare with me.

Let’s look at how we would use this change in our XAML – change the Label to look like this

<Label x:Name="MainLabel"
   Text="My Application"
   VerticalOptions="Center" 
   HorizontalOptions="Center">
   <VisualStateManager.VisualStateGroups>
      <VisualStateGroup x:Name="Responsive">
         <VisualState x:Name="Large">
            <VisualState.Setters>
               <Setter Property="FontSize" Value="128" />
               <Setter Property="TextColor" Value="Green" />
            </VisualState.Setters>
         </VisualState>
         <VisualState x:Name="Default">
            <VisualState.Setters>
               <Setter Property="FontSize" Value="48" />
               <Setter Property="TextColor" Value="Red" />
            </VisualState.Setters>
         </VisualState>
     </VisualStateGroup>
  </VisualStateManager.VisualStateGroups>
</Label>

As previously stated, we are still bound to the x:Name visual element (which is not perfect) but we can now change any property in this named element, based upon the visual state as demonstrated by change FontSize and TextColor.

This approach also suffers a problem with multiple controls reusing the VisualState names such as x:Name=”Large”. There may be a way around this that I’ve not discovered yet.

Our end goal is for multiple controls changing based upon the VSM states. Let’s start by moving the VSM XAML to the ContentPage itself. This means we no longer care about the explicit element accepting the state, but it’s moved to the ContentPage and from there we reference the specific elements using TargetName

<VisualStateManager.VisualStateGroups>
   <VisualStateGroup x:Name="Responsive">
      <VisualState x:Name="Large">
         <VisualState.Setters>
            <Setter Property="BackgroundColor" Value="Blue"/>

            <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Green"/>
            <Setter TargetName="MainLabel" Property="Label.FontSize" Value="128"/>
         </VisualState.Setters>
      </VisualState>
      <VisualState x:Name="Default">
         <VisualState.Setters>
            <Setter Property="BackgroundColor" Value="Azure"/>

            <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Red"/>
            <Setter TargetName="MainLabel" Property="Label.FontSize" Value="48"/>

         </VisualState.Setters>
      </VisualState>
   </VisualStateGroup>
</VisualStateManager.VisualStateGroups>

<Grid>
   <Label x:Name="MainLabel"
      Text="My Application"
      VerticalOptions="Center" 
      HorizontalOptions="Center" />
</Grid>

We’ll again need to change the code behind, but now just need to send state changes to the page itself, i.e.

VisualStateManager.GoToState(this, Width > 400 ? "Large" : "Default");

As mentioned, what we’ve done is gained the ability to change, not only the label FontSize and TextColor but also the BackgroundColor of the Page and potentially anything else displayed within the page. We’ve also removed any knowledge of the controls displayed on the page from the code behind (i.e. removed the name MainLabel from the code behind).

One more issue we might want to look into is that we are currently coding the different breakpoint size i.e. Width > 400 into the code behind. It would be better if we could move this to the XAML or some other mechanism not requiring us to edit the code behind at all.

AdaptiveTrigger

One way of removing the VisualStateManager code and the handling of the SizeChanged event is to use the AdaptiveTrigger.

At the time of writing (6.x MAUI) this doesn’t work correctly, it appears to have been fixed in 7.x, so for now we cannot use the AdaptiveTrigger, but let’s take a look at the code changes that should work when it’s fixed.

We’d remove all the code we added to the page’s code behind and our XAML would look something like this

<VisualStateManager.VisualStateGroups>
   <VisualStateGroup x:Name="Responsive">
      <VisualState x:Name="Large">
         <VisualState.StateTriggers>
            <AdaptiveTrigger MinWindowWidth="1200" />
         </VisualState.StateTriggers>
         <VisualState.Setters>
            <Setter Property="BackgroundColor" Value="Blue"/>

            <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Green"/>
            <Setter TargetName="MainLabel" Property="Label.FontSize" Value="128"/>
         </VisualState.Setters>
         </VisualState>
      <VisualState x:Name="Default">
         <VisualState.StateTriggers>
            <AdaptiveTrigger MinWindowWidth="0" />
         </VisualState.StateTriggers>
         <VisualState.Setters>
            <Setter Property="BackgroundColor" Value="Azure"/>

            <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Red"/>
            <Setter TargetName="MainLabel" Property="Label.FontSize" Value="48"/>
         </VisualState.Setters>
      </VisualState>
   </VisualStateGroup>
</VisualStateManager.VisualStateGroups>

Lines such as <AdaptiveTrigger MinWindowWidth=”1200″ /> are effectively defining our UI breakpoints. We’ll discuss more around breakpoints in the next section.

Breakpoints

We’ve seen that whatever solution we try to use, we’ll need some way to define our breakpoints. The dimensions at which we change layouts etc. If we look to create breakpoints in line with bootstrap documentation, for example. Then we might declare some resources like this

<x:Double x:Key="ExtraSmall">0</x:Double>
<x:Double x:Key="Small">576</x:Double>
<x:Double x:Key="Medium">768</x:Double>
<x:Double x:Key="Large">992</x:Double>
<x:Double x:Key="ExtraLarge">1200</x:Double>
<x:Double x:Key="ExtraExtraLarge">1400</x:Double>

and use these in our AdaptiveTrigger’s.

Great, but what can we use now?

As stated several times, as of MAUI 6.x we cannot use AdaptiveTrigger’s, so what can we do which won’t end up requiring us to write code-behind etc.? Well, if we stick to using the VSM, then we need a way to attach to the ContentPage and a way to use the VSM to trigger our UI changes.

One way to achieve this is by creating a behavior for the ContentPage type something like this

public class BreakpointBehavior : Behavior<Page>
{
    protected override void OnAttachedTo(Page page)
    {
        page.SizeChanged += PageSizeChanged;
        base.OnAttachedTo(page);
    }

    protected override void OnDetachingFrom(Page page)
    {
        page.SizeChanged += PageSizeChanged;
        base.OnDetachingFrom(page);
    }

    private void PageSizeChanged(object sender, EventArgs e)
    {
        if (sender is Page page)
        {
            VisualStateManager.GoToState(page, ToState(page.Width));
        }
    }

    private string ToState(double width)
    {
        if (width >= 1400)
            return "ExtraExtraLarge";
        if (width >= 1200)
            return "ExtraLarge";
        if (width >= 992)
            return "Large";
        if (width >= 768)
            return "Medium";
        if (width >= 576)
            return "Small";

        return "ExtraSmall";
    }
}

and now our ContentPage would look like this

<ContentPage.Behaviors>
   <extensions:BreakpointBehavior />
</ContentPage.Behaviors>

<VisualStateManager.VisualStateGroups>
   <VisualStateGroup x:Name="Responsive">
      <VisualState x:Name="ExtraExtraLarge">
         <VisualState.Setters>
            <Setter Property="BackgroundColor" Value="Blue"/>

            <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Pink"/>
            <Setter TargetName="MainLabel" Property="Label.FontSize" Value="128"/>
         </VisualState.Setters>
      </VisualState>
      <VisualState x:Name="Large">
         <VisualState.Setters>
            <Setter Property="BackgroundColor" Value="Blue"/>

            <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Green"/>
            <Setter TargetName="MainLabel" Property="Label.FontSize" Value="96"/>
         </VisualState.Setters>
      </VisualState>
      <VisualState x:Name="Medium">
         <VisualState.Setters>
            <Setter Property="BackgroundColor" Value="Azure"/>

            <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Red"/>
            <Setter TargetName="MainLabel" Property="Label.FontSize" Value="48"/>

         </VisualState.Setters>
      </VisualState>
   </VisualStateGroup>
</VisualStateManager.VisualStateGroups>

<Grid>
   <Label x:Name="MainLabel"
      Text="My Application"
      VerticalOptions="Center" 
      HorizontalOptions="Center" />
</Grid>

Essentially, we add visual states to match what the BreakpointBehvior sets and change our UI accordingly. We might look to make additions to the behavior to allow us to set the breakpoint properties via our XAML that way, we can respond to custom defined breakpoints.

We’ve covered a lot of ground but only really looked at the possibilities for responsive design, i.e. we can change properties but we’re not changing the layout.
Also, this code does not handle portrait or landscape orientations.

Changing layouts altogether

Whilst we can change properties on our layouts using the example shown here. It really would be much simpler if we could simply change layouts altogether and design the different layouts required separately. This is definitely useful when looking at portrait/landscape changes.

For Xamarin Forms I had a simple way of handling this, it may not be the best way or efficient, but it allowed me to host different views in a ContentView in a very simple way.

See my post Handling orientation in Xamarin Forms for the code of OnOrientation. This code works with MAUI.

Our XAML might look something like this

<extensions:OnOrientation>
   <extensions:OnOrientation.Portrait>
      <VerticalStackLayout>
         <Label Text="My Application Portrait"
            VerticalOptions="Center" 
            HorizontalOptions="Center" />
      </VerticalStackLayout>
   </extensions:OnOrientation.Portrait>
   <extensions:OnOrientation.Landscape>
      <Grid>
         <Label Text="My Application Landscape"
            VerticalOptions="Center" 
            HorizontalOptions="Center" />
      </Grid>
   </extensions:OnOrientation.Landscape>
</extensions:OnOrientation>

So, what about an alternative to the code above?

Changing layouts using DataTemplates and DataTemplateSelector

MAUI, WPF etc. already has the ability to define alternate layouts templates. We can create DataTemplate resources, one for portrait, one for landacape and then use AdaptiveTriggers or VSM to be used based upon the device orientation. We can then define a DataTemplateSelector to simply switch in and out the template based upon orientation (a bit like my OnOrientation code, above).

Let’s forget about the device info telling us what the orientation of the device is, but instead we’ll simply define landscape ad having a Width great then the height otherwise it’s in portrait orientation.

We’re going to now start to put together some of the pieces we’ve already discussed…

Sorry, this is a bigger chunk of code, but our ContentPage should now look like the following

    <ContentPage.Resources>
        <DataTemplate x:Key="Portrait">
            <VerticalStackLayout>
                <VisualStateManager.VisualStateGroups>
                    <VisualStateGroup x:Name="Responsive">
                        <VisualState x:Name="Medium">
                            <VisualState.Setters>
                                <Setter Property="BackgroundColor" Value="Blue"/>

                                <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Green"/>
                                <Setter TargetName="MainLabel" Property="Label.FontSize" Value="128"/>
                            </VisualState.Setters>
                        </VisualState>
                        <VisualState x:Name="Small">
                            <VisualState.Setters>
                                <Setter Property="BackgroundColor" Value="Azure"/>

                                <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Red"/>
                                <Setter TargetName="MainLabel" Property="Label.FontSize" Value="48"/>

                            </VisualState.Setters>
                        </VisualState>
                    </VisualStateGroup>
                </VisualStateManager.VisualStateGroups>

                <Label x:Name="MainLabel"
                       Text="My Application Portrait"
                       VerticalOptions="Center" 
                       HorizontalOptions="Center" />
            </VerticalStackLayout>
        </DataTemplate>
        <DataTemplate x:Key="Landscape">
            <Grid>
                <VisualStateManager.VisualStateGroups>
                    <VisualStateGroup x:Name="Responsive">
                        <VisualState x:Name="ExtraExtraLarge">
                            <VisualState.Setters>
                                <Setter Property="BackgroundColor" Value="Blue"/>

                                <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Green"/>
                                <Setter TargetName="MainLabel" Property="Label.FontSize" Value="128"/>
                            </VisualState.Setters>
                        </VisualState>
                        <VisualState x:Name="Medium">
                            <VisualState.Setters>
                                <Setter Property="BackgroundColor" Value="Azure"/>

                                <Setter TargetName="MainLabel" Property="Label.TextColor" Value="Red"/>
                                <Setter TargetName="MainLabel" Property="Label.FontSize" Value="48"/>

                            </VisualState.Setters>
                        </VisualState>
                    </VisualStateGroup>
                </VisualStateManager.VisualStateGroups>

                <Label x:Name="MainLabel"
                       Text="My Application Landscape"
                       VerticalOptions="Center" 
                       HorizontalOptions="Center" />
            </Grid>
        </DataTemplate>
        <extensions:OrientationDataTemplateSelector x:Key="OrientedView"
            Landscape="{StaticResource Landscape}" 
            Portrait="{StaticResource Portrait}" />
    </ContentPage.Resources>

    <ContentPage.Behaviors>
        <extensions:AdaptableBehavior OrientationTemplateSelector="{StaticResource OrientedView}" />
    </ContentPage.Behaviors>

There’s a lot there, but hopefully it makes sense. We’re defining two data templates, the first for Portrait, the second for Landscape, we’re also defining state changes based upon some breakpoints, Medium and Small. We also define the actual UI/layout within each template.

Next, we’re using a DataTemplateSelector, that I’ve created, named OrientationDataTemplateSelector. This will simply choose the data template based upon the orientation (width against height) of the window/view. The code for this looks like this

public class OrientationDataTemplateSelector : DataTemplateSelector
{
    public DataTemplate Landscape { get; set; }
    public DataTemplate Portrait { get; set; }

    protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
    {
        return item?.ToString() == "Portrait" ? Portrait : Landscape;
    } 
}

This is just an example, you might prefer to use enum’s for the value, for example.

The DataTemplateSelector is simply a way to say use this template for landscape and this one for portrait but we now need a way to tell the selector when to use each of these data templates. This is what I’ve created the AdaptableBehavior for. Here’s the code

public class AdaptableBehavior : Behavior<ContentPage>
{
    public static readonly BindableProperty OrientationTemplateSelectorProperty = BindableProperty.Create(nameof(OrientationTemplateSelector),
        typeof(DataTemplateSelector), typeof(OrientationLayout));

    public DataTemplateSelector OrientationTemplateSelector
    {
        get => (DataTemplateSelector)GetValue(OrientationTemplateSelectorProperty);
        set => SetValue(OrientationTemplateSelectorProperty, value);
    }

    private static View CreateItemView(object item, DataTemplate dataTemplate)
    {
        if (dataTemplate != null)
        {
            var view = (View)dataTemplate.CreateContent();
            view.BindingContext = item;
            return view;
        }

        return new Label { Text = item?.ToString(), HorizontalTextAlignment = TextAlignment.Center };
    }


    protected override void OnAttachedTo(ContentPage page)
    {
        page.SizeChanged += PageSizeChanged;
        base.OnAttachedTo(page);
    }

    protected override void OnDetachingFrom(ContentPage page)
    {
        page.SizeChanged += PageSizeChanged;
        base.OnDetachingFrom(page);
    }

    private void PageSizeChanged(object sender, EventArgs e)
    {
        if (sender is ContentPage page)
        {
            var orientation = GetOrientation(page);
            var dataTemplate = OrientationTemplateSelector;
            var selected = dataTemplate.SelectTemplate(orientation, page);
            page.Content = CreateItemView(orientation, selected);

            VisualStateManager.GoToState(page.Content, ToState(page.Width));
        }
    }

    private string GetOrientation(Page page)
    {
        return page.Width > page.Height ? "Landscape" : "Portrait";
    }

    private string ToState(double width)
    {
        if (width >= 1400)
            return "ExtraExtraLarge";
        if (width >= 1200)
            return "ExtraLarge";
        if (width >= 992)
            return "Large";
        if (width >= 768)
            return "Medium";
        if (width >= 576)
            return "Small";

        return "ExtraSmall";
    }
}

We can make the breakpoints settable via XAML or some other way to configure them, but you get the idea.

The AdaptableBehavior now does as our earlier example and responds to both size (breakpoints) as well as orientation. It uses the supplied DataTemplateSelector to choose the correct data template to use and then uses VSM to tell the template which size breakpoints to use.

If you run this example in Windows you’ll find it handles some different breakpoints but then when the breakpoint is not assigned any values the sizes will switch back to the defaults. As the AdaptableBehavior has no idea what’s listening to state changes, it’s difficult for it to handle this itself.

Ofcourse, there are other ways to achieve this, such as making the AdaptableBehavior the DataTemplateSelector (essentially like my Xamarin Forms OnOrientation code) and simply supply it with the various orientations and breakpoint UI templates. I’ll leave that to the reader to look into.

Is handling breakpoints using width good enough?

In much of this post we talk about breakpoints around the display’s width, but is just handling the MinWindowWidth good enough for our needs.

If we solely handle the width as our breakpoint trigger then we have a potential issue if the device switches to landscape mode. For example, let’s assume that we set the font size to 48 for small displays (upto 1200) and 96 for larger or equal to 1200 width. On a mobile phone in portrait the font may look perfect at 48, but when the phone is rotated to landscape mode the display is greater than 1200, switching to the larger font size which is now possible unusable.

We could obviously look at using triggers with breakpoints and then use orientation of device/platform to have alternate values or we could look to handle the height as a trigger as well – as you can see, this then starts to get complicated, requiring many visual states or triggers etc. for many device scenarios.

Ofcourse, we could fix the UI to portrait and make our lives simpler, but this will not suit all applications.

So, make sure you test your UI on different sizes devices and in different orientations.

There’s more…

Before we end this rather long post. I mentioned that the AdaptiveTrigger would (in MAUI 7.x) allow us to define XAML that’s triggered by breakpoint changes, there’s also the OrientationStateTrigger used like this

Update: I’ve just confirmed, MAUI 7.x has a working version of AdaptiveTrigger.

<VisualState.StateTriggers>
   <OrientationStateTrigger Orientation="Portrait" />
</VisualState.StateTriggers>

Is that it?

Our original aims were to be able to change things like font size, control sizes and layouts to be truly adaptive. Using DataTemplates with either triggers or behaviours allows us to achieve these goals, but there one last thing to look into/think about.

Not quite the same thing but MAUI controls come with the property FontAutoScalingEnabled set to true by default. Now this basically says, if the device scales the font, then our UI/control should respond accordingly and scale.

This is the option of some devices to change the Settings | Display | Font size. So when, like me, your eyesight’s not great on small devices, you tell the device to scale fonts to a larger size, FontAutoScalingEnabled=”True” means your font will get scaled, False means it will not. This is important to remember if you are already handling things like scaling your font size based upon breakpoints as it could affect your UI look if you max your font via the breakpoint and then find the user scales it further.

Let’s see this in actions. If we change our MainPage ContentPage to just having this XAML

<Grid>
   <Label Text="My AutoScaling Label"
      VerticalOptions="Center" 
      HorizontalOptions="Center"
      FontSize="32"
      FontAutoScalingEnabled="True" />
</Grid>

Now we run this up on an Android Phone emaulator, I’m using the Pixel 3 XL. What you should see is our text nicely display on a single line at the centre (vertically and horizontally) on the emulator. All looks pretty good.

Now go to the Android Settings | Display | Advanced section and select Font Size, you should see some sample text and a control to change the default text size – change this to the largest option, now return to your application UI and I was lucky, the label just fit on the one line, but as you can see, the font size increased and hence this might have an effect on your layout and design if you’ve already maximized your font size for the given screen size.

Hence, if you need to stop this happening, set FontAutoScalingEnabled=”False”

Code

Code is available via my blog-projects github repo..

Change the statusbar using MAUI

The status bar is the top most bar which shows things such as the battery, wifi etc. indicators. Now I got a little way to achieving this but then hit a snag – so shout out to Change Status Bar Color for Android and iOS in .NET MAUI where Gerald Versluis demonstrates the MAUI Cummunity Toolkit behaviour for solving this problem.

First off install the CommunityToolkit.Maui Nuget package 1.3.0 or above.

In MauiProgram.cs add the following to the builder.

.UseMauiCommunityToolkit();

In the MainPage.xaml we can set up our behaviour like this

<!-- You'll need the following -->
xmlns:behaviors="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"

<!-- Then within the ContentPage put the following -->
<ContentPage.Behaviors>
   <behaviors:StatusBarBehavior StatusBarColor="#FF013558" StatusBarStyle="LightContent" />
</ContentPage.Behaviors>

The statusbar colour is whatever you’re setting for you apps. base colour. The status bar style refers to the text/icons. Obviously for a dark theme/colour, such as above, you’ll want to set the style to light content. If you go for a lighter background/theme you’ll tend to set the style to DarkContent.

These fields are ofcourse bindable, so you can change as you wish.

And that’s it!

Wait, before we end this discussion, you’ll notices things don’t quite work for iOS. We do not see the colour changes to the status bar. In this case we need to edit the info.plist (best to use an XML/text editor as the option is not supported in the Visual Studio UI for this) and add

<key>UIViewControllerBasedStatusBarAppearance</key>
<false />

Changing your MAUI application’s title bar colour

A quick post on how to change your MAUI application’s title bar background colour.

Navigate to Resources/Styles/Colors.xaml and change the Primary colour, for example

<Color x:Key="Primary">#FF013558</Color>

You will probably want to also change the Platforms/Android/Resources/values/colours.xml, to change the top status bar background colour on Android

<color name="colorPrimaryDark">#FF013558</color>

Changing a MAUI application splash screen

This is quick post on manipulating your MAUI application’s splash screen.

  • The first thing to change is the splash.svg file in Resources/Splash. It seems to (by default) be 456 * 456 (height and width).
  • Open the .csproj file and change the following line’s Color and/or .svg
    <MauiSplashScreen 
       Include="Resources\Splash\splash.svg" 
       Color="#FF013558" 
       BaseSize="128,128" />