What does a production MAUI app look like?

In the past, I’ve played with MAUI in toy projects but recently I decided to try and upgrade a Xamarin Forms app to MAUI.
That went poorly with the Upgrade Assistant so I started with a fresh project and migrated across. Here’s a few tips and tricks and common patterns for a MAUI app that I found useful.

EDIT: I’ve been informed that there’s a thing called Community MVVM Toolkit that takes care of a lot of things regarding observability. Probably use that instead of all the INotifyPropertyChanged etc here.

I’m not going to claim these are the only methods of getting good outcomes, or that I’ve even measured the outcomes…
But I’ve applied some common design patterns to this app and gotten a result I like.

Let’s have a closer look.

Contents

Goals #

At the end of this, I want a project that has:

Result #

AV1 support in Edge requires a Microsoft Store Extension.

Setup #

With all that out of the way, let’s start with the guts of any production application: Dependency injection.

Dependency Injection #

Dependency injection? Isn’t that a bit overkill for a mobile app?
I’d argue “no” considering it’s not all that heavy and it’s a familiar pattern for dotnet developers, which is one of the main goals.
Another major reason I want to use it here is that it makes working with EFCore easier.

The setup and usage is essentially the same as in aspnetcore:

MauiProgram.cs

Expand/Collapse cs

public static MauiApp CreateMauiApp()
{
  var builder = MauiApp.CreateBuilder();
  builder
  .UseMauiApp<App>()
  //...

  builder.Services.AddTransient<MyService>();
  //...

  return builder.Build();
}
public static MauiApp CreateMauiApp()
{
  var builder = MauiApp.CreateBuilder();
  builder
  .UseMauiApp<App>()
  //...

  builder.Services.AddTransient<MyService>();
  //...

  return builder.Build();
}

ServiceCollectionExtensions.cs

public static IServiceCollection RegisterViewModels(this IServiceCollection services)
{
  services.AddTransient<MainPageViewModel>();
  services.AddTransient<ContentPopupViewModel>();
  return services;
}

Note that I have to register my views and view models so they can take advantage of DI.
I’ve created extension methods to register them to keep MauiProgram.cs clean, and logically group things.

Consuming DI is done via constructor injection or IServiceProvider as normal, however there is a current bug where the very first view can’t have constructor injection for it’s view model.
As such, the first view (usually App.cs) has to resolve it with IServiceProvider, but the rest can be as normal.

App.cs

public App(IServiceProvider services, IConfigurationService config)
{
	InitializeComponent();
	MainPage = services.GetRequiredService<MyMainPage>();  // Can't inject the view model, have to resolve it here.
	this.config = config;
}

MainPage.cs

public MainPage(MainPageViewModel viewModel)
{
  InitializeComponent();
  BindingContext = viewModel;
}

One thing I ran into almost immediately with this setup was that view models tended to have async setup, fetching data from databases or networks and such.
So how can we handle those situations?

View model configuration #

There’s a couple of ways I want to configure my view models.
The main one is the one I alluded to above: Long running initialisation setup.

Async setup #

Views have a lifecycle event for when it appears to the user: OnAppearing which can be async.
We can use that to trigger any async setup for the view model and/or any other setup for the view we need that can’t be done as part of the constructor.

protected override async void OnAppearing()
{
  await (BindingContext as MainPageViewModel)?.Initialise();
  base.OnAppearing();
}

There’s also an OnDisappearing lifecycle event for cleanup if required, but I haven’t needed it yet.

Another scenario I quickly discovered was having to pass information from one view to another.

Passing info into view models #

The only time I needed to do this was when using popups and I wanted to pass text or info into it.
I liked the way that aspnetcore and other libraries handle their configuration.
Nick Chapsas talks about this in one of his videos, and it looks like:

private void ShowInfoPopup()
{
  popupService.ShowPopup<ContentPopup, ContentPopupViewModel>(model =>
  {
    // Configure the model here
    model.Title = "Info about this app";
    model.Content = "This is an attempt to show how MAUI can be used more easily in a production scenario. That is, I'm using processes like those described in this app in a production app.";
  });
}

It’s a bit of an involved process to get that for the view models:

Sync Version

public void ShowPopup<T, V>(Action<V> viewModelSetup = null) where T : Popup where V : ViewModelBase
{
  var mainPage = App.Current?.MainPage ?? throw new MissingMethodException("Main page is null");
  var popup = services.GetRequiredService<T>();
  var viewModel = services.GetRequiredService<V>();
  viewModelSetup?.Invoke(viewModel);
  popup.BindingContext = viewModel;
  mainPage.ShowPopup(popup);
  popups.Push(popup);
}

Async Version

public async Task ShowPopup<T, V>(Func<V, Task> viewModelSetup = null) where T : Popup where V : ViewModelBase
{
  var mainPage = App.Current?.MainPage ?? throw new MissingMethodException("Main page is null");
  var popup = services.GetRequiredService<T>();
  var viewModel = services.GetRequiredService<V>();
  await viewModelSetup?.Invoke(viewModel);
  popup.BindingContext = viewModel;
  mainPage.ShowPopup(popup);
  popups.Push(popup);
}

Popups #

Popups are a common requirement in mobile apps.
I’m using the popups from the Maui Community Toolkit, but I don’t think there’s a template for it so my method is:

After I did this, I saw that there’s a ModalStack on the Navigation property of views. I’m not sure if I could have used that instead…

Common settings #

Using the Popup #

I’ve already described most of it above, but here’s a refresher on how I’m opening a popup.

Expand/Collapse cs

// No view model configuration
popupService.ShowPopup<MyPopup>();

// With view model config
popupService.ShoPopup<MyPopup, MyPopupViewModel>(model => {
	model.MyValue = "some value";
	model.SubmitAction = new Command(() => 
	{
		popupService.HidePopup();  // Hides this popup because it's the newest
		DoSomeSubmitAction();
	});
});
// No view model configuration
popupService.ShowPopup<MyPopup>();

// With view model config
popupService.ShoPopup<MyPopup, MyPopupViewModel>(model => {
	model.MyValue = "some value";
	model.SubmitAction = new Command(() => 
	{
		popupService.HidePopup();  // Hides this popup because it's the newest
		DoSomeSubmitAction();
	});
});

Closing a popup is done by calling .Close() on the popup itself, but we want to be able to close the popup from any view model:
popupService.HidePopup() // close the current top popup

My PopupService has a stack: private Stack<Popup> popups = new(); and the above function HidePopup looks like this:

public void HidePopup()
{
  popups.Pop().Close();
}

EFCore for persistence #

Most apps want to save data somewhere, and we could do that on the server, fetching and sending data as required, but there’s usually a requirement to store things locally for some purpose.
I’m using EFCore because I’m familiar with it, as are many of my colleagues are as well, and thus fits the “familiar feeling” requirement.

Let’s use Sqlite here as it’s “lite” and the one used in the Microsoft samples 😀

// MauiProgram.cs
var migrationAssembly = typeof(Repo).Assembly.GetName().Name;
builder.Services.AddDbContext<IRepo, Repo>(opts =>
{
	var dbPath = Path.Combine(FileSystem.AppDataDirectory, "database.db3");
	opts.UseSqlite($"Filename={dbPath}", x=> x.MigrationsAssembly(migrationsAssembly));
});

But wait, what’s that .MigrationsAssembly thing?

“External” Migrations #

The dotnet ef migrations CLI doesn’t support the MAUI targets, so we need to use a separate project.
The docs have some details, but I found it a bit all over the place, so here’s a flow chart showing the resulting projects and links.

DBEntryProjectDBMAUIProject

The process is:

Console.WriteLine("This is the efcore CLI migrations entrypoint. Use 'dotnet ef migrations add <migration name> --project MyProject/MyProject.csproj --startup-project DB/DB.csproj' to add a new migration");

public class DesignRepo : IDesignTimeDbContextFactory<Repo>
{
  public Repo CreateDbContext(string[] args)
  {
    return new Repo(new DbContextOptionsBuilder<Repo>().UseSqlite("doesnt matter").Options);
  }
}

I feel like I could put the DB classes in the same project as the entry project, but I haven’t tested that. NO you can’t do this, at least in my setup. Attempting it gives something like “‘project’ is a non-selfcontained executable and cannot be referenced from a self contained executable”

EFCore needs a connection string, which we usually get from appsettings.json.
We can do that!

Configuration through appsettings.json #

While this is similar to aspnetcore, there’s a bit of extra setup here and requires some nuget packages:

Expand/Collapse cs

// I've set the appsettings.json as an EmbeddedResource
// So we need to get it's path with assembly
var ass = Assembly.GetExecutingAssembly();
var debugSettings = ass.GetManifestResourceStream("MAUIExampleWithEFCore.appsettings.json");

// Add appsettings.json file
var configRoot = new ConfigurationBuilder()
    .AddJsonStream(debugSettings)
    .Build();

// Bind to Config class
var config = new Config();
configRoot.Bind(config);

services.AddSingleton<Config>(config);
// I've set the appsettings.json as an EmbeddedResource
// So we need to get it's path with assembly
var ass = Assembly.GetExecutingAssembly();
var debugSettings = ass.GetManifestResourceStream("MAUIExampleWithEFCore.appsettings.json");

// Add appsettings.json file
var configRoot = new ConfigurationBuilder()
    .AddJsonStream(debugSettings)
    .Build();

// Bind to Config class
var config = new Config();
configRoot.Bind(config);

services.AddSingleton<Config>(config);

Custom views/controls #

It wasn’t long before I wanted to encapsulate some view components for reuse.
This is especially true for xaml compared to html as it feels more verbose.
For some reason, this took me a bit of time to figure out, but it’s actually pretty simple.

Nicer Text Input #

Here’s a nicer text input control with some nicer placeholder behaviour.

Before

<video class="inner quartersize-frame media-frame lazy" autoplay muted loop playsinline style="width: 25rem; max-width: 100%;">
    <source data-src='https://www.kaels-kabbage.com/posts/production-maui-app/img/NormalEntry_quartersize_AV1.mp4' type="video/mp4; codecs=av01.0.05M.08">
    <source data-src='https://www.kaels-kabbage.com/posts/production-maui-app/img/NormalEntry_quartersize_VP9.webm' type="video/webm; codecs=vp9">
    <source data-src='https://www.kaels-kabbage.com/posts/production-maui-app/img/NormalEntry.mp4' type="video/mp4">
</video>

<video class="inner halfsize-frame media-frame lazy" autoplay muted loop playsinline style="width: 25rem; max-width: 100%;"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NormalEntry_halfsize_AV1.mp4' type="video/mp4; codecs=av01.0.05M.08"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NormalEntry_halfsize_VP9.webm' type="video/webm; codecs=vp9"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NormalEntry.mp4' type="video/mp4"> </video>

<video class="inner fullsize-frame media-frame lazy" autoplay muted loop playsinline style="width: 25rem; max-width: 100%;"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NormalEntry_AV1.mp4' type="video/mp4; codecs=av01.0.05M.08"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NormalEntry_VP9.webm' type="video/webm; codecs=vp9"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NormalEntry.mp4' type="video/mp4"> </video>

<span id="edge-av1-message">AV1 support in Edge requires a <a href="https://apps.microsoft.com/store/detail/av1-video-extension/9MVZQVXJBQ9V?hl=en-us&gl=US">Microsoft Store Extension</a>.</span>

<div class="hq-links">

&lt;div class=&quot;alt-text&quot;&gt;Default Entry behaviour&lt;/div&gt;

</div>

After

<video class="inner quartersize-frame media-frame lazy" autoplay muted loop playsinline style="width: 25rem; max-width: 100%;">
    <source data-src='https://www.kaels-kabbage.com/posts/production-maui-app/img/NiceEntry_quartersize_AV1.mp4' type="video/mp4; codecs=av01.0.05M.08">
    <source data-src='https://www.kaels-kabbage.com/posts/production-maui-app/img/NiceEntry_quartersize_VP9.webm' type="video/webm; codecs=vp9">
    <source data-src='https://www.kaels-kabbage.com/posts/production-maui-app/img/NiceEntry.mp4' type="video/mp4">
</video>

<video class="inner halfsize-frame media-frame lazy" autoplay muted loop playsinline style="width: 25rem; max-width: 100%;"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NiceEntry_halfsize_AV1.mp4' type="video/mp4; codecs=av01.0.05M.08"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NiceEntry_halfsize_VP9.webm' type="video/webm; codecs=vp9"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NiceEntry.mp4' type="video/mp4"> </video>

<video class="inner fullsize-frame media-frame lazy" autoplay muted loop playsinline style="width: 25rem; max-width: 100%;"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NiceEntry_AV1.mp4' type="video/mp4; codecs=av01.0.05M.08"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NiceEntry_VP9.webm' type="video/webm; codecs=vp9"> <source data-src=‘ https://www.kaels-kabbage.com/posts/production-maui-app/img/NiceEntry.mp4' type="video/mp4"> </video>

<span id="edge-av1-message">AV1 support in Edge requires a <a href="https://apps.microsoft.com/store/detail/av1-video-extension/9MVZQVXJBQ9V?hl=en-us&gl=US">Microsoft Store Extension</a>.</span>

<div class="hq-links">

&lt;div class=&quot;alt-text&quot;&gt;Nicer entry behaviour, similar to Material design&lt;/div&gt;

</div>

Used thusly:

<!-- With this namespace added -->
<!-- xmlns:controls="clr-namespace:MAUIExampleWithEFCore.Views.Controls" -->

<controls:NiceEntry Text="{Binding Name}" Placeholder="Name?" WidthRequest="100" LabelColor="White" TextColor="White"/>

It’s quite a bit of backend code to make it work though, primarily in all the bindable properties I want to expose.

XAML

Expand/Collapse xml

<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
            xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
            x:Class="MAUIExampleWithEFCore.Views.Controls.NiceEntry"
            x:Name="niceEntry">
  <Grid BackgroundColor="Transparent" HeightRequest="50">
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
      <RowDefinition Height="50"/>
    </Grid.RowDefinitions>

    <Entry Grid.Row="0" Grid.Column="0"
      Margin="0,0,0,0"
      Text="{Binding Source={x:Reference niceEntry}, Path=Text}" 
      ReturnType="{Binding Source={x:Reference niceEntry}, Path=ReturnType}"
      FontSize="{Binding Source={x:Reference niceEntry}, Path=FontSize}"
      BackgroundColor="{Binding Source={x:Reference niceEntry}, Path=BackgroundColor}"
      TextColor="{Binding Source={x:Reference niceEntry}, Path=TextColor}"
      Focused="Entry_FocusChanged"
      Unfocused="Entry_FocusChanged"
      Keyboard="{Binding Source={x:Reference niceEntry}, Path=Keyboard}"
      ReturnCommand="{Binding Source={x:Reference niceEntry}, Path=ReturnCommand}"
      IsPassword="{Binding Source={x:Reference niceEntry}, Path=IsPassword}"
    />

    <Label Grid.Row="0" Grid.Column="0"
      BackgroundColor="Transparent"
      Margin="0,10,0,0"
      AnchorX="0"
      AnchorY="0"
      TextColor="{Binding Source={x:Reference niceEntry}, Path=LabelColor}"
      FontSize="{Binding Source={x:Reference niceEntry}, Path=FontSize}"
      x:Name="thelabel"
      Text="{Binding Source={x:Reference niceEntry}, Path=Placeholder}"
      />
  </Grid>
</ContentView>
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
            xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
            x:Class="MAUIExampleWithEFCore.Views.Controls.NiceEntry"
            x:Name="niceEntry">
  <Grid BackgroundColor="Transparent" HeightRequest="50">
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
      <RowDefinition Height="50"/>
    </Grid.RowDefinitions>

    <Entry Grid.Row="0" Grid.Column="0"
      Margin="0,0,0,0"
      Text="{Binding Source={x:Reference niceEntry}, Path=Text}" 
      ReturnType="{Binding Source={x:Reference niceEntry}, Path=ReturnType}"
      FontSize="{Binding Source={x:Reference niceEntry}, Path=FontSize}"
      BackgroundColor="{Binding Source={x:Reference niceEntry}, Path=BackgroundColor}"
      TextColor="{Binding Source={x:Reference niceEntry}, Path=TextColor}"
      Focused="Entry_FocusChanged"
      Unfocused="Entry_FocusChanged"
      Keyboard="{Binding Source={x:Reference niceEntry}, Path=Keyboard}"
      ReturnCommand="{Binding Source={x:Reference niceEntry}, Path=ReturnCommand}"
      IsPassword="{Binding Source={x:Reference niceEntry}, Path=IsPassword}"
    />

    <Label Grid.Row="0" Grid.Column="0"
      BackgroundColor="Transparent"
      Margin="0,10,0,0"
      AnchorX="0"
      AnchorY="0"
      TextColor="{Binding Source={x:Reference niceEntry}, Path=LabelColor}"
      FontSize="{Binding Source={x:Reference niceEntry}, Path=FontSize}"
      x:Name="thelabel"
      Text="{Binding Source={x:Reference niceEntry}, Path=Placeholder}"
      />
  </Grid>
</ContentView>

Code behind

Expand/Collapse cs

public partial class NiceEntry : ContentView
{
    public NiceEntry()
    {
        InitializeComponent();
    }

    public static BindableProperty IsPasswordProperty = BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(NiceEntry), false);

    public static BindableProperty PlaceholderProperty = BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(NiceEntry), null);

    public static BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(NiceEntry), null, BindingMode.TwoWay);

    public static BindableProperty ReturnTypeProperty = BindableProperty.Create(nameof(ReturnType), typeof(ReturnType), typeof(NiceEntry), ReturnType.Default);

    public static BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(NiceEntry), null);
    public static BindableProperty LabelColorProperty = BindableProperty.Create(nameof(LabelColor), typeof(Color), typeof(NiceEntry), null);

    public static BindableProperty KeyboardProperty = BindableProperty.Create(nameof(Keyboard), typeof(Keyboard), typeof(NiceEntry), Keyboard.Default);

    public static BindableProperty FontSizeProperty = BindableProperty.Create(nameof(FontSize), typeof(string), typeof(NiceEntry), "24");

    public static BindableProperty ReturnCommandProperty = BindableProperty.Create(nameof(ReturnCommand), typeof(Command), typeof(NiceEntry), null);

    public Color TextColor
    {
        get => (Color)GetValue(TextColorProperty);
        set => SetValue(TextColorProperty, value);
    }

    public Color LabelColor
    {
        get => (Color)GetValue(LabelColorProperty);
        set => SetValue(LabelColorProperty, value);
    }

    public string FontSize
    {
        get => (string)GetValue(FontSizeProperty);
        set => SetValue(FontSizeProperty, value);
    }

    public Keyboard Keyboard
    {
        get => (Keyboard)GetValue(KeyboardProperty);
        set => SetValue(KeyboardProperty, value);
    }

    public bool IsPassword
    {
        get => (bool)GetValue(IsPasswordProperty);
        set => SetValue(IsPasswordProperty, value);
    }

    public string Placeholder
    {
        get => (string)GetValue(PlaceholderProperty);
        set => SetValue(PlaceholderProperty, value);
    }

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set
        {
            SetValue(TextProperty, value);
            var noText = string.IsNullOrEmpty(value);

            if (!noText)
            {
                MakeLabelSmall();
            }

            if (noText)
            {
                RestoreLabel();
            }
        }
    }

    public ReturnType ReturnType
    {
        get => (ReturnType)GetValue(ReturnTypeProperty);
        set => SetValue(ReturnTypeProperty, value);
    }

    public Command ReturnCommand
    {
        get => (Command)GetValue(ReturnCommandProperty);
        set => SetValue(ReturnCommandProperty, value);
    }

    private void MakeLabelSmall()
    {
        thelabel.ScaleTo(0.7, easing: Easing.CubicInOut);
        thelabel.TranslateTo(0, -20, easing: Easing.CubicInOut);
    }
    private void RestoreLabel()
    {
        thelabel.ScaleTo(1, easing: Easing.CubicInOut);
        thelabel.TranslateTo(0, 0, easing: Easing.CubicInOut);
    }

    private void Entry_FocusChanged(object sender, FocusEventArgs e)
    {
        if (e.IsFocused)
        {
            MakeLabelSmall();
        }

        if (string.IsNullOrEmpty(Text))
        {
            RestoreLabel();
        }
    }
}
public partial class NiceEntry : ContentView
{
    public NiceEntry()
    {
        InitializeComponent();
    }

    public static BindableProperty IsPasswordProperty = BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(NiceEntry), false);

    public static BindableProperty PlaceholderProperty = BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(NiceEntry), null);

    public static BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(NiceEntry), null, BindingMode.TwoWay);

    public static BindableProperty ReturnTypeProperty = BindableProperty.Create(nameof(ReturnType), typeof(ReturnType), typeof(NiceEntry), ReturnType.Default);

    public static BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(NiceEntry), null);
    public static BindableProperty LabelColorProperty = BindableProperty.Create(nameof(LabelColor), typeof(Color), typeof(NiceEntry), null);

    public static BindableProperty KeyboardProperty = BindableProperty.Create(nameof(Keyboard), typeof(Keyboard), typeof(NiceEntry), Keyboard.Default);

    public static BindableProperty FontSizeProperty = BindableProperty.Create(nameof(FontSize), typeof(string), typeof(NiceEntry), "24");

    public static BindableProperty ReturnCommandProperty = BindableProperty.Create(nameof(ReturnCommand), typeof(Command), typeof(NiceEntry), null);

    public Color TextColor
    {
        get => (Color)GetValue(TextColorProperty);
        set => SetValue(TextColorProperty, value);
    }

    public Color LabelColor
    {
        get => (Color)GetValue(LabelColorProperty);
        set => SetValue(LabelColorProperty, value);
    }

    public string FontSize
    {
        get => (string)GetValue(FontSizeProperty);
        set => SetValue(FontSizeProperty, value);
    }

    public Keyboard Keyboard
    {
        get => (Keyboard)GetValue(KeyboardProperty);
        set => SetValue(KeyboardProperty, value);
    }

    public bool IsPassword
    {
        get => (bool)GetValue(IsPasswordProperty);
        set => SetValue(IsPasswordProperty, value);
    }

    public string Placeholder
    {
        get => (string)GetValue(PlaceholderProperty);
        set => SetValue(PlaceholderProperty, value);
    }

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set
        {
            SetValue(TextProperty, value);
            var noText = string.IsNullOrEmpty(value);

            if (!noText)
            {
                MakeLabelSmall();
            }

            if (noText)
            {
                RestoreLabel();
            }
        }
    }

    public ReturnType ReturnType
    {
        get => (ReturnType)GetValue(ReturnTypeProperty);
        set => SetValue(ReturnTypeProperty, value);
    }

    public Command ReturnCommand
    {
        get => (Command)GetValue(ReturnCommandProperty);
        set => SetValue(ReturnCommandProperty, value);
    }

    private void MakeLabelSmall()
    {
        thelabel.ScaleTo(0.7, easing: Easing.CubicInOut);
        thelabel.TranslateTo(0, -20, easing: Easing.CubicInOut);
    }
    private void RestoreLabel()
    {
        thelabel.ScaleTo(1, easing: Easing.CubicInOut);
        thelabel.TranslateTo(0, 0, easing: Easing.CubicInOut);
    }

    private void Entry_FocusChanged(object sender, FocusEventArgs e)
    {
        if (e.IsFocused)
        {
            MakeLabelSmall();
        }

        if (string.IsNullOrEmpty(Text))
        {
            RestoreLabel();
        }
    }
}

BindableProperty creates a property that is exposed to the xaml view and configures it’s behaviour (default values, binding directions, etc)
This allows other bindings/values to be passed in from the parent.

An important difference between these custom controls and normal views is the binding context.
Normal views bind to themselves (or the viewmodels, usually set somewhere), but for custom controls, the binding context is actually wherever it’s used.
As such, when we bind to our properties so we can display them, we need to tell the xaml that they’re in the control, not in the parent.
We do that by setting {Binding Source={x:Reference controlName}, Path=Something} for the bindings.

Can we set the binding context in the constructor? Like BindingContext = this;?
Not sure, but this way works nicely.

Styles and resource dictionaries #

Stying isn’t done with CSS (good/bad?) but we do get some nice intellisense through Visual Studio.
There are a bunch of ways to handle this, but the ways I generally found worked nicely were:

An example style is below and sets some various properties:

<Style TargetType="Frame" x:Key="myFrame">
  <Setter Property="HasShadow" Value="False" />
  <Setter Property="BorderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />  <!--> This is some custom default stuff, not sure how it works <-->
  <Setter Property="CornerRadius" Value="8" />
</Style>

It has the x:Key attribute which means it’ll only be applied if directly referenced like:

<Frame Style={StaticResource myFrame} ... />

If x:Key is omitted, the style applies to all matching types “below” where the style is referenced in the tree. i.e. If it’s in the App.xaml resource dictionary, it’s essentially a global style, but if it’s defined in a StackLayout.Resources block, it only applies to the resources within that StackLayout.

Some smaller tidbits #

Animations #

Animations seem to be only defined in code behind, no xaml.

someElement.ScaleTo(2);  // Translate, rotate, etc

Custom fonts #

Add fonts to Resources folder, then adjust MauiProgram.cs:

Expand/Collapse cs

var builder = MauiApp.CreateBuilder();
builder
  .UseMauiApp<App>()
  .UseMauiCommunityToolkit()
  .ConfigureFonts(fonts =>
  {
    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");

    // Add more fonts to the Fonts folder, set as MAUI font, and add here as above
    fonts.AddFont("Roboto-Regular.ttf", "Roboto-Regular");
  });
  //...
var builder = MauiApp.CreateBuilder();
builder
  .UseMauiApp<App>()
  .UseMauiCommunityToolkit()
  .ConfigureFonts(fonts =>
  {
    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");

    // Add more fonts to the Fonts folder, set as MAUI font, and add here as above
    fonts.AddFont("Roboto-Regular.ttf", "Roboto-Regular");
  });
  //...

Frame vs Border #

Xamarin Forms had a Frame component which allowed adding a border, corner radius, shadow, etc to views.
Frame is deprecated in MAUI, and it’s supposed to still work, but I get exceptions like “No parameterless constructor for FrameRenderer”.
Instead, we can use Border:

<Border 
  BackgroundColor="{StaticResource ThemeWhite}"
  WidthRequest="560"
  VerticalOptions="Center"
  HorizontalOptions="Center"
  Padding="0">
  <Border.StrokeShape>
    <RoundRectangle CornerRadius="8"/>
  </Border.StrokeShape>
  <Grid...

Even smaller bits and recommendations #

var timer = Dispatcher.GetForCurrentThread().CreateTimer();
timer.Interval = TimeSpan.FromSeconds(2);
timer.Tick += async (_,_) => await DoSomething();
timer.Start();
MainThread.InvokeOnMainThreadAsync(async() => await DoSomething());

You can see the full solution on Github for more context and information.