(Library Part 2) Info & Error states with FluentValidation

In the second MAUI Design System series, we'll explore how to expanded the functionality of a standard MAUI Entry control by including Info and Error states, introducing data validation with FluentValidation and cleaning the code with CommunityToolkit.Mvvm.
(Library Part 2) Info & Error states with FluentValidation

Intro

Disclaimer: This article is intended for an audience already familiar with MAUI. We keep the explanations tight and straightforward for beginners.

Welcome to this series of articles where we’ll build a complete Design System library of custom controls in MAUI. This article builds on the previous one, (MAUI Library Part 1) Create a Custom Entry using SkiaSharp.

To recap, this custom entry is split into two files:

  • LabelBase, which contains the properties and behaviors inherited by all controls.
  • EntryLabel, which extends LabelBase by customizing properties and behaviors specific to the entry control.

Goal

We’ll implement two states, info and error, within the LabelBase control, which will apply to our Entry. The final result will look like this:

Final Result

Add Info and Error states in LabelBase

Info and Error states will be displayed using the Label control. This Label will extend to any control that inherits from LabelBase, ensuring consistent behavior across all derived controls.

–> Blog_MAUI_Components/Presentations/Pages/Entry/LabelBase.xaml

<Grid
    RowDefinitions="Auto,Auto,Auto">

// ...

<skia:SKCanvasView 
    x:Name="BorderCanvasView" 
    PaintSurface="OnCanvasViewPaintSurface" />

<Border 
    x:Name="BorderLabel" 
    Style="{StaticResource BaseBorder}" />

<Label 
    x:Name="InfoLabel" Grid.Row="1"
    IsVisible="False"
    TextColor="{StaticResource Gray500}"
    Style="{StaticResource BaseLabelLabel}" />

<Label 
    x:Name="ErrorLabel" Grid.Row="2"
    IsVisible="False"
    TextColor="{StaticResource Danger}"
    Style="{StaticResource BaseLabelLabel}"/>

</Grid>

It is essential to set the visibility of labels to false by default ensuring they remain hidden until content is provided. Once content is provided, visibility will be updated to true. Feel free to adapt the visual appearance to the standards of your design system.

Add two string Bindable Properties in the code behind to contain the content of the Info and Error states.

–> Blog_MAUI_Components/Presentations/Pages/Entry/LabelBase.xaml.cs

// ...

public static readonly BindableProperty InfoProperty = 
  BindableProperty.Create("Info", typeof(string), 
  typeof(LabelBase), propertyChanged: InfoChanged);

public string Info
{
    get => (string)GetValue(InfoProperty);
    set => SetValue(InfoProperty, value);
}

public static readonly BindableProperty ErrorProperty = 
  BindableProperty.Create("Error", typeof(string), 
  typeof(LabelBase), propertyChanged: ErrorChanged);

public string Error
{
    get => (string)GetValue(ErrorProperty);
    set => SetValue(ErrorProperty, value);
}

// ...

private static void InfoChanged
  (BindableObject bindable, object oldValue, object newValue) => 
    (LabelBase)bindable).UpdateInfoView();
private static void ErrorChanged
  (BindableObject bindable, object oldValue, object newValue) => 
    ((LabelBase)bindable).UpdateErrorView();

private void UpdateInfoView()
{
    InfoLabel.Text = Info;
    InfoLabel.IsVisible = !string.IsNullOrEmpty(Info);
}

private void UpdateErrorView()
{
    ErrorLabel.Text = Error;
    ErrorLabel.IsVisible = !string.IsNullOrEmpty(Error);
    BorderCanvasView.InvalidateSurface(); // Repaint when binding context changes
}

// ...

Changing the border color to red is a good practice, as it gives users a clearer view when the error state is raised.

We need to set the correct color according to the contents of the Error property in our method that draws the canvas:

private void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
    // ...

    var paint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        StrokeWidth = 3,
        IsAntialias = true // Smooth edges
    };

    paint.Color = !string.IsNullOrEmpty(Error) 
      ? ResourceHelper.GetResource<Color>("Danger").ToSKColor() 
      : ResourceHelper.GetThemeColor("Gray900", "Gray100").ToSKColor();
    
    // ...
}

Display EntryLabel using Info & Error properties in a page

Voilà! Since our EntryLabel inherits from LabelBase, we now have access to the Info and Error properties in our EntryPage control, so let’s play with them.

–> Blog_MAUI_Components/Presentations/Pages/Entry/EntryPage.xaml

// ...
    
<ccl:EntryLabel
    Label="First name or Last name"
    IsRequired="True"
    Placeholder="John Doe" />

<ccl:EntryLabel
    Label="Email"
    Error="Email is required"
    IsRequired="True"
    Info="We will never share your email with anyone"
    Placeholder="E.g: john.doe@gmail.com" />
    
// ...

We can see the output result below.

Display Interface with static errors

It works, but it’s not usable for real-life use cases. You may think that nothing is dynamic; we need to trigger a validation check when I click on a Save button.

Let’s have a look at the steps we’re missing:

  1. Bind the content to some of the ViewModel’s properties (e.g. FullName, Email, etc.)
  2. Add a Save button
  3. Validate the email address should not null or incorrectly formatted
  4. Trigger the error state if the validation fails

Implement FluenValidation & CommunityToolkit.Mvvm

Let me introduce FluentValidation, a widely-used library in .NET for building strongly-typed validation rules.

The [ObservableProperty] and [RelayCommand] attributes are part of the CommunityToolkit.Mvvm library, which simplifies property creation in the MVVM pattern.

Using the attribute [ObservableProperty] will generate a corresponding public property FullName with getter and setter methods. Additionally, it implements INotifyPropertyChanged, so the property will notify the UI of changes when its value is updated. The [RelayCommand] attribute is used to automatically generate command.

Let’s create our first ViewModel named EntryPageViewModel, we will add properties for FullName and Email. The validation result will be stored in a ValidationResult property.

–> Blog_MAUI_Components/Presentations/Pages/Entry/EntryPageViewModel.cs

public partial class EntryPageViewModel : ViewModelBase
{
    private readonly ILogger<EntryPageViewModel> _logger;
    
    [ObservableProperty]
    private string? _fullName;
    
    [ObservableProperty]
    private string? _email;

    [ObservableProperty]
    private ValidationResult? _validationResult;
    
    public EntryPageViewModel(
        ILogger<EntryPageViewModel> logger)
    {
        _logger = logger;

        _logger.LogInformation("Building EntryPageViewModel");
    }

    // Some code...
    
    [RelayCommand]
    private async Task Save()
    {
        _logger.LogInformation("Save()");
        
        // Create a new instance of the Validator
        var validator = new EntryPageViewModelValidator();
        
        // Apply the validation and get the result
        ValidationResult = await validator.ValidateAsync(this);
        if (!ValidationResult.IsValid)
        {
            // The validation contains error, we stop the process
            return;
        }
        
        await Toast.Make("Saved !!").Show();
    }
}

To proceed with validation, we need to define the rules. This is the purpose of the EntryPageViewModelValidator class:

–> Blog_MAUI_Components/Presentations/Pages/Entry/EntryPageViewModelValidator.cs

public class EntryPageViewModelValidator : AbstractValidator<EntryPageViewModel>
{
    // Used by the view to know which property of the ViewModel is doing the validation.
    public static string FullNameProperty => nameof(EntryPageViewModel.FullName);
    public static string EmailProperty => nameof(EntryPageViewModel.Email);

    public EntryPageViewModelValidator()
    {
        // FullName can't be null
        RuleFor(x => x.FullName)
            .NotNull();
        
        // Email can't be null and should be a valid email
        RuleFor(x => x.Email)
            .NotNull()
            .EmailAddress();
            // Use the method below to override and define
            // your own error message.
            //.WithMessage("This email is not valid")
    }
}

Define the ViewModel into our associated view:

–> Blog_MAUI_Components/Presentations/Pages/Entry/EntryPage.xaml.cs

public EntryPage(EntryPageViewModel viewModel)
{
    InitializeComponent();
    BindingContext = viewModel;
}

Update CreateMauiApp() to ensure that our ViewModel is properly registered. I’m using .AddTransientWithShellRoute() from the CommunityToolkit.Maui library, which offers a clean and streamlined way to register. Behind the scenes, the library using Routing.RegisterRoute(route, typeof(TView)) and AddTransient<TView, TViewModel>().

–> Blog_MAUI_Components/MauiProgram.cs

// Register your pages
builder.Services.AddTransientWithShellRoute<EntryPage, EntryPageViewModel>(
  RouteConstants.EntryPage);

Add ShowErrorConverter

Understanding the structure of ValidationResult

We have a problem, our validation is stored into ValidationResult but our Error property from the EntryLabel is a string. We need a way to convert it. Let’s see how our ValidationResult is structured by adding a break point.

Display EntryPage with static errors

Create ShowErrorConverter.cs

We observe a list of errors, the reason for the error is described inside the ErrorMessage property. One recommended way to convert a ValidationResult into a string is by creating a custom ValueConverter.

To sum up, a ValueConverter can be used to transform data from one type to another when binding properties in XAML or in code, making it an ideal solution for our scenario.

–> Blog_MAUI_Components.MAUI.Converters/ShowErrorConverter.cs

public class ShowErrorConverter : IValueConverter
{
    public object? Convert(object? value, Type targetType, 
                           object? parameter, CultureInfo culture)
    {
        if (value is not ValidationResult validationResult || 
                                          validationResult.Errors.Count == 0)
        {
            return null;
        }

        if (parameter == null)
        {
            return null;
        }

        var property = parameter as string;
        return validationResult.Errors
          .FirstOrDefault(x => x.PropertyName.Split(".").LastOrDefault() == property)?
          .ErrorMessage;
    }

    public object? ConvertBack(object? value, Type targetType, 
                               object? parameter, CultureInfo culture)
    {
        throw new NotImplementedException(
          "ConvertBack not implemented for the converter.");
    }
}

Register the new converter in App.xaml so it can be easily referenced throughout the application by using StaticResource.

–> Blog_MAUI_Components/App.xaml

<Application 
  xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
  xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
  xmlns:converters="clr-namespace:Blog_MAUI_Components.MAUI.Converters;assembly=Blog_MAUI_Components.MAUI"
  x:Class="Blog_MAUI_Components.App">
  <Application.Resources>
      <ResourceDictionary>
          <ResourceDictionary.MergedDictionaries>
              <!--  Code ...  -->
          </ResourceDictionary.MergedDictionaries>
          <converters:ShowErrorConverter x:Key="ShowErrorConverter" />
      </ResourceDictionary>
  </Application.Resources>
</Application>

Use ValidationResult to display the Error on EntryPage

Finally, we can update our EntryPage to trigger the validation on the Save button:

  1. Bind the Text property of the EntryLabel to the FullName property in the ViewModel
    Text="{Binding FullName, Mode=TwoWay}"
    
  2. Bind the Error property of the EntryLabel to the ValidationResult:
    • Converter: Use the ShowErrorConverter to convert the ValidationResult into a string.
    • ConverterParameter: Reference the property related to the validation.

      We could reference the property by hardcoding ‘FullName’ as a string. However, this approach is not recommended because it doesn’t support refactoring when the property name changes. For example, if you rename FullName to DisplayName, you might forget to update the ConverterParameter value. This can lead to a subtle bug: the application will run, validation will fail, but the error state won’t be displayed for the concerned EntryLabel.

      I suggest avoiding the use of this hardcoded string:
      Error="{Binding ValidationResult, 
       Converter={StaticResource ShowErrorConverter}, 
       ConverterParameter='FullName'}"
      

      By using the static variable defined in our Validator class:

      Error="{Binding ValidationResult, 
       Converter={StaticResource ShowErrorConverter}, 
       ConverterParameter={x:Static page:EntryPageViewModelValidator.FullNameProperty}}"
      
  3. Do the same for Email EntryLabel
    Error="{Binding ValidationResult, 
     Converter={StaticResource ShowErrorConverter}, 
     ConverterParameter={x:Static page:EntryPageViewModelValidator.EmailProperty}}"
    
  4. Add a button at the bottom of the page bind to the SaveCommand
<Button Command="{Binding SaveCommand}" 
        Style="{StaticResource FilledPrimaryButton}" 
        Text="Save" />

The full implementation of the page:

–> Blog_MAUI_Components/Presentations/Pages/Entry/EntryPage.xaml

<base:ContentPageBase
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:ccl="clr-namespace:Blog_MAUI_Components.MAUI.CustomControls.Labels;assembly=Blog_MAUI_Components.MAUI"
    xmlns:page="clr-namespace:Blog_MAUI_Components.Presentation.Pages.Entry"
    xmlns:base="clr-namespace:Blog_MAUI_Components.Presentation.Common"
    Title="Entry"
    x:DataType="page:EntryPageViewModel"
    x:Class="Blog_MAUI_Components.Presentation.Pages.Entry.EntryPage">

    <Grid RowDefinitions="*,Auto" >
    <ScrollView>
        <VerticalStackLayout Margin="16, 32" Spacing="25">
            <ccl:EntryLabel
                Label="First name or Last name"
                Text="{Binding FullName, Mode=TwoWay}"
                Error="{Binding ValidationResult, 
                    Converter={StaticResource ShowErrorConverter}, 
                    ConverterParameter={x:Static 
                        page:EntryPageViewModelValidator.FullNameProperty}}"
                IsRequired="True"
                Placeholder="John Doe" />

            <ccl:EntryLabel
                Label="Email"
                Text="{Binding Email, Mode=TwoWay}"
                Error="{Binding ValidationResult, 
                    Converter={StaticResource ShowErrorConverter}, 
                    ConverterParameter={x:Static 
                        page:EntryPageViewModelValidator.EmailProperty}}"
                IsRequired="True"
                Info="We will never share your email with anyone"
                Placeholder="E.g: john.doe@gmail.com"
                ReturnCommand="{Binding SaveCommand}"/>
        </VerticalStackLayout>
    </ScrollView>

    <VerticalStackLayout Grid.Row="1" 
        Style="{StaticResource BottomElementVerticalStackLayout}">
        <Button Command="{Binding SaveCommand}" 
                Style="{StaticResource FilledPrimaryButton}" 
                Text="Save" />
    </VerticalStackLayout>
    </Grid>
</base:ContentPageBase>

Result

Display EntryPage with static errors

Conclusion

The full code is available on GitHub, with a working example.

In conclusion, we’ve improved the basic MAUI Entry control by adding Info and Error states and using FluentValidation and CommunityToolkit.Mvvm for data validation. This creates a flexible, reusable component that can be easily used in different forms and validation cases.

While this implementation is a good start, real-world applications often need more customization and refinement. By building on this foundation, you can further improve your controls to fit the specific needs of your project.

If you enjoyed this blog post, then follow me on LinkedIn, subscribe the newsletter so you don’t miss out on any future posts. Don’t forget to share this with your friends and colleagues who are interested in learning about this topic. Thank you 🥰

Happy coding!

(Library Part 2) Info & Error states with FluentValidation
(Library Part 2) Info & Error states with FluentValidation