Awesome
Maui.DataForms
This is a proof of concept for easily creating validable data entry forms in .NET MAUI. This is not published as a Nuget package yet, so please clone locally and add reference to the Maui.DataForms.Core
, Maui.DataForms.Controls
, and either Maui.DataForms.Dynamic
or Maui.DataForms.Fluent
projects to use.
Maui.DataForms
provides 2 modes of defining data forms within your applications:
- Fluent: this method is strongly typed and uses a classes representing your model and
Maui.DataForms
form. - Dynamic: this method allows for easy creation of dynamic forms at runtime. Forms can be created in code based on certain criteria or even generated by a server-side API and then deserialized from JSON into a
DynamicDataForm
.
Fluent Demo
The included Maui.DataForms.Sample
project illustrates how to use Mail.DataForms. Below are the highlights of using the Fluent API. There is also a Dynamic API which will be discussed at the bottom of this page.
Person
The Person
class represents the underlying data model from which the form will be created.
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DateOfBirth { get; set; }
public TimeSpan TimeOfBirth { get; set; }
public string Biography { get; set; }
public double Height { get; set; }
public double Weight { get; set; }
public bool LikesPizza { get; set; }
public bool IsActive { get; set; }
}
PersonValidator
The PersonValidator
class is the FluentValidation validator used to validate user inputs in our data form. You can use any validation framewwork you wish, just be sure to implement Maui.DataForms.Validation.IDataFormValidator<TModel>
where TModel
is the data model class (in this case Person
) to be validated.
public class PersonValidator : AbstractValidator<Person>, IDataFormValidator<Person>
{
public PersonValidator()
{
RuleFor(r => r.FirstName)
.NotEmpty()
.MaximumLength(20);
RuleFor(r => r.LastName)
.NotEmpty()
.MaximumLength(50);
RuleFor(r => r.DateOfBirth)
.NotEmpty()
.GreaterThanOrEqualTo(new DateTime(2000, 1, 1, 0, 0, 0))
.LessThanOrEqualTo(new DateTime(2021, 12, 31, 23, 59, 59));
RuleFor(r => r.Biography)
.NotEmpty()
.MaximumLength(500);
RuleFor(r => r.Height)
.GreaterThan(0.2)
.LessThanOrEqualTo(0.8);
RuleFor(r => r.Weight)
.GreaterThan(20.0)
.LessThanOrEqualTo(80.0);
}
public FormFieldValidationResult ValidateField(Person model, string formFieldName)
{
var members = new string[] { formFieldName };
var validationContext = new ValidationContext<Person>(model, new PropertyChain(), new MemberNameValidatorSelector(members));
var validationResults = Validate(validationContext);
var errors = validationResults.IsValid
? Array.Empty<string>()
: validationResults.Errors.Select(s => s.ErrorMessage).ToArray();
return new FormFieldValidationResult(validationResults.IsValid, errors);
}
public DataFormValidationResult ValidateForm(Person model)
{
var validationResults = Validate(model);
var errors = validationResults.IsValid
? new Dictionary<string, string[]>()
: validationResults.ToDictionary();
return new DataFormValidationResult(validationResults.IsValid, errors);
}
}
PersonForm
The PersonDataForm
class is where the UI elements for the data entry form are defined. To build the form, just inherit from FluentFormBase<TModel>
and then create a constructor which passes the model and validator (optional) instances to the base class and the define your fields using the fluent syntax. Finally call Build()
public class PersonDataForm : FluentFormBase<Person>
{
public PersonDataForm(Person model, IDataFormValidator<Person> validator = null)
: base(model, validator)
{
FieldFor(f => f.FirstName)
.AsEntry()
.WithConfiguration(config => config.Placeholder = "First Name")
.WithLayout(layout => layout.GridRow = 0)
.WithValidationMode(ValidationMode.Auto);
FieldFor(f => f.LastName)
.AsEntry()
.WithConfiguration(config => config.Placeholder = "Last Name")
.WithLayout(layout => layout.GridRow = 1)
.WithValidationMode(ValidationMode.Auto);
FieldFor(f => f.DateOfBirth)
.AsDatePicker()
.WithConfiguration(config =>
{
config.Format = "D";
config.MinimumDate = DateTime.MinValue;
config.MaximumDate = DateTime.MaxValue;
})
.WithLayout(layout => layout.GridRow = 2)
.WithValidationMode(ValidationMode.Auto);
FieldFor(f => f.TimeOfBirth)
.AsTimePicker()
.WithConfiguration(config => config.Format = "t")
.WithLayout(layout => layout.GridRow = 3)
.WithValidationMode(ValidationMode.Auto);
FieldFor(f => f.Biography)
.AsEditor()
.WithConfiguration(config => config.Placeholder = "Biography")
.WithLayout(layout => layout.GridRow = 4)
.WithValidationMode(ValidationMode.Auto);
FieldFor(f => f.Height)
.AsSlider()
.WithConfiguration(config =>
{
config.Minimum = 0.1;
config.Maximum = 0.9;
})
.WithLayout(layout => layout.GridRow = 5)
.WithValidationMode(ValidationMode.Auto);
FieldFor(f => f.Weight)
.AsStepper()
.WithConfiguration(config =>
{
config.Minimum = 10.0;
config.Maximum = 90.0;
})
.WithLayout(layout => layout.GridRow = 6)
.WithValidationMode(ValidationMode.Auto);
FieldFor(f => f.LikesPizza)
.AsSwitch()
.WithLayout(layout => layout.GridRow = 7);
FieldFor(f => f.IsActive)
.AsCheckBox()
.WithLayout(layout => layout.GridRow = 8);
Build();
}
}
FluentDemoPageViewModel.cs
The FluentDemoPageViewModel
class then sets the PersonDataForm
(autogenerated by CTK MVVM source generators) to a new instance of PersonDataForm
with an instances of Person
model and PersonValidator
as constructor parameters.
public partial class FluentDemoPageViewModel : ObservableObject
{
[ObservableProperty]
private PersonDataForm personDataForm;
public FluentDemoPageViewModel()
{
PersonDataForm = new PersonDataForm(new Models.Person(), new PersonValidator());
}
[RelayCommand]
private async Task Submit()
{
// no-op for now.
}
}
FluentDemoPage.xaml.cs
Set the BindingContext
to a new instance of FluentDemoPageViewModel
. In the sample, the FluentDemoPageViewModel
is configured in IServiceCollection
and automatically injected by MAUI at runtime.
public partial class FluentDemoPage : ContentPage
{
public FluentDemoPage(FluentDemoPageViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
FluentDemoPage.xaml
Add the mdfc
namespace and then a Grid
with BindableLayout.ItemsSource="{Binding PersonDataForm.Fields}"
which binds to the Fields
property of the PersonDataForm
which is a property of the view model previously set to the BindingContext
. Finally, set the Forms BindableLayout.ItemTemplateSelector
to an instance of DataFormsDataTemplateSelector
.
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="Maui.DataForms.Sample.Views.FluentDemoPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:mdfc="clr-namespace:Maui.DataForms.Controls;assembly=Maui.DataForms.Core"
xmlns:viewModels="clr-namespace:Maui.DataForms.Sample.ViewModels"
Title="Fluent Demo"
x:DataType="viewModels:FluentDemoPageViewModel">
<ScrollView>
<Grid
Margin="20"
RowDefinitions="Auto,Auto,Auto"
RowSpacing="10">
<Label
Grid.Row="0"
FontSize="18"
HorizontalOptions="Center"
Text="The data form below was generated using a model class and is validated using FluentValidation." />
<Grid
Grid.Row="1"
BindableLayout.ItemsSource="{Binding PersonDataForm.Fields}"
RowDefinitions="*,*,*,*,*,*,*,*,*"
VerticalOptions="Start">
<BindableLayout.ItemTemplateSelector>
<mdfc:DataFormsDataTemplateSelector />
</BindableLayout.ItemTemplateSelector>
</Grid>
<Button
Grid.Row="2"
Command="{Binding SubmitCommand}"
Text="Submit" />
</Grid>
</ScrollView>
</ContentPage>
FormField Controls
The default FormField controls are defined in the Maui.DataForms.Controls
project. For now, the controls are very basic to prove the proof of concept. The controls must be registered at application startup in MauiProgram
using the UseDefaultFormFieldContentControls
extension method:
using CommunityToolkit.Maui;
using Maui.DataForms.Sample.ViewModels;
using Maui.DataForms.Sample.Views;
using Maui.DataForms.Controls;
namespace Maui.DataForms.Sample;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMauiCommunityToolkit()
.UseDefaultFormFieldContentControls()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
.Services
.AddTransient<DynamicDemoPage, DynamicDemoPageViewModel>()
.AddTransient<FluentDemoPage, FluentDemoPageViewModel>();
return builder.Build();
}
}
Additionaly custom control can be registered using the MapFormFieldContentControl<ContentView>(this MauiAppBuilder builder, string formFieldTemplateName)
extension method.
WithConfiguration()
As shown in the EntryFormFieldControl.xaml
section above, you can set many values of the Entry
control using Maui.DataForms
. These values are set in the WithConfiguration()
method call when defining your field. The EntryFormFieldConfiguration.cs
snippet below shows all configuration that can be set for Entry
controls.
By default,
Maui.DataForms
uses all the control default values for the various properties, so unless there is a specific need to change the default value, you may consider removing the binding from theFormField
control definition to minimize cycles binding to values which already contain the default value.
using System.Windows.Input;
namespace Maui.DataForms.Configuration;
public sealed class EntryFormFieldConfiguration : FormFieldConfigurationBase
{
public ClearButtonVisibility ClearButtonVisibility { get; set; } = ClearButtonVisibility.Never;
public bool FontAutoScalingEnabled { get; set; } = true;
public Keyboard Keyboard { get; set; } = Keyboard.Default;
public bool IsPassword { get; set; } = false;
public bool IsTextPredictionEnabled { get; set; } = true;
public string Placeholder { get; set; } = string.Empty;
public ICommand ReturnCommand { get; set; }
public object ReturnCommandParameter { get; set; }
public ReturnType ReturnType { get; set; } = ReturnType.Default;
}
WithLayout()
This method provides you fine grained control over the placement of the control by allowing you to specify the Grid.Row
, Grid.Column
, Grid.RowSpan
, and Grid.ColumnSpan
properties.
WithValidationMode()
This method allows you ability to control whether the field is validated when the underlying property value is changed or if validation must be manually invoked. Valid values are:
namespace Maui.DataForms.FormFields;
public enum ValidationMode
{
Auto = 0,
Manual = 1
}
Styling
Since styling is something unique to every application and can vary greatly across controls, Maui.DataForms
doesn't provide any options to provide styling and instead encourages developers to use styles set in the application resource dictionary.
Dynamic Forms
In addition to the Maui.DataForms.Fluent
API which is great for creating strongly typed forms from model classes, the Maui.DataForms.Dynamic
project allows for creating form definitions directly or via JSON and then dynamically rendering the form at runtime. This can be useful in situations where forms need to vary based on certain criteria or you want to dynamically define a form server-side.
DynamicDemoPageViewModel.cs
The DynamicDemoPageViewModel
illustrates how to dynamically define a DynamicDataForm
which is deserialized from JSON embedded in the view model. This JSON could have just as easily been a response from an API call:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Maui.DataForms.Models;
using System.Text.Json;
namespace Maui.DataForms.Sample.ViewModels;
public partial class DynamicDemoPageViewModel : ObservableObject
{
[ObservableProperty]
private DynamicDataForm personDataForm;
public DynamicDemoPageViewModel()
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
options.Converters.Add(new SystemObjectNewtonsoftCompatibleConverter());
var dataFormDefinition = JsonSerializer.Deserialize<DataFormDefiniton>(json, options);
PersonDataForm = DynamicDataForm.Create(dataFormDefinition);
}
[RelayCommand]
private async Task Submit()
{
// no-op for now.
}
private const string json =
"""
{
"id": "personForm",
"name": "Person Form",
"etag": 1664738021,
"fields": [
{
"id": "firstName",
"name": "First Name",
"dataType": "string",
"controlTemplateName": "Entry",
"validationMode": 0,
"validationRules": [
{
"ruleName": "notEmpty",
"errorMessageFormat": "First Name must not be empty."
},
{
"ruleName": "maximumLength",
"ruleValue": 20,
"errorMessageFormat": "First Name must not be longer than {0} characters."
}
],
"configuration": {
"placeholder": "First Name"
},
"layout": {
"gridColumn": 0,
"gridRow": 0
}
},
{
"id": "lastName",
"name": "Last Name",
"dataType": "string",
"controlTemplateName": "Entry",
"validationMode": 0,
"validationRules": [
{
"ruleName": "notEmpty",
"errorMessageFormat": "Last Name must not be empty."
},
{
"ruleName": "maximumLength",
"ruleValue": 50,
"errorMessageFormat": "Last Name must not be longer than {0} characters."
}
],
"configuration": {
"placeholder": "Last Name"
},
"layout": {
"gridColumn": 0,
"gridRow": 1
}
},
{
"id": "dateOfBirth",
"name": "Date Of Birth",
"dataType": "DateTime",
"controlTemplateName": "DatePicker",
"validationMode": 0,
"validationRules": [
{
"ruleName": "greaterThanOrEqual",
"ruleValue": "2000-01-01T00:00:00",
"errorMessageFormat": "Date Of Birth must not be greater than or equal to {0}."
},
{
"ruleName": "lessThanOrEqual",
"ruleValue": "2021-12-31T23:59:59",
"errorMessageFormat": "Date Of Birth must not be less than or equal to {0}."
}
],
"configuration": {
"format": "D",
"minimumDate": "0001-01-01T00:00:00",
"maximumDate": "9999-12-31T23:59:59"
},
"layout": {
"gridColumn": 0,
"gridRow": 2
}
},
{
"id": "timeOfBirth",
"name": "Time Of Birth",
"dataType": "TimeSpan",
"controlTemplateName": "TimePicker",
"validationMode": 0,
"configuration": {
"format": "t"
},
"layout": {
"gridColumn": 0,
"gridRow": 3
}
},
{
"id": "biography",
"name": "Biography",
"dataType": "string",
"controlTemplateName": "Editor",
"validationMode": 0,
"validationRules": [
{
"ruleName": "notEmpty",
"errorMessageFormat": "Biography must not be empty."
},
{
"ruleName": "maximumLength",
"ruleValue": 500,
"errorMessageFormat": "Biography must not be longer than {0} characters."
}
],
"configuration": {
"placeholder": "Biography"
},
"layout": {
"gridColumn": 0,
"gridRow": 4
}
},
{
"id": "height",
"name": "Height",
"dataType": "double",
"controlTemplateName": "Slider",
"validationMode": 0,
"validationRules": [
{
"ruleName": "greaterThan",
"ruleValue": 0.2,
"errorMessageFormat": "Height must not be greater than {0}."
},
{
"ruleName": "lessThanOrEqual",
"ruleValue": 0.8,
"errorMessageFormat": "Height must be less than or equal to {0}."
}
],
"configuration": {
"minimum": 0.1,
"maximum": 0.9
},
"layout": {
"gridColumn": 0,
"gridRow": 5
}
},
{
"id": "weight",
"name": "Weight",
"dataType": "double",
"controlTemplateName": "Stepper",
"validationMode": 0,
"validationRules": [
{
"ruleName": "greaterThan",
"ruleValue": 20.0,
"errorMessageFormat": "Weight must not be greater than {0}."
},
{
"ruleName": "lessThanOrEqual",
"ruleValue": 80.0,
"errorMessageFormat": "Weight must be less than or equal to {0}."
}
],
"configuration": {
"minimum": 10.0,
"maximum": 90.0
},
"layout": {
"gridColumn": 0,
"gridRow": 6
}
},
{
"id": "likesPizza",
"name": "Likes Pizza",
"dataType": "bool",
"controlTemplateName": "Switch",
"validationMode": 0,
"layout": {
"gridColumn": 0,
"gridRow": 7
}
},
{
"id": "isActive",
"name": "Is Active",
"dataType": "bool",
"controlTemplateName": "CheckBox",
"validationMode": 0,
"layout": {
"gridColumn": 0,
"gridRow": 8
}
}
]
}
""";
}
Dynamic Form Validation
Since the form is dynamically generated, it is now easily possible to use a validation libray like FluentValidation
to do the validation. Therefore, the Maui.DataForms.Dynamic
project defines a handful of built-in validation rules. This library will expand and beome more robust over time. It will also be possible to define your own custom validation rules.
Customization
As previously mentioned, every application is different and therefore data entry forms may require input controls beyond those built into .NET MAUI. For this reason, Maui.DataForms
allows custom FormFields to be defined.