/Maui.DataForms

This is a proof of concept library 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.FluentForms project to use.

Primary LanguageC#MIT LicenseMIT

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 the FormField 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.