Refactoring a .NET MAUI app to a Declarative UI using C# Markup

Hello C# Markup!

Introduction

A Declarative UI is a modern approach to building an app’s user interface (UI) in code rather than using a separate markup or tooling approach. The .NET MAUI Community Toolkit provides a fluent API called C# Markup, enabling us to create declarative UIs in C# with .NET MAUI.

C# Markup enables the creation of a View using C#, leveraging the fluent API to reduce the code’s verbosity. The views can be used in a traditional MVVM architecture alongside XAML Views.

To demonstrate this, I have refactored only the XAML Views from the previous blog post while keeping the rest of the app the same to allow us to compare the two approaches.

Demo App

John O’Reilly’s People In Space Kotlin Multiplatform demo app inspired the creation of a demo app that displays a list of astronauts and shows a detailed view when tapped. You can find it in this GitHub repo.

I’ve used the SpaceX Rest API to provide the data via the Crew endpoint. The app uses Shell navigation and monitors internet connectivity using the Reactive Extensions to convert from .Net Event handling to an Observable event stream using the Observable.FromEventPattern.

I have taken some of the patterns we successfully used in production and created a demo app using .Net MAUI. The libraries used in the demo include:-

The following libraries all fall under the ReactiveUI umbrella:-

  • ReactiveUI – A Reactive framework built on top of Rx
  • Refit – Retrofit inspired automatic Rest library
  • Akavache – Asynchronous key-value store built on SQLite
  • DynamicData – Reactive collections with Rx

Key Points

  • Slightly improved runtime performance
  • Refactoring works with UI View code
  • Find references works with UI View code
  • Avoid some clunky XAML syntax for static, constants & converters
  • There is no learning requirement for a separate markup technology
  • Hot Reload works but requires some additional code setup
  • We lose the XAML Live Preview & Live Visual Tree tools when using C# Markup
  • The View definition can be broken down into functions to make it easier to understand and reuse
  • XAML may be easier to scan to understand the overall page structure, especially with the Live Preview/Live Visual Tree

Design Overview

The app employs the MVVM design pattern within a Shell. Its main page displays a list of astronauts and allows users to navigate to a detail view when an astronaut is tapped.

A repository uses Akavache to cache the data in an SQLite database, which is loaded when the main view is displayed or a pull-to-refresh is initiated.

We monitor the network connection status and notify the user of any changes in the connection status.

Code Walkthrough

The MainPage remains a ReactiveContentPage as per the previous XAML version of the app:

public class MainPage : ReactiveContentPage
{
    public MainPage(MainPageViewModel mainPageViewModel)
    {
        // Set the binding context
        BindingContext = mainPageViewModel;
        ViewModel = mainPageViewModel;

        // Set up the page title binding
        this.Bind(ViewModel, vm => vm.PageTitle, v => v.Title);
        
        var refreshView = new RefreshView
        {
            Command = ViewModel.LoadCommand,
            CommandParameter = true
        };
        refreshView.SetBinding(RefreshView.IsRefreshingProperty, "IsRefreshing");
        
        var scrollView = new ScrollView();
        var collectionView = new CollectionView
        {
            SelectionMode = SelectionMode.None,
            EmptyView = "Please pull to refresh the view",
            Margin = new Thickness(10)
        };
        collectionView.SetBinding(ItemsView.ItemsSourceProperty, "Crew");
        
        var itemsLayout = new LinearItemsLayout(ItemsLayoutOrientation.Vertical)
        {
            ItemSpacing = 10
        };
        collectionView.ItemsLayout = itemsLayout;
        
        collectionView.ItemTemplate = new DataTemplate(() =>
        {
            var frame = new Frame
            {
                BackgroundColor = Colors.White,
                CornerRadius = 5,
                Margin = new Thickness(5),
                Padding = new Thickness(10),
                HorizontalOptions = LayoutOptions.Fill
            };

            var grid = new Grid
            {
                Padding = new Thickness(0)
            };
            grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(80) });
            grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(50) });
            grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(160) });
            grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });

            var tapGestureRecognizer = new TapGestureRecognizer();
            tapGestureRecognizer.SetBinding(TapGestureRecognizer.CommandProperty, new Binding("BindingContext.NavigateToDetailCommand", source: this));
            tapGestureRecognizer.SetBinding(TapGestureRecognizer.CommandParameterProperty, ".");
            grid.GestureRecognizers.Add(tapGestureRecognizer);

            var image = new Image
            {
                Aspect = Aspect.AspectFill,
                HeightRequest = 150,
                WidthRequest = 150,
                Margin = new Thickness(0)
            };
            image.SetBinding(Image.SourceProperty, "Image");

            var nameLabel = new Label
            {
                FontAttributes = FontAttributes.Bold,
                FontSize = 18,
                VerticalOptions = LayoutOptions.Start,
                Margin = new Thickness(10, 10, 10, 0)
            };
            nameLabel.SetBinding(Label.TextProperty, "Name");

            var agencyLabel = new Label
            {
                FontSize = 16,
                Margin = new Thickness(10, 0, 10, 10)
            };
            agencyLabel.SetBinding(Label.TextProperty, "Agency");

            grid.Children.Add(image);
            grid.Children.Add(nameLabel);
            grid.Children.Add(agencyLabel);

            Grid.SetRow(nameLabel, 0);
            Grid.SetColumn(nameLabel, 1);
            Grid.SetRow(agencyLabel, 1);
            Grid.SetColumn(agencyLabel, 1);

            Grid.SetRow(image, 0);
            Grid.SetColumn(image, 0);
            Grid.SetRowSpan(image, 2);
            
            frame.Content = grid;

            return frame;
        });

        // Add the CollectionView to the ScrollView and then to the RefreshView
        scrollView.Content = collectionView;
        refreshView.Content = scrollView;

        // Set the content of the page
        Content = refreshView;
        
        this.WhenActivated(disposables =>
        {
        });
    }
    
    protected override void OnAppearing()
    {
        ViewModel?.LoadCommand.Execute(false).Subscribe();
        
        base.OnAppearing();
    }
}

The DetailPage is defined like this:

public class DetailPage : ReactiveContentPage
{
    public DetailPage(DetailPageViewModel detailPageViewModel)
    {
        // Set the binding context
        BindingContext = detailPageViewModel;
        ViewModel = detailPageViewModel;

        // Set up the page title binding
        this.Bind(ViewModel, vm => vm.PageTitle, v => v.Title);
        
        var scrollView = new ScrollView();
        
        var grid = new Grid
        {
            Padding = new Thickness(0)
        };
        grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
        grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(70) });
        grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(3000) });
        grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Star });
        
        var image = new Image
        {
            HorizontalOptions = LayoutOptions.Center
        };
        image.SetBinding(Image.SourceProperty, "CrewMember.Image");
        grid.Children.Add(image);
        Grid.SetRow(image, 0);
        Grid.SetColumn(image, 0);
        
        var nameLabel = new Label
        {
            Padding = new Thickness(10),
            FontSize = 24, // FontSize.Large equivalent
            HorizontalOptions = LayoutOptions.Center
        };
        nameLabel.SetBinding(Label.TextProperty, "CrewMember.Name");
        grid.Children.Add(nameLabel);
        Grid.SetRow(nameLabel, 1);
        Grid.SetColumn(nameLabel, 0);
        
        var webView = new WebView
        {
            VerticalOptions = LayoutOptions.Fill
        };
        webView.SetBinding(WebView.SourceProperty, "CrewMember.Wikipedia");
        grid.Children.Add(webView);
        Grid.SetRow(webView, 2);
        Grid.SetColumn(webView, 0);

        // Add the Grid to the ScrollView
        scrollView.Content = grid;

        // Set the content of the page
        Content = scrollView;

        this.WhenActivated(disposables =>
        {
            // Any disposables here
        });
    }
}

We can break the definition of the view up into functions to aid readability and reusability, for example the MainPage:

public class MainPage : ReactiveContentPage
{
    public MainPage(MainPageViewModel mainPageViewModel)
    {
        BindingContext = ViewModel = mainPageViewModel;
        this.Bind(ViewModel, vm => vm.PageTitle, v => v.Title);

        Content = CreateRefreshView();

        this.WhenActivated(disposables =>
        {
            // Any disposables here
        });
    }

    private RefreshView CreateRefreshView()
    {
        var refreshView = new RefreshView
        {
            Command = ViewModel?.LoadCommand,
            CommandParameter = true
        };
        refreshView.SetBinding(RefreshView.IsRefreshingProperty, nameof(ViewModel.IsRefreshing));
        refreshView.Content = CreateScrollView();

        return refreshView;
    }

    private ScrollView CreateScrollView()
    {
        var scrollView = new ScrollView
        {
            Content = CreateCollectionView()
        };
        return scrollView;
    }

    private CollectionView CreateCollectionView()
    {
        var collectionView = new CollectionView
        {
            SelectionMode = SelectionMode.None,
            EmptyView = "Please pull to refresh the view",
            Margin = new Thickness(10),
            ItemsLayout = new LinearItemsLayout(ItemsLayoutOrientation.Vertical) { ItemSpacing = 10 },
            ItemTemplate = CreateItemTemplate()
        };
        collectionView.SetBinding(ItemsView.ItemsSourceProperty, nameof(ViewModel.Crew));

        return collectionView;
    }

    private DataTemplate CreateItemTemplate()
    {
        return new DataTemplate(() =>
        {
            var frame = new Frame
            {
                BackgroundColor = Colors.White,
                CornerRadius = 5,
                Margin = new Thickness(5),
                Padding = new Thickness(10),
                HorizontalOptions = LayoutOptions.Fill
            };

            var grid = CreateGrid();
            frame.Content = grid;

            return frame;
        });
    }

    private Grid CreateGrid()
    {
        var grid = new Grid
        {
            Padding = new Thickness(0)
        };
        grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(80) });
        grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(50) });
        grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(160) });
        grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });

        var tapGestureRecognizer = new TapGestureRecognizer();
        tapGestureRecognizer.SetBinding(TapGestureRecognizer.CommandProperty, new Binding("BindingContext.NavigateToDetailCommand", source: this));
        tapGestureRecognizer.SetBinding(TapGestureRecognizer.CommandParameterProperty, ".");
        grid.GestureRecognizers.Add(tapGestureRecognizer);

        var image = CreateImage();
        var nameLabel = CreateNameLabel();
        var agencyLabel = CreateAgencyLabel();

        grid.Children.Add(image);
        grid.Children.Add(nameLabel);
        grid.Children.Add(agencyLabel);

        Grid.SetRow(nameLabel, 0);
        Grid.SetColumn(nameLabel, 1);
        Grid.SetRow(agencyLabel, 1);
        Grid.SetColumn(agencyLabel, 1);

        Grid.SetRow(image, 0);
        Grid.SetColumn(image, 0);
        Grid.SetRowSpan(image, 2);

        return grid;
    }

    private static Image CreateImage()
    {
        var image = new Image
        {
            Aspect = Aspect.AspectFill,
            HeightRequest = 150,
            WidthRequest = 150,
            Margin = new Thickness(0)
        };
        image.SetBinding(Image.SourceProperty, "Image");
        return image;
    }

    private static Label CreateNameLabel()
    {
        var nameLabel = new Label
        {
            FontAttributes = FontAttributes.Bold,
            FontSize = 18,
            VerticalOptions = LayoutOptions.Start,
            Margin = new Thickness(10, 10, 10, 0)
        };
        nameLabel.SetBinding(Label.TextProperty, "Name");
        return nameLabel;
    }

    private static Label CreateAgencyLabel()
    {
        var agencyLabel = new Label
        {
            FontSize = 16,
            Margin = new Thickness(10, 0, 10, 10)
        };
        agencyLabel.SetBinding(Label.TextProperty, "Agency");
        return agencyLabel;
    }

    protected override void OnAppearing()
    {
        ViewModel?.LoadCommand.Execute(false).Subscribe();
        base.OnAppearing();
    }
}

And the DetailPage definition broken down into functions could look like this:

public class DetailPage : ReactiveContentPage
{
    public DetailPage(DetailPageViewModel detailPageViewModel)
    {
        // Set the binding context
        BindingContext = ViewModel = detailPageViewModel;

        // Set up the page title binding
        this.Bind(ViewModel, vm => vm.PageTitle, v => v.Title);

        // Create the main layout
        var scrollView = CreateScrollView();
        
        // Set the content of the page
        Content = scrollView;

        this.WhenActivated(disposables =>
        {
            // Any disposables here
        });
    }

    private static ScrollView CreateScrollView()
    {
        var grid = CreateGrid();
        var scrollView = new ScrollView { Content = grid };
        return scrollView;
    }

    private static Grid CreateGrid()
    {
        var grid = new Grid
        {
            Padding = new Thickness(0)
        };
        grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
        grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(70) });
        grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(3000) });
        grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Star });

        var image = CreateImage();
        grid.Children.Add(image);
        Grid.SetRow(image, 0);
        Grid.SetColumn(image, 0);

        var nameLabel = CreateNameLabel();
        grid.Children.Add(nameLabel);
        Grid.SetRow(nameLabel, 1);
        Grid.SetColumn(nameLabel, 0);

        var webView = CreateWebView();
        grid.Children.Add(webView);
        Grid.SetRow(webView, 2);
        Grid.SetColumn(webView, 0);

        return grid;
    }

    private static Image CreateImage()
    {
        var image = new Image
        {
            HorizontalOptions = LayoutOptions.Center
        };
        image.SetBinding(Image.SourceProperty, "CrewMember.Image");
        return image;
    }

    private static Label CreateNameLabel()
    {
        var nameLabel = new Label
        {
            Padding = new Thickness(10),
            FontSize = 24, // FontSize.Large equivalent
            HorizontalOptions = LayoutOptions.Center
        };
        nameLabel.SetBinding(Label.TextProperty, "CrewMember.Name");
        return nameLabel;
    }

    private static WebView CreateWebView()
    {
        var webView = new WebView
        {
            VerticalOptions = LayoutOptions.Fill
        };
        webView.SetBinding(WebView.SourceProperty, "CrewMember.Wikipedia");
        return webView;
    }
}

Hot Reload

While Hot Reload works, it may not update the UI immediately as .NET MAUI is not aware of the changes made to the Intermediate Language. This topic has been covered by David Ortinau in his blog post C# UI and .NET Hot Reload – A Match Made in .NET MAUI and by James Montemagno in his YouTube video Enable Hot Reload for .NET MAUI C# UI and Markup | Super Productivity Boost. 

I’ve taken the example code and adapted it slightly. As part of this refactoring I have removed the epj.RouteGenerator plugin since this can cause an error with hot reload as it triggers a code regeneration.

The HotReloadService is defined as per the example code with the correct namespace:

#if DEBUG
using CSharpMarkupPeopleInSpaceMaui.HotReload;

[assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(HotReloadService))]
namespace CSharpMarkupPeopleInSpaceMaui.HotReload
{
    public static class HotReloadService
    {
#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
        public static event Action<Type[]?>? UpdateApplicationEvent;
#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

        internal static void ClearCache(Type[]? types) { }
        internal static void UpdateApplication(Type[]? types)
        {
            UpdateApplicationEvent?.Invoke(types);
        }
    }
}
#endif

I’ve created a HotReloadHelper so that we don’t have to add too much extra code to the views, favouring composition over inheritance so we don’t lose sight of the ReactiveContentPage. I’ve used the Observable.FromEventPattern for the page navigation events:

public class HotReloadHelper : IDisposable
    {
        private readonly Page _page;
        private readonly Action _build;
        private readonly IDisposable _navigatedToSubscription;
        private readonly IDisposable _navigatedFromSubscription;

        public HotReloadHelper(Page page, Action build)
        {
            _page = page;
            _build = build;

            var navigatedTo = Observable.FromEventPattern<NavigatedToEventArgs>(
                handler => _page.NavigatedTo += handler,
                handler => _page.NavigatedTo -= handler
            );

            var navigatedFrom = Observable.FromEventPattern<NavigatedFromEventArgs>(
                handler => _page.NavigatedFrom += handler,
                handler => _page.NavigatedFrom -= handler
            );

            _navigatedToSubscription = navigatedTo.Subscribe(_ => OnNavigatedTo());
            _navigatedFromSubscription = navigatedFrom.Subscribe(_ => OnNavigatedFrom());
        }

        private void OnNavigatedTo()
        {
            _build();

#if DEBUG
            HotReloadService.UpdateApplicationEvent += ReloadUI;
#endif
        }

        private void OnNavigatedFrom()
        {
#if DEBUG
            HotReloadService.UpdateApplicationEvent -= ReloadUI;
#endif
        }

        private void ReloadUI(Type[]? obj)
        {
            _page.Dispatcher.Dispatch(() =>
            {
                _build();
            });
        }

        public void Dispose()
        {
            _navigatedToSubscription.Dispose();
            _navigatedFromSubscription.Dispose();
        }
    }
}

The MainPage constructor is changed to use the HotReloadHelper and create the view  using a Build method:

   public MainPage(MainPageViewModel mainPageViewModel)
    {
        BindingContext = ViewModel = mainPageViewModel;
        this.Bind(ViewModel, vm => vm.PageTitle, v => v.Title);

        var hotReloadHelper = new HotReloadHelper(this, Build);
        
        this.WhenActivated(disposables =>
        {
            // Any disposables here
            Disposable.Create(() => hotReloadHelper.Dispose()).DisposeWith(disposables);

        });
    }

    void Build() => Content =
        CreateRefreshView();

And the DetailPage constructor is similarly changed to use the HotReloadHelper and create the view  using a Build method:

 public DetailPage(DetailPageViewModel detailPageViewModel)
    {
        // Set the binding context
        BindingContext = ViewModel = detailPageViewModel;

        // Set up the page title binding
        this.Bind(ViewModel, vm => vm.PageTitle, v => v.Title);

        var hotReloadHelper = new HotReloadHelper(this, Build);
        
        this.WhenActivated(disposables =>
        {
            // Any disposables here
            Disposable.Create(() => hotReloadHelper.Dispose()).DisposeWith(disposables);
        });
    }

    void Build() => Content =
        CreateScrollView();

Summary

Combining a declarative UI approach using C# Markup with functional and reactive programming styles demonstrates a modern approach to building a .Net MAUI app.

Having recently worked with native Android and Kotlin Multiplatform (KMP) apps, I’m used to using a functional, reactive programming style with a declarative UI, so I thought it would be nice to investigate how best we can replicate this using .Net MAUI.

Most .Net MAUI apps probably use XAML Views and MVVM with the Community MVVM Toolkit or Prism Library. Hopefully, these blog posts will show how we can complement them with a functional, reactive, or declarative UI approach while leveraging our existing knowledge of the MVVM pattern.

Some people will naturally prefer to stay with XAML, which they are familiar with. It offers more tooling with the Live Preview and Live Visual Tree. While I’m happy to work with XAML, the syntax can sometimes be cumbersome, and writing converters is additional overhead. It’s great that there are different options available to us!

If you are interested in discussing how we could help with .Net, Kotlin or KMP development, you can reach me, Richard Woollcott, at [email protected]