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:
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.
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:
- Bind the content to some of the ViewModel’s properties (e.g. FullName, Email, etc.)
- Add a Save button
- Validate the email address should not null or incorrectly formatted
- 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.
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:
- Bind the Text property of the EntryLabel to the FullName property in the ViewModel
Text="{Binding FullName, Mode=TwoWay}"
- 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}}"
- Do the same for Email EntryLabel
Error="{Binding ValidationResult, Converter={StaticResource ShowErrorConverter}, ConverterParameter={x:Static page:EntryPageViewModelValidator.EmailProperty}}"
- 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
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!