Showing posts with label .NET 10. Show all posts
Showing posts with label .NET 10. Show all posts

Sunday, 12 July 2026

Android and Google Maps using .NET Maui

MAUI Maps Demo Notes πŸ—Ί️

A short summary of the map demo, measurement mode, and pin icon adjustments.
The source code can be cloned from this Github repo of mine : πŸ‘‰ MauiMapAppDemo Github repo πŸ’Ύ

This demo shows a .NET MAUI map page with two main ideas: regular cabin pins and a measure mode that lets the user tap two points to draw a red line and calculate distance. The page is driven by a view model, while a map behavior keeps the map logic reusable and keeps the XAML clean. In use is also CommunityToolkit.Mvvm to make MVVM easier to implement. The use of Behaviors to implement view specific behavior / functionality is a common pattern when working with client based logic and MVVM and UI controls.

πŸ”’ The app reads Google Maps and Azure Maps keys from user secrets during local development. The keys are not exposed in the solution or this article.

What the solution does

  • Loads cabin pin data from the view model and inits the location around Trondheim in Norway.
  • Supports a measure mode that tracks start and end taps (by clicking a button to activate this)
  • Draws a polyline (line segment) between the two points when both locations are set.
  • Uses custom measurement marker icons so the start and end pins are easier to spot.
  • Keeps the layout polished with spacing around the logo, button, and map.

Behavior

The behavior listens for map clicks, manages the temporary measure pins, and clears the graphics when measure mode is turned off. It also wires the pin click command for normal cabin markers.

using MauiMapAppDemo.ViewModels;
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Maps;
using System.Windows.Input;

namespace MauiMapAppDemo.Behaviors
{
    public class MapPinsBehavior : Behavior<Microsoft.Maui.Controls.Maps.Map>
    {
        public static readonly BindableProperty IsMeasuringModeProperty =
            BindableProperty.Create(
                nameof(IsMeasuringMode),
                typeof(bool),
                typeof(MapPinsBehavior),
                false,
                propertyChanged: OnMeasurementStateChanged);

        private void RefreshMeasurementLine()
        {
            if (_map == null)
            {
                return;
            }

            ClearMeasurementGraphics();

            if (MeasureStart == null || MeasureEnd == null)
            {
                return;
            }

            _startPin = new MeasurementPin
            {
                Label = "Start",
                Address = "Measurement Start",
                Location = MeasureStart,
                IconResourceName = "startmarkerv2"
            };

            _endPin = new MeasurementPin
            {
                Label = "End",
                Address = "Measurement End",
                Location = MeasureEnd,
                IconResourceName = "endmarkerv2"
            };
        }
    }
}

View model

The view model owns the state: measurement mode, the first and second tapped locations, the computed distance, and the cabin pins. That keeps the behavior focused on map rendering and interaction.

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;

namespace MauiMapAppDemo.ViewModels
{
    public partial class MapsViewModel : ObservableObject
    {
        [ObservableProperty]
        public bool _isMeasuringMode;

        [ObservableProperty]
        private Location? _firstLocationMeasureMode;

        [ObservableProperty]
        private Location? _secondLocationMeasureMode;

        [ObservableProperty]
        private double _distanceMeasuredKm;

        public ObservableCollection<MapPinModel> CabinPins { get; } = [];

        [RelayCommand]
        private async Task MapClicked(Location location)
        {
            if (IsMeasuringMode)
            {
                HandleMeasuringMode(location);
                return;
            }

            await HandleDefaultMapClicked(location);
        }
    }
}

Marker icons πŸ“

The biggest visual improvement was the measurement markers. The solution now uses small transparent SVG assets for the start and end points, which avoids oversized default-looking icons and keeps the map readable.

The important part was keeping those marker assets compact and making sure the Android handler only applies them to the measurement pins.

Layout cleanup

The page also got a small spacing pass: the button gained more breathing room and a larger corner radius, the logo and map got margins, and the Eudem-25 info label was moved higher so the top of the page reads more naturally.

MAUI Maps Demo - Code Details

This is a compact code detail listing for the two core pieces behind the demo: the map behavior and the view model. It is meant as a quick reference for how the map, measurement flow, and pin handling fit together.

MapPinsBehavior

The behavior owns map interaction, pin rendering, measurement graphics, and the custom marker icon hookup used for the start and end measurement pins.

using MauiMapAppDemo.ViewModels;
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Maps;
using System.Windows.Input;

namespace MauiMapAppDemo.Behaviors
{

    public class MapPinsBehavior : Behavior<Microsoft.Maui.Controls.Maps.Map>
    {

        private Microsoft.Maui.Controls.Maps.Map? _map;

        private Microsoft.Maui.Controls.Maps.Polyline? _measurementLine;

        private Microsoft.Maui.Controls.Maps.Pin? _startPin;
        private Microsoft.Maui.Controls.Maps.Pin? _endPin;


        public static readonly BindableProperty IsMeasuringModeProperty =
            BindableProperty.Create(
                nameof(IsMeasuringMode),
                typeof(bool),
                typeof(MapPinsBehavior),
                false,
                propertyChanged: OnMeasurementStateChanged);

        public bool IsMeasuringMode
        {
            get => (bool)GetValue(IsMeasuringModeProperty);
            set => SetValue(IsMeasuringModeProperty, value);
        }


        public static readonly BindableProperty MeasureStartProperty =
            BindableProperty.Create(
                nameof(MeasureStart),
                typeof(Location),
                typeof(MapPinsBehavior),
                propertyChanged: OnMeasurementChanged
            );

        public Location MeasureStart
        {
            get => (Location)GetValue(MeasureStartProperty);
            set => SetValue(MeasureStartProperty, value);
        }

        public static readonly BindableProperty MeasureEndProperty =
            BindableProperty.Create(
                nameof(MeasureEnd),
                typeof(Location),
                typeof(MapPinsBehavior),
                propertyChanged: OnMeasurementChanged
            );

        public Location MeasureEnd
        {
            get => (Location)GetValue(MeasureEndProperty);
            set => SetValue(MeasureEndProperty, value);
        }


        public static readonly BindableProperty CenterProperty =
         BindableProperty.Create(
             nameof(Center),
             typeof(Location),
             typeof(MapPinsBehavior),
             propertyChanged: OnCenterChanged
             );

        public Location Center
        {
            get => (Location)GetValue(CenterProperty);
            set => SetValue(CenterProperty, value);
        }

        public static readonly BindableProperty PinItemsProperty =
            BindableProperty.Create(
                nameof(PinItems),
                typeof(IEnumerable<MapPinModel>),
                typeof(MapPinsBehavior),
                propertyChanged: OnPinItemsChanged
                );      

        public IEnumerable<MapPinModel>? PinItems
        {
            get => (IEnumerable<MapPinModel>?)GetValue(PinItemsProperty);
            set => SetValue(PinItemsProperty, value);
        }

        public static readonly BindableProperty MapClickedCommandProperty =
            BindableProperty.Create(
                nameof(MapClickedCommand),
                typeof(ICommand),
                typeof(MapPinsBehavior),
                defaultValue: null);

        public ICommand MapClickedCommand
        {
            get => (ICommand)GetValue(MapClickedCommandProperty);
            set => SetValue(MapClickedCommandProperty, value);
        }

        public static readonly BindableProperty PinClickedCommandProperty =
           BindableProperty.Create(
               nameof(PinClickedCommand),
               typeof(ICommand),
               typeof(MapPinsBehavior),
               defaultValue: null);

        public ICommand PinClickedCommand
        {
            get => (ICommand)GetValue(PinClickedCommandProperty);
            set => SetValue(PinClickedCommandProperty, value);
        }

        protected override void OnAttachedTo(Microsoft.Maui.Controls.Maps.Map bindable)
        {
            _map = bindable;

            WireUpMapClickedCommand(bindable);

            base.OnAttachedTo(bindable);

            RefreshPins();

            if (Center is not null)
            {
                _map.MoveToRegion(MapSpan.FromCenterAndRadius(Center, Distance.FromKilometers(8)));
            }
        }

        private void WireUpMapClickedCommand(Microsoft.Maui.Controls.Maps.Map map)
        {
            map.MapClicked += (object? sender, MapClickedEventArgs e) =>
            {
                if (MapClickedCommand?.CanExecute(e.Location) == true)
                {
                    MapClickedCommand.Execute(e.Location);
                }
            };
        }

        protected override void OnDetachingFrom(Microsoft.Maui.Controls.Maps.Map bindable)
        {
            _map = null;

            base.OnDetachingFrom(bindable);
        }

        private static void OnCenterChanged(
            BindableObject bindable,
            object oldValue,
            object newValue)
        {
            var behavior = (MapPinsBehavior)bindable;

            if (behavior._map is not null && newValue is Location location)
            {
                behavior._map.MoveToRegion(MapSpan.FromCenterAndRadius(location,
                    Distance.FromKilometers(8)));
            }
        }

        private static void OnMeasurementChanged(
            BindableObject bindable,
            object oldValue,
            object newValue)
        {
            ((MapPinsBehavior)bindable).RefreshMeasurementLine();
        }


        private static void OnMeasurementStateChanged(
            BindableObject bindable,
            object oldValue,
            object newValue)
        {
            var behavior = (MapPinsBehavior)bindable;

            if (!(bool)newValue)
            {
                behavior.ClearMeasurementGraphics();
            }
        }


        private void RefreshMeasurementLine()
        {
            if (_map == null)
            {
                return;
            }

            ClearMeasurementGraphics();

            if (MeasureStart == null)
            {
                return;
            }
            else
            {
                _startPin = new MeasurementPin
                {
                    Label = "Start",
                    Address = "Measurement Start",
                    Location = MeasureStart,
                    IconResourceName = "startmarkerv2"
                };

                _map.Pins.Add(_startPin);
            }

            if (MeasureEnd == null)
            {
                return;
            }
            else
            {


                _endPin = new MeasurementPin
                {
                    Label = "End",
                    Address = "Measurement End",
                    Location = MeasureEnd,
                    IconResourceName = "endmarkerv2"
                };

                _map.Pins.Add(_endPin);
            }

            _measurementLine = new Polyline
            {
                StrokeColor = Colors.Red,
                StrokeWidth = 5
            };

            _measurementLine.Geopath.Add(MeasureStart);
            _measurementLine.Geopath.Add(MeasureEnd);

            _map.MapElements.Add(_measurementLine);
        }

        private static void OnPinItemsChanged(
            BindableObject bindable,
            object oldValue,
            object newValue)
        {
            ((MapPinsBehavior)bindable).RefreshPins();
        }

        private void ClearMeasurementGraphics()
        {
            if (_map == null)
            {
                return;
            }

            if (_startPin != null)
            {
                _map.Pins.Remove(_startPin);
            }

            if (_endPin != null)
            {
                _map.Pins.Remove(_endPin);
            }

            if (_measurementLine != null)
            {
                _map.MapElements.Remove(_measurementLine);
            }

            _startPin = null;
            _endPin = null;
            _measurementLine = null;
        }

        private void RefreshPins()
        {
            if (_map is null || PinItems is null)
                return;

            _map.Pins.Clear();

            foreach (var item in PinItems.OfType<MapPinModel>())
            {
                var pin = new Pin
                {
                    Label = item.Label,
                    Address = item.Address,
                    Location = new Location(
                        item.Latitude,
                        item.Longitude)
                };

                pin.MarkerClicked += (_, _) =>
                {
                    if (PinClickedCommand?.CanExecute(item) == true)
                    {
                        PinClickedCommand.Execute(item);
                    }
                };

                _map.Pins.Add(pin);
            }
        }




    }
}

MapsViewModel

The view model owns the measurement state, the list of cabin pins, and the commands invoked by the page and the behavior.

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MauiMapAppDemo.Repositories.PinLocations;
using MauiMapAppDemo.Services;
using System.Collections.ObjectModel;

namespace MauiMapAppDemo.ViewModels
{

    public partial class MapsViewModel : ObservableObject
    {
        private readonly OpenTopoService _openTopoService;
        private readonly GeocodingService _geocodingService;
        private readonly DialogService _dialogService;

        private bool _pinClickInProgress = false;

        [ObservableProperty]
        public bool _isMeasuringMode;

        [ObservableProperty]
        private Location? _firstLocationMeasureMode;

        [ObservableProperty]
        private Location? _secondLocationMeasureMode;

        [ObservableProperty]
        private double _distanceMeasuredKm;

        public ObservableCollection<MapPinModel> CabinPins { get; } = [];

        public Location MapCenter { get; } = new(63.4305, 10.3951);

        public MapsViewModel(OpenTopoService openTopoService, GeocodingService geocodingService, DialogService dialogService)
        {
            InitCabinPins();

            _openTopoService = openTopoService;
            _geocodingService = geocodingService;
            _dialogService = dialogService;
        }

        [RelayCommand]
        private async Task PinClicked(MapPinModel pin)
        {
            _pinClickInProgress = true;
            try
            {
                var elevation =
                    await _openTopoService.GetElevationAsync(
                        pin.Latitude,
                        pin.Longitude);

                var placementInfo =
                    await _geocodingService.GetGeocodingPlacemark(
                        pin.Latitude,
                        pin.Longitude);

                await _dialogService.ShowAlertAsync(
                    pin.Label,
                    $"{pin.Address}\n\nElevation: {elevation}m\n\n{placementInfo}",
                    "OK");
            }
            finally
            {
                _pinClickInProgress = false;
            }
        }

        [RelayCommand]
        private async Task ToggleMeasureMode()
        {
            IsMeasuringMode = !IsMeasuringMode; //toggle the measuring mode
        }

        [RelayCommand]
        private async Task MapClicked(Location location)
        {
            if (_pinClickInProgress)
            {
                return;
            }

            if (IsMeasuringMode)
            {
                HandleMeasuringMode(location);
                return;
            }

            await HandleDefaultMapClicked(location);
        }

        private void HandleMeasuringMode(Location location)
        {
            if (FirstLocationMeasureMode == null)
            {
                FirstLocationMeasureMode = location;
                return;
            }

            if (SecondLocationMeasureMode == null)
            {
                SecondLocationMeasureMode = location;

                var distance = Location.CalculateDistance(
                    FirstLocationMeasureMode,
                    SecondLocationMeasureMode,
                    DistanceUnits.Kilometers
                    );

                DistanceMeasuredKm = Math.Round(distance, 1);

                return;
            }

            //Third click restarts over 
            FirstLocationMeasureMode = location;
            SecondLocationMeasureMode = null;
        }      

        private async Task HandleDefaultMapClicked(Location location)
        {
            var elevationOfPoint = await _openTopoService.GetElevationAsync(location.Latitude, location.Longitude);
            await ShowLocationInformationAlert($"Clicked point in the map:", $"Showing elevation of clicked point:", location.Latitude, location.Longitude);
        }

        private async Task ShowLocationInformationAlert(string label, string address, double latitude, double longitude)
        {
            var elevationOfPoint = await _openTopoService.GetElevationAsync(latitude, longitude);

            var placementInfo = await _geocodingService.GetGeocodingPlacemark(latitude, longitude);

            await _dialogService.ShowAlertAsync(
                    label,
                    address + $"\n\nElevation: {elevationOfPoint} m\n\nGeocoding (Placement) info:\n {placementInfo ?? ""}",
                    "OK"
                ); //on click , alert the pin data also via this marker clicked callback 
        }

        private void InitCabinPins()
        {
            foreach (var cabin in TrondheimCabins.GetSampleData())
            {
                CabinPins.Add(
                    new MapPinModel
                    {
                        Label = cabin.Name,
                        Address = cabin.Description,
                        Latitude = cabin.Latitude,
                        Longitude = cabin.Longitude
                    });
            }
        }

    }
}

MauiProgram Adjustments for custom markers on Android platform with Maui

This section shows the startup wiring that makes the demo work on Android: it registers MAUI Maps, reads the maps keys from user secrets, and appends the custom marker icon mapping used by the measurement pins.

using CommunityToolkit.Maui;
using MauiMapAppDemo.Behaviors;
using MauiMapAppDemo.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Maui.Maps.Handlers;

namespace MauiMapAppDemo
{
    public static class MauiProgram
    {
        public static MauiApp CreateMauiApp()
        {
            var builder = MauiApp.CreateBuilder();
            builder
                .UseMauiApp<App>()
                .UseMauiMaps()
                .UseMauiCommunityToolkit()
                .ConfigureFonts(fonts =>
                {
                    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
                });

#if ANDROID
            MapPinHandler.Mapper.AppendToMapping("MeasurementPinIcons", (handler, mapPin) =>
            {
                if (mapPin is not MeasurementPin measurementPin)
                {
                    return;
                }

                var resourceId = GetDrawableResourceId(measurementPin.IconResourceName);
                if (resourceId == 0)
                {
                    return;
                }

                handler.PlatformView.SetIcon(Android.Gms.Maps.Model.BitmapDescriptorFactory.FromResource(resourceId));
            });
#endif

#if DEBUG
            builder.Configuration.AddUserSecrets<App>();
#endif

            string azureMapsKey = builder.Configuration["AzureMapsKey"] ?? string.Empty;
            builder.ConfigureEssentials(essentials => essentials.UseMapServiceToken(azureMapsKey));

#if DEBUG
            builder.Logging.AddDebug();
#endif

            builder.Services.AddSingleton<OpenTopoService>();
            builder.Services.AddSingleton<GeocodingService>();
            builder.Services.AddSingleton<DialogService>();

            return builder.Build();
        }

#if ANDROID
        private static int GetDrawableResourceId(string resourceName)
        {
            if (string.IsNullOrWhiteSpace(resourceName))
            {
                return 0;
            }

            var field = typeof(Resource.Drawable).GetField(
                resourceName,
                System.Reflection.BindingFlags.Public |
                System.Reflection.BindingFlags.Static |
                System.Reflection.BindingFlags.IgnoreCase);

            if (field?.GetValue(null) is int resourceId)
            {
                return resourceId;
            }

            return 0;
        }
#endif
    }
}

Screenshots of the demo running with Android emulator from VS 2026 πŸ“Έ

Here are images of the demo running with the Android emulator. Pixel 7 with API v36 is being used here.

Sunday, 15 February 2026

Blazor Property Grid - Updated version

Blazor Property Grid

I wrote a Blazor Property Grid component back in 2021, the updated the version is now available some five years later.

You will find the property grid available on Nuget here:

https://www.nuget.org/packages/BlazorPropertyGridComponents/1.2.4

The property grid allows inspection of an object's properties and also edit them. Supported data types are the fundamental data types, which means integers, date times, booleans, strings and numbers.

The property grid supports nested properties, properties that are compound objects themselves. I have not yet added template supported for custom data types, but fundamentals nested properties inside the complex property are shown. This means you can drill down into an object and both inspect the object and also edit the properties that are the mentioned fundamental data types.

This project delivers a Blazor property‑grid component capable of inspecting and editing both top‑level and deeply nested properties of an object. It works smoothly with nested structures and internal members.

It has been verified using Blazor WebAssembly running on .NET. 10. A sample client is included in the BlazorSampleClient project.

The component implementation is located inside the Razor Class Library BlazorPropertyGridComponents.

Licensing is MIT and the component is provided as‑is. If used in production, you are responsible for validating its suitability. Forks, modifications, and commercial reuse are allowed. The project originated as a personal learning exercise.

The screenshot below (from the sample client) illustrates the property grid on the right side updating the same model that the form on the left is bound to.

The component expects your project to include Bootstrap and Font Awesome. You can inspect exact versions inside libman.json. Additional styling resides in styles.css.

libman.json example

{
  "version": "1.0",
  "defaultProvider": "cdnjs",
  "libraries": [
    {
      "library": "bootstrap@5.3.3",
      "destination": "wwwroot/bootstrap/"
    },
    {
      "library": "font-awesome@6.5.1",
      "destination": "wwwroot/font-awesome/"
    }
  ]
}

Supported Property Types

  • DateTime (full or date‑only)
  • Float
  • Decimal
  • Int
  • Double
  • String
  • Bool
  • Enums (rendered using <select> and <option>)

Using the Component – API

Below is a Razor example showing how to use the component. The PropertySetValueCallback is optional and only required if you want UI changes reflected elsewhere immediately.

<div class="col-md-6"> <!-- Property grid shown in the right column -->
  <h4 class="mb-3">Property Grid Component Demo</h4>

  <PropertyGridComponent
      PropertySetValueCallback="OnPropertyValueSet"
      ObjectTitle="Property grid - Edit form for : 'Customer Details'"
      DataContext="@exampleModel">
  </PropertyGridComponent>
</div>

@code {
  private void OnPropertyValueSet(PropertyChangedInfoNotificationInfoPayload pi)
  {
    if (pi != null)
    {
      JsRunTime.InvokeVoidAsync(
        "updateEditableField",
        pi.FieldName,
        pi.FullPropertyPath,
        pi.Value
      );
    }
  }

  private CustomerModel exampleModel = new CustomerModel
  {
    Address = new AddressInfo
    {
      Zipcode = 7045,
      AddressDetails = new AddressInfoDetails
      {
        Box = "PO Box 123"
      }
    }
  };

  private void HandleValidSubmit()
  {
  }
}

The JavaScript function updateEditableField is located in script.js.

Updating via C# Instead of JS

You can also update the model directly through reflection. This approach ensures proper Blazor re‑rendering. Be sure to call StateHasChanged(). Note that this code is only required in case you use the property grid for editing properties and show the object you edit other places on the same page.

@code {

  private void OnPropertyValueSet(PropertyChangedInfoNotificationInfoPayload pi)
  {
    if (pi == null)
      return;

    SetPropertyByPath(exampleModel, pi.FullPropertyPath, pi.Value, pi.ValueType);
    StateHasChanged();
  }

  private void SetPropertyByPath(object target, string propertyPath, object value, string valueType)
  {
    if (target == null || string.IsNullOrEmpty(propertyPath))
      return;

    var parts = propertyPath.Split('.');
    var current = target;

    // Move to parent object

        // Navigate to the parent object
        for (int i = 0; i < parts.Length - 1; i++)
        {
            var prop = current.GetType().GetProperty(parts[i]);
            if (prop == null) return;
            current = prop.GetValue(current);
            if (current == null) return;
        }

        // Set the final property
        var finalProp = current.GetType().GetProperty(parts[^1]);
        if (finalProp == null) return;

        try
        {
            object convertedValue = value;

            if (finalProp.PropertyType.IsEnum && value != null)
            {
                var valStr = value.ToString();
                if (int.TryParse(valStr, out int intVal))
                    convertedValue = Enum.ToObject(finalProp.PropertyType, intVal);
                else
                    convertedValue = Enum.Parse(finalProp.PropertyType, valStr, ignoreCase: true);
            }
            else if (finalProp.PropertyType == typeof(bool) && value != null)
            {
                convertedValue = Convert.ToBoolean(value);
            }
            else if (value != null && finalProp.PropertyType != typeof(string))
            {
                convertedValue = Convert.ChangeType(value, finalProp.PropertyType);
            }

            finalProp.SetValue(current, convertedValue);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Failed to set {propertyPath}: {ex.Message}");
        }
    }

    private CustomerModel exampleModel = new CustomerModel
    {
        Address = new AddressInfo
        {
            Zipcode = 7045,
            AddressDetails = new AddressInfoDetails
            {
                Box = "PO Box 123"
            }
        }
    };

    private void HandleValidSubmit()
    {

    }

}

I have used Claude Haiku 4.5 LLM to create a nice Architecture Documentation of my component so it is more convenient to see the structure of the Blazor property grid component. This is handy for those developers who wants to work on the component and add features and understood its structure. As mentioned before, the component is licensed with MIT license and you can adjust the component as needed free of use (and responsiblity).

πŸ”· Blazor Property Grid Component - Architecture Documentation


πŸ“Š Property Grid Component Structure

PROPERTY GRID COMPONENT ARCHITECTURE
====================================

ROOT CONTAINER: PropertyGridComponent.razor
├── EditForm (wraps the entire grid)
└── .property-grid-container
    ├── Header Table (.property-grid-header-table)
    │   └── thead (.property-grid-header)
    │       └── tr
    │           ├── th: "Property"
    │           ├── th: "Value"
    │           └── th: Edit Button (pencil icon)
    │
    └── Body Table (.property-grid-body-table)
        └── tbody (.property-grid-body)
            └── foreach KeyValuePair in Props
                ├── IF: Simple Property (IsClass = false)
                │   └── [COMMENTED OUT - NOT DISPLAYED]
                │
                └── IF: Nested Class (IsClass = true)
                    └── tr
                        ├── td (colspan=2)
                        │   ├── Expand/Collapse Button (minus icon)
                        │   └── div (.collapse .show)
                        │       └── PropertyRowComponent [Depth=1]
                        └── td (empty)


PROPERTY ROW COMPONENT: PropertyRowComponent.razor
====================================================

FOR EACH SubProperty in PropertyInfoAtLevel.SubProperties:
├── IF: Simple Type (not a class or System namespace)
│   └── tr (.property-row)
│       ├── td (.property-name-cell)
│       │   └── span (.property-name) = Property Name
│       │
│       └── td (.property-value-cell)
│           ├── IF: DateTime
│           │   └── InputDate (if editable) OR span (if readonly)
│           │
│           ├── IF: bool
│           │   └── InputCheckbox (if editable) OR span (if readonly)
│           │
│           ├── IF: int
│           │   └── InputNumber (if editable) OR span (if readonly)
│           │
│           ├── IF: double
│           │   └── InputText type="number" (if editable) OR span (if readonly)
│           │
│           ├── IF: decimal
│           │   └── InputText type="number" (if editable) OR span (if readonly)
│           │
│           ├── IF: float
│           │   └── InputText type="number" (if editable) OR span (if readonly)
│           │
│           ├── IF: string
│           │   └── InputText type="text" (if editable) OR span (if readonly)
│           │
│           ├── IF: Enum
│           │   └── select (if editable)
│           │       └── option foreach Enum value
│           │       OR span (if readonly)
│           │
│           └── ELSE: Unknown Type
│               └── span = Raw Value
│
└── IF: Nested Class (PropertyValue is HierarchicalPropertyInfo)
    └── tr (.property-row .nested-property-row)
        ├── td (colspan=2, .nested-property-cell)
        │   ├── span (.nested-property-name) = Nested Class Name
        │   ├── Expand/Collapse Button (plus icon)
        │   └── div (.collapse or .collapse.show)
        │       └── PropertyRowComponent [Depth+1] (RECURSIVE)
        └── [Empty td]


DATA STRUCTURE: HierarchicalPropertyInfo
================================================

HierarchicalPropertyInfo
├── PropertyName: string
├── PropertyValue: object
├── PropertyType: Type
├── SubProperties: Dictionary<string, HierarchicalPropertyInfo>
├── FullPropertyPath: string (dot-separated path)
├── IsClass: bool (indicates if this is a class type)
├── IsEditable: bool
├── NewValue: object (for tracking changes)
└── ValueSetCallback: EventCallback

HIERARCHY BUILD PROCESS:
========================

MapPropertiesOfDataContext(root object)
├── Create ROOT HierarchicalPropertyInfo
├── For each Public Property:
│   ├── IF: Simple Type (not class or not System namespace)
│   │   └── Add to SubProperties as leaf node (IsClass=false)
│   │
│   └── IF: Nested Class (class type, not System namespace)
│       └── Recursively call MapPropertiesOfDataContext
│           └── Add to SubProperties with nested tree (IsClass=true)
│
└── Return complete tree structure


INTERACTIVITY:
==============

Edit Mode Toggle:
├── ToggleEditButton() → IsEditingAllowed = !IsEditingAllowed
└── SetEditFlagRecursive() → walks entire tree setting IsEditable on all nodes

Value Changes:
├── SetValue() called on input change
├── Handles type conversion (enums, numbers, dates, etc.)
├── Updates PropertyValue immediately for UI reflection
└── Invokes ValueSetCallback → OnValueSetCallback()

Property Change Callback:
└── PropertySetValueCallback emits PropertyChangedInfoNotificationInfoPayload with:
    ├── FieldName
    ├── FullPropertyPath
    ├── Value
    └── ValueType (text, boolean, number, date, enum)

Expand/Collapse:
└── ToggleExpandButton() → JavaScript: blazorPropertyGrid.toggleExpandButton()
    └── Toggles Bootstrap collapse class on nested div

πŸ”§ Component Descriptions and Roles

1. PropertyGridComponent (PropertyGridComponent.razor + PropertyGridComponent.razor.cs)

Role: Root container and orchestrator for the entire property grid UI

Primary Responsibility:

Accepts a data object and transforms it into a hierarchical property structure

Key Functions:

  • OnParametersSet() - Initializes the component when parameters are passed
  • MapPropertiesOfDataContext() - Recursively walks the object graph and builds the HierarchicalPropertyInfo tree
  • IsNestedProperty() - Determines if a property should be expanded (nested classes)
  • ToggleEditButton() - Handles the edit mode button click (pencil icon in header)
  • SetEditFlag() / SetEditFlagRecursive() - Propagates the IsEditable flag through the entire tree
  • OnValueSetCallback() - Listens for value changes and emits PropertySetValueCallback events to the parent component

Parameters:

  • DataContext (object) - The root object to display
  • ObjectTitle (string) - Display title for the grid
  • IsEditingAllowed (bool) - Whether fields are editable
  • PropertySetValueCallback (EventCallback) - Callback when a property value changes

Rendering:

Header table with Property/Value columns and edit button. Body table containing rows for top-level properties (delegates nested properties to PropertyRowComponent)

2. PropertyRowComponent (PropertyRowComponent.razor + PropertyRowComponent.razor.cs)

Role: Recursive component that renders individual property rows and handles nested object expansion

Primary Responsibility:

Display a single level of properties and recursively display nested levels

Key Functions:

  • SetValue() - Handles input change events, performs type conversion, and invokes callbacks
  • ToggleExpandButton() - JS interop to toggle Bootstrap collapse classes for expand/collapse UI
  • Property type detection - Conditionally renders different input controls based on property type

Type Support:

  • DateTime → InputDate (HTML5 datetime-local)
  • bool → InputCheckbox
  • int → InputNumber
  • double, decimal, float → InputText with type="number"
  • string → InputText with type="text"
  • Enum → HTML select dropdown with enum values
  • Nested Classes → Recursive PropertyRowComponent call with Depth+1
  • Unknown Types → Raw span display

Parameters:

  • PropertyInfoAtLevel (HierarchicalPropertyInfo) - The current property node to render
  • Depth (int) - Nesting depth for styling and collapse behavior
  • DisplayedFullPropertyPaths (List<string>) - Tracks which paths have been rendered (prevents duplication)

Features:

  • Editable/Read-only modes based on IsEditable flag
  • Collapse/expand functionality for nested objects
  • Visual styling (beige background for read-only values)
  • Full property path as tooltip for clarity

3. HierarchicalPropertyInfo (HierarchicalPropertyInfo.cs)

Role: Data structure representing a single property node in the object hierarchy

Primary Responsibility:

Act as a node in a tree structure that mirrors the original object's property graph

Properties:

  • PropertyName (string) - Name of the property
  • PropertyValue (object) - Current value of the property
  • PropertyType (Type) - CLR type of the property
  • SubProperties (Dictionary<string, HierarchicalPropertyInfo>) - Child properties (for nested objects)
  • FullPropertyPath (string) - Dot-separated path from root (e.g., "Customer.Address.Street")
  • IsClass (bool) - Whether this represents a class type (true = nested object, false = leaf value)
  • IsEditable (bool) - Whether the property can be edited in the current UI state
  • NewValue (object) - Tracks modified value before submission
  • ValueSetCallback (EventCallback) - Callback when this property's value changes

4. PropertyChangedInfoNotificationInfoPayload (PropertyChangedInfoNotificationInfoPayload.cs)

Role: Payload object that carries change notification information back to the parent

Primary Responsibility:

Communicate property change events with detailed context

Properties:

  • FieldName (string) - Name of the property
  • FullPropertyPath (string) - Complete path to the property
  • Value (object) - New value
  • ValueType (string) - Type of value ("text", "number", "boolean", "date", "enum")

πŸ”„ Component Interaction Flow

User interacts with PropertyGrid
    ↓
PropertyGridComponent receives DataContext
    ↓
MapPropertiesOfDataContext() builds tree of HierarchicalPropertyInfo
    ↓
Renders PropertyRowComponent for each top-level property
    ↓
PropertyRowComponent renders based on type:
  ├─ Simple types → InputControl (text, number, checkbox, date, select)
  └─ Nested classes → Recursive PropertyRowComponent
    ↓
User edits a value
    ↓
PropertyRowComponent.SetValue() processes input
    ↓
ValueSetCallback invoked
    ↓
OnValueSetCallback() determines value type and emits PropertySetValueCallback
    ↓
Parent component receives PropertyChangedInfoNotificationInfoPayload

🎯 Key Design Patterns

1. Recursive Composition

PropertyRowComponent calls itself recursively for nested objects, allowing unlimited nesting depth.

2. Tree Structure

HierarchicalPropertyInfo forms a tree that mirrors the object graph, enabling efficient traversal and state management.

3. Event Cascading

Value changes propagate up through callbacks, maintaining separation of concerns between components.

4. Type-Driven Rendering

PropertyRowComponent dynamically renders different input controls based on CLR type, supporting datetime, enum, numeric, boolean, and string types.

5. Bootstrap Collapse Integration

Nested objects use Bootstrap's collapse classes for expand/collapse functionality, toggled via JavaScript interop.


πŸ“Š Data Flow Summary

DataContext (Object)
    ↓
MapPropertiesOfDataContext() [Reflection-based tree building]
    ↓
HierarchicalPropertyInfo Tree
    ↓
PropertyGridComponent → PropertyRowComponent Chain [Rendering]
    ↓
HTML Tables + Form Controls
    ↓
[User edits value]
    ↓
ValueSetCallback Events [Bubbling up]
    ↓
PropertyChangedInfoNotificationInfoPayload [Event payload]
    ↓
Parent Component [Handles business logic]

Sunday, 25 January 2026

Rendering Blazor components using HtmlRenderer

Rendering Blazor Components Dynamically with HtmlRenderer in .NET 8

πŸš€ Rendering Blazor Components Dynamically with HtmlRenderer in .NET 8

A practical example using a generic component renderer

Source code available here:
πŸ‘‰ https://github.com/toreaurstadboss/BlazorIntroCourse/tree/main/Components/Demos/TemplatedComponents

Blazor has always been about component‑driven UI, but until .NET 8, components were tightly coupled to the Blazor runtime — either WebAssembly or Server. With the introduction of HtmlRenderer, that boundary disappears.

You can now render any .razor component into pure HTML on the server, without a browser, without WebAssembly, and without a running Blazor app. This opens up a whole new world of scenarios:

  • Rendering components inside MVC, Razor Pages, or Minimal APIs
  • Generating HTML for emails, PDFs, reports, or static site generation
  • Using Blazor components as server‑side templates
  • Building dynamic component renderers that choose components at runtime
  • Running components in background services or unit tests

In this post, I’ll walk through a practical example: A generic Blazor component that uses HtmlRenderer to render any component dynamically, and a simple Bootstrap‑style Alert component to demonstrate how it works.


🎯 Why HtmlRenderer Matters

Here’s the short version — arguments you can inform other developers why HtmlRenderer opens up so many possibilities for using Blazor components many places:

  • Render Blazor components anywhere — MVC, Razor Pages, Minimal APIs, background jobs.
  • Generate static HTML — perfect for emails, PDFs, SEO, caching, and static sites.
  • Use Blazor components as templates — no need for Razor Views or TagHelpers.
  • Full component lifecycle — DI, parameters, cascading values, child content all work.
  • No browser required — everything runs server‑side.
  • Dynamic component composition — choose components at runtime using generics.
  • Great for testing — render components without a browser or JS runtime.

πŸ–Ό️ Diagram: How HtmlRenderer Works

Your Blazor Component (e.g., <Alert>) Generic Renderer (TComponent + parameters) HtmlRenderer .NET 8 server-side renderer HTML Output (string / MarkupString)

πŸ“¦Sample Blazor component used | Alert.razor — A Simple Bootstrap‑Style Alert Component


<div 
    class=@($"alert {(IsDismissable ? "alert-dismissible fade show" : "")} alert-{AlertType.ToString().ToLower()}")
    role="alert">
    @if (IsDismissable)
    {
        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
    }
    @ChildContent
</div>

@code {

    [Parameter]
    public required RenderFragment ChildContent { get; set; } = @<b>Default message</b>;

    [Parameter]
    public bool IsDismissable { get; set; }

    [Parameter]
    public AlertTypeEnum AlertType { get; set; } = AlertTypeEnum.Success;

}


🧩 The alert type enum | AlertTypeEnum.cs


namespace DependencyInjectionDemo.Components.Demos.TemplatedComponents
{
    public enum AlertTypeEnum
    {
        Primary,
        Secondary,
        Success,
        Danger,
        Warning,
        Info,
        Light,
        Dark
    }
}


🧠 The Generic HTML Renderer Component | GenericHtmlRenderer.razor


@((MarkupString)RenderedHtml)

@typeparam TComponent

@code {
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public Dictionary<string, object?> Parameters { get; set; } = new();

    [Inject] IServiceProvider ServiceProvider { get; set; } = default!;
    [Inject] ILoggerFactory LoggerFactory { get; set; } = default!;

    private string RenderedHtml { get; set; } = string.Empty;

    protected override async Task OnInitializedAsync()
    {
        if (ChildContent != null)
        {
            Parameters["ChildContent"] = ChildContent;
        }

        RenderedHtml = await RenderComponentAsync(Parameters);
    }

    private async Task<string> RenderComponentAsync(Dictionary<string, object?> parameters)
    {
        if (!typeof(IComponent).IsAssignableFrom(typeof(TComponent)))
        {
            throw new InvalidOperationException($"{typeof(TComponent).Name} is not a valid Blazor component.");
        }

        using var htmlRenderer = new HtmlRenderer(ServiceProvider, LoggerFactory);
        var parameterView = ParameterView.FromDictionary(parameters);

        var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
        {
            var result = await htmlRenderer.RenderComponentAsync(typeof(TComponent), parameterView);
            return result.ToHtmlString();
        });

        return html;
    }
}

Note that we must also check if the parameters sent in here actually are present in the component. That means the parameter is a public property with attribute Parameter. The revised code of the method RenderComponentAsync is shown below.

private async Task<string> RenderComponentAsync(Dictionary<string, object?> parameters)
{

    if (!typeof(IComponent).IsAssignableFrom(typeof(TComponent)))
    {
        throw new InvalidOperationException($"{typeof(TComponent).Name} is not a valid Blazor component.");
    }

    using var htmlRenderer = new HtmlRenderer(ServiceProvider, LoggerFactory);

    var filteredParameters = GetValidParameters(parameters);

    var parameterView = ParameterView.FromDictionary(filteredParameters);

    var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
    {
        var result = await htmlRenderer.RenderComponentAsync(typeof(TComponent), parameterView);
        return result.ToHtmlString();
    });

    return html;
}

private Dictionary<string, object?> GetValidParameters(Dictionary<string, object?> parameters)
{
    var validnames = typeof(TComponent).GetProperties(BindingFlags.Public | BindingFlags.Instance)
        .Where(p => p.IsDefined(typeof(ParameterAttribute), true))
        .Select(p => p.Name)
        .ToHashSet(StringComparer.OrdinalIgnoreCase);   
    validnames.Add("ChildContent");

    var filteredParameters = parameters
        .Where(kvp => validnames.Contains(kvp.Key))
        .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);

    var unknownKeys = parameters.Keys.Except(filteredParameters.Keys, StringComparer.OrdinalIgnoreCase).ToList();
    if (unknownKeys.Count > 0)
    {
        var logger = LoggerFactory.CreateLogger("GenericHtmlRenderer");
        string warningmsg = $"Dropped unknown component parameters for {typeof(TComponent).FullName}: {string.Join(",", unknownKeys)}";
        logger.LogWarning(warningmsg);
        Console.WriteLine(warningmsg);
    }

    return filteredParameters;
}

This adjustment makes the code more resilient in case a non-existent parameter is sent in. Please note that an invalid parameter value will still give a runtime exception, so further validation could be added here, such as using TypeConverter to check if we can convert the parameter value sent in to the parameter in the component. The revised code is shown below.

@using System.Reflection
@using System.ComponentModel
@using Microsoft.AspNetCore.Components
@((MarkupString)RenderedHtml)

@typeparam TComponent

@code {
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public Dictionary<string, object?> Parameters { get; set; } = new();

    [Inject] IServiceProvider ServiceProvider { get; set; } = default!;
    [Inject] ILoggerFactory LoggerFactory { get; set; } = default!;

    private string RenderedHtml { get; set; } = string.Empty;

    protected override async Task OnInitializedAsync()
    {
        if (ChildContent != null)
        {
            Parameters["ChildContent"] = ChildContent;
        }

        RenderedHtml = await RenderComponentAsync(Parameters);
    }

    private async Task<string> RenderComponentAsync(Dictionary<string, object?> parameters)
    {

        if (!typeof(Microsoft.AspNetCore.Components.IComponent).IsAssignableFrom(typeof(TComponent)))
        {
            throw new InvalidOperationException($"{typeof(TComponent).Name} is not a valid Blazor component.");
        }

        using var htmlRenderer = new HtmlRenderer(ServiceProvider, LoggerFactory);

        var filteredParameters = GetValidParameters(parameters);

        var parameterView = ParameterView.FromDictionary(filteredParameters);

        var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
        {
            var result = await htmlRenderer.RenderComponentAsync(typeof(TComponent), parameterView);
            return result.ToHtmlString();
        });

        return html;
    }


    private Dictionary<string, object?> GetValidParameters(Dictionary<string, object?> parameters)
    {
        var componentType = typeof(TComponent);

        // Get valid property names and their PropertyInfo
        var validProperties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Where(p => p.IsDefined(typeof(ParameterAttribute), true))
            .ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);

        // Add ChildContent as a special case
        validProperties["ChildContent"] = null!;

        var filteredParameters = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);

        foreach (var kvp in parameters)
        {
            if (validProperties.ContainsKey(kvp.Key))
            {
                var propertyInfo = validProperties[kvp.Key];

                if (propertyInfo != null)
                {
                    var targetType = propertyInfo.PropertyType;
                    var value = kvp.Value;

                    // Check if value is null or already assignable
                    if (value == null || targetType.IsInstanceOfType(value))
                    {
                        filteredParameters[kvp.Key] = value;
                    }
                    else
                    {
                        // Use TypeDescriptor to check conversion
                        var converter = TypeDescriptor.GetConverter(targetType);
                        if (converter != null && converter.CanConvertFrom(value.GetType()))
                        {
                            try
                            {
                                filteredParameters[kvp.Key] = converter.ConvertFrom(value);
                            }
                            catch
                            {
                                LogConversionWarning(kvp.Key, value);
                            }
                        }
                        else
                        {
                            LogConversionWarning(kvp.Key, value);
                        }
                    }



πŸ§ͺ Demo Page — Rendering Alerts Dynamically | GenericHtmlRendererDemo.razor


@page "/GenericHtmlRendererDemo"
@using BlazorIntroCourse.Components.Demos.TemplatedComponents

<h3>GenericHtmlRendererDemo - Dynamic Alert rendering</h3>

<h6>Here the parameters are set in code</h6>
<GenericHtmlRenderer TComponent="Alert"
                     ChildContent="alertContent"
                     Parameters="alternateAlertParameters" />

<h6>Here we use the ChildContent element to enter in the html</h6>
<GenericHtmlRenderer TComponent="Alert"
                     Parameters="alertParameters">
    <ChildContent>
        <text>This is the second alert</text>
    </ChildContent>                 
</GenericHtmlRenderer>

<h6>Here we use inline object initializer for parameters</h6>
<GenericHtmlRenderer TComponent="Alert"
                     Parameters="@(new Dictionary<string, object>{
    [ "AlertType" ] = AlertTypeEnum.Danger,
    [ "IsDismissable" ] = true
})">
    <ChildContent>
        <text>This is the third alert</text>
    </ChildContent>  
</GenericHtmlRenderer>

@code {

    private RenderFragment alertContent = @<text>This is the first Alert</text>;
    Dictionary<string, object?> alertParameters = new()
    {
        { "AlertType", AlertTypeEnum.Info}, 
        { "IsDismissable", true }
    };

    Dictionary<string, object?> alternateAlertParameters = new()
    {
        { "AlertType", AlertTypeEnum.Success }, 
        { "IsDismissable", true }
    };

}


πŸ“˜ Diagram: Where You Can Use HtmlRenderer

HtmlRenderer API .NET 8 MVC Razor Pages Minimal APIs Background Jobs (emails, PDFs, reports) Static HTML Generation

🏁 Wrapping Up

HtmlRenderer in .NET 8 is one of those features that quietly unlocks a huge amount of flexibility. It turns Blazor components into universal UI building blocks that can be rendered:

  • in a browser
  • on the server
  • inside MVC
  • inside Razor Pages
  • inside Minimal APIs
  • inside background services
  • inside unit tests
  • or even at build time

The generic component renderer shown here is a concrete example of how powerful this can be — dynamic component rendering, runtime composition, and server‑side HTML generation all in one.

If you’re curious, you can explore the full source code here: (part of a larger repo I am looking into currently as a playground for misc Blazor functionality):
πŸ‘‰ https://github.com/toreaurstadboss/BlazorIntroCourse/tree/main/Components/Demos/TemplatedComponents