Skip to content

Fluent API support #168

@mattleibow

Description

@mattleibow

Issue moved from dotnet/maui#12380


From @idexus on Monday, January 2, 2023 8:09:40 PM

Discussed in dotnet/maui#12273

Description

I would like to have fluent API support in MAUI so first I created a library https://github.com/idexus/Sharp.UI then because some classes are sealed, I decided to create a fork https://github.com/idexus/maui to fully support this feature in the MAUI project. Most code is generated using source generators.

I have two questions

  • Is it possible to include fluent API support directly in the maui project?
  • If there is no way to add such support to the maui project for now, is there a way to "unseal" the maui classes?

I think for many it would make it much easier to create an interface without the need for XAML, without disabling the possibility of using it.

My goal was:

new ScrollView
{
    new VerticalStackLayout
    {
        new Grid
        {
            new Label("Hello")
                .Column(0)
                .FontSize(28),
            
            new Label("World", out var label)
                .Column(1),

            new Button("Click me")
                .Row(1)
                .ColumnSpan(2)
                .OnClicked(button => {
                    label.Text = "you";
                });
        }
        .ColumnDefinitions(e => e.Star(2).Star(1))
        .RowDefinitions(e => e.Star().Absolute(100)),
            
        new Path
        {
            new GeometryGroup
            {
                new PathGeometry
                {
                    new PathFigure(15, 50)
                    {
                        new LineSegment(800,150),
                        new LineSegment(500,50)
                    }
                },

                new EllipseGeometry()
                    .Center(new Point(50,70))
                    .RadiusX(10)
                    .RadiusY(50),
            }
        }
        .Stroke(Colors.Yellow)
        .Fill(Colors.Red),
    }
}

Unfortunately some classes are sealed, e.g.

PathGeometry, PathFigure, TapGestureRecognizer, PinchGestureRecognizer, PointerGestureRecognizer, SwipeGestureRecognizer, Ellipse, Polyline, Line, Path, Polygon, Rectangle, RoundRectangle, TableSection, ColumnDefinition, RowDefinition Style, Trigger,... etc.

If I want to create a library with these classes so that I could create the UI declaratively as I described, only in code using fluent interface, I can't do it without wrapping them.

Detailed description

Below is a description of how each problem was solved.

Properties

Fluent extension methods are generated for all properties and bindable properties (for all derived classes of BindableObject, and for Style, VisualState, VisualStateGroup, VisualStateGroupList classes)

Usage:

Label()
  .FontSize(28)
  .TextColor(Colors.White)

Generated methods for FontSize property:

public static T FontSize<T>(this T obj,
    double fontSize)
    where T : Microsoft.Maui.Controls.Label
{
    obj.FontSize = fontSize;
    return obj;
}

public static T FontSize<T>(this T obj,
    System.Func<ValueBuilder<double>, ValueBuilder<double>> buidValue)
    where T : Microsoft.Maui.Controls.Label
{
    var builder = buidValue(new ValueBuilder<double>());
    if (builder.ValueIsSet()) obj.FontSize = builder.GetValue();
    return obj;
}

public static T FontSize<T>(this T obj,
    System.Func<LazyValueBuilder<double>, LazyValueBuilder<double>> buidValue)
    where T : Microsoft.Maui.Controls.Label
{
    var builder = buidValue(new LazyValueBuilder<double>());
    if (builder.ValueIsSet()) obj.FontSize = builder.GetValue();
    return obj;
}

public static T FontSize<T>(this T obj,
    System.Func<BindingBuilder<double>, BindingBuilder<double>> buidBinding)
    where T : Microsoft.Maui.Controls.Label
{
    var builder = buidBinding(new BindingBuilder<double>(obj, Microsoft.Maui.Controls.Label.FontSizeProperty));
    builder.BindProperty();
    return obj;
}

EventHandler

Fluent extension methods are generated for each EventHandler adding an On prefix

usage example:

new VerticalStackLayout
{
    new Label("Hello"),
    new Button("Click me")
        OnClicked(button => {

        });
}

generated methods for Clicked event handler:

public static T OnClicked<T>(this T obj, System.EventHandler handler)
    where T : Microsoft.Maui.Controls.Button
{
    obj.Clicked += handler;
    return obj;
}

public static T OnClicked<T>(this T obj, System.Action<T> action)
    where T : Microsoft.Maui.Controls.Button
{
    obj.Clicked += (o, arg) => action(obj);
    return obj;
}

ContentProperty attributes

Implementation of IList (if not implemented yet) or IEnumerable interfaces to add Add() method support to class are generated for all classes with the ContentProperty attribute.

Example usage:

new Border
{
    new VerticalStackLayout
    {
        new Label("Hello").FontSize(28),
        new Grid
        {
            ...
        },
        new HorizontalStackLayout
        {
            new Label("foo"),
            new Button("OK").OnClicked(OnClickedButton)
        }

    }
}

Generated class implementation for single item containers:

public partial class Border : IEnumerable
{

    ...

    // ----- single item container -----

    public IEnumerator GetEnumerator() { yield return this.Content; }

    public void Add(Microsoft.Maui.Controls.View? content) => this.Content = content;

}

Generated class implementation for collection containers:

public partial class Shell : IList<Microsoft.Maui.Controls.ShellItem>
{

    ...

    // ----- collection container -----

    public int Count => this.Items.Count;
    public Microsoft.Maui.Controls.ShellItem this[int index] { get => this.Items[index]; set => this.Items[index] = value; }
    public bool IsReadOnly => false;
    public void Add(Microsoft.Maui.Controls.ShellItem item) => this.Items.Add(item);
    public void Clear() => this.Items.Clear();
    public bool Contains(Microsoft.Maui.Controls.ShellItem item) => this.Items.Contains(item);
    public void CopyTo(Microsoft.Maui.Controls.ShellItem[] array, int arrayIndex) => this.Items.CopyTo(array, arrayIndex);
    public IEnumerator<Microsoft.Maui.Controls.ShellItem> GetEnumerator() => this.Items.GetEnumerator();
    public int IndexOf(Microsoft.Maui.Controls.ShellItem item) => this.Items.IndexOf(item);
    public void Insert(int index, Microsoft.Maui.Controls.ShellItem item) => this.Items.Insert(index, item);
    public bool Remove(Microsoft.Maui.Controls.ShellItem item) => this.Items.Remove(item);
    public void RemoveAt(int index) => this.Items.RemoveAt(index);
    IEnumerator IEnumerable.GetEnumerator() => this.Items.GetEnumerator();

}

Constructors

Aditional constructors

Additional constructors have been added to simplify the interface creation process

example usage:

new VerticalStackLayout
{
    new Label("Hello"),
    new Button("Click me")
        OnClicked(button => {

        });
}

implementation

public partial class Label
{
    public Label(string text) : this()
    {
        this.Text = text;
    }
}

Additional out parameter

Additional constructors with the out parameter are generated for all public constructors.

usage:

new VerticalStackLayout
{
    new Label("Hello", out var label)
    new Button("Click me")
        OnClicked(button => {
            label.Text = "Clicked";
        });
}

genetated constructor:

public partial class Label : IEnumerable
{

    // ----- constructors -----

    public Label(out Label label) : this()
    {
        label = this;
    }

    public Label(string text, out Label label) : this(text)
    {
        label = this;
    }

    ...
}

In-line value builders

BindingBuilder

For in-line creation of bindings

usage:

new VerticalStackLayout
{
    new Slider(1, 100, 1, out var slider),
    new Label()
        .Text(e => e.Path("Value").Source(slider).StringFormat("Value {0:F1}"))
}

ValueBuilder

To set values depending on the app theme, platform or device idiom

new Label("Hello")
    .TextColor(e => e.OnDark(Colors.White).OnLight(Colors.Black))
    .FontSize(e => e.Default(28).OnPhone(18).OnTV(40))

LazyValueBuilder

To lazily set values depending on the app theme, platform or device idiom

new Label()
    .Text(e => e
        .Default(() => DefaultName())
        .OnAndroid(() => AndroidName())
    )

ColumnDefinitionBuilder and RowDefinitionBuilder

To define the number and sizes of rows and columns.

example:

new Grid
{
    ...
}
.RowDefinitions(e => e.Star(2).Star().Star(3))
.ColumnDefinitions(e => e.Absolute(100).Star());

In this example you define

  • 3 rows - Star(2), Star(), Star(3)
  • 2 columns - Absolute(100), Star()

Style

BindableProperty => Setter

Button.BackgroundColorProperty.Set(Colors.White),

Button.TextColorProperty.Set().OnLight(Colors.White).OnDark(AppColors.Primary),

implementation

public static class BindablePropertyExtension
{
    public static Setter Set(this BindableProperty property, object value) => new Setter { Property = property, Value = value };
    public static Setter Set(this BindableProperty property) => new Setter { Property = property };		
}

public static class SetterExtension
{
    public static Setter OnLight(this Setter setter, object value) { if (Application.Current?.RequestedTheme == AppTheme.Light) setter.Value = value; return setter; }
    public static Setter OnDark(this Setter setter, object value) { if (Application.Current?.RequestedTheme == AppTheme.Dark) setter.Value = value; return setter; }
    ...
}

IList Add methods

Additional Add() methods for the IList interface

public partial class Style
{
    public void Add(Setter setter) {...}
    public void Add(Trigger trigger) {...}
    public void Add(DataTrigger trigger) {...}
    public void Add(VisualStateGroupList groupList) {...}
    public void Add(VisualStateGroup group) {...}
    public void Add(VisualState visualState) {...}
}

Usage example

new Style(typeof(Button))
{
    Button.TextColorProperty.Set().OnLight(Colors.White).OnDark(AppColors.Primary),
    Button.BackgroundColorProperty.Set().OnLight(AppColors.Primary).OnDark(Colors.White),
    Button.FontSizeProperty.Set(14).OnDesktop(20),
    Button.CornerRadiusProperty.Set(8).OniOS(15),
    new VisualState(VisualState.VisualElement.Normal)
    {
        Button.TextColorProperty.Set().OnLight(Colors.White).OnDark(AppColors.Primary),
        Button.BackgroundColorProperty.Set().OnLight(AppColors.Primary).OnDark(Colors.White),
    },
    new VisualState(VisualState.VisualElement.Disabled)
    {
        Button.TextColorProperty.Set().OnLight(AppColors.Gray950).OnDark(AppColors.Gray200),
        Button.BackgroundColorProperty.Set().OnLight(AppColors.Gray200).OnDark(AppColors.Gray600),
    },
},

User defined classes

[FluentInterface] attribute

Fluent methods will be generated for user definied classes with the [FluentInterface] attribute

[BindableProperties] attribute

Bindable properties and fluent methods will be generated for user definied classes with interfaces with the [BindableProperties] attribute

Additional atributes:
  • [DefaultValue] to define default values
  • [PropertyCallbacks(propertyChanged, propertyChanging,validateValue, coerceValue, defaultValueCreator] to define callback names
Example
[BindableProperties]
public interface IAngleViewModelProperties
{
    [PropertyCallbacks(propertyChanged: "OnAngleChanged")]
    public double RawAngle { get; set; }

    [PropertyCallbacks(coerceValue: "CoerceAngle")]
    public double Angle { get; set; }

    [DefaultValue(360.0)]
    public double MaximumAngle { get; set; }
}


[FluentInterface]
public partial class AngleViewModel : BindableObject, IAngleViewModelProperties
{
    static void OnAngleChanged(BindableObject bindable, object oldValue, object newValue)
    {
        var viewModel = bindable as AngleViewModel;
        viewModel.Angle = (double)newValue;
    }

    static object CoerceAngle(BindableObject bindable, object value)
    {
        var viewModel = bindable as AngleViewModel;
        double input = (double)value;

        if (input > viewModel.MaximumAngle)
            input = viewModel.MaximumAngle;

        return input;
    }
}

Custom user view example

[BindableProperties]
public interface ICardViewProperties
{
    string CardTitle { get; set; }
    string CardDescription { get; set; }
    Color CardColor { get; set; }
    Color BorderColor { get; set; }
    Style DescriptionStyle { get; set; }
    View ContentView { get; set; }
    string ButtonTitle { get; set; }
}

[FluentInterface]
[ContentProperty(nameof(ContentView))]
public partial class CardView : ContentView, ICardViewProperties
{
    public event EventHandler Clicked;

    public CardView()
    {
        this.BindingContext = this;
        Content = new Border
        {
            new Grid
            {
                new VerticalStackLayout
                {
                    new Label(out var label1)
                        .Text(e => e.Path(nameof(CardTitle)))
                        .FontSize(29)
                        .TextColor(Colors.White),

                    new Label()
                        .Text(e => e.Path(nameof(CardDescription)))
                        .Style(e => e.Path(nameof(DescriptionStyle))),
                },

                new ContentView()
                    .Row(1)
                    .Content(e => e.Path(nameof(ContentView)))
                    .HorizontalOptions(LayoutOptions.Center)
                    .VerticalOptions(LayoutOptions.Center)
                    .SizeRequest(120,120),

                new Button()
                    .Row(2)
                    .Text(e => e.Path(nameof(ButtonTitle)))
                    .BackgroundColor(Colors.LightGray)
                    .TextColor(Colors.Black)
                    .OnClicked((sender, e) => Clicked(sender,e))
            }
            .RowDefinitions(e => e.Star(1.0).Star(2.0).Star(0.7))
            .RowSpacing(10)
        }
        .StrokeShape(new RoundRectangle().CornerRadius(10))
        .Stroke(e => e.Path(nameof(BorderColor)))
        .BackgroundColor(e => e.Path(nameof(CardColor)))
        .SizeRequest(200, 300)
        .Margin(50)
        .Padding(20);
    }
}

public partial class CardViewPage : ContentPage
{
    public CardViewPage()
    {
        this.Content = new VerticalStackLayout
        {
            new Slider(1, 100, 1, out var slider),

            new HorizontalStackLayout
            {
                new CardView(out var cardNo1)
                {
                    new Image("dotnet_bot.png").Aspect(Aspect.AspectFit)
                }
                .CardTitle(e => e.Path("Value").Source(slider).StringFormat("Value {0:F1}"))
                .ButtonTitle("Play")
                .CardDescription("Do you like it")
                .CardColor(Colors.DarkSlateGrey)
                .BorderColor(Colors.DarkGrey)
                .OnClicked(e =>
                {
                    cardNo1.CardDescription = "Let's play :)";
                }),

                new CardView(out var cardView)
                {
                    new VerticalStackLayout
                    {
                        new Label("This is a simple card view example"),
                        new Label("Second label")
                            .TextColor(Colors.Red)
                            .FontSize(18)
                    }					
                }
                .CardTitle("Title 2")
                .ButtonTitle("Stop")
                .CardColor(Colors.DarkSlateGrey)
                .BorderColor(Colors.DarkGrey)
                .DescriptionStyle(new Style(typeof(Label))
                {
                    Label.TextColorProperty.Set(Colors.Blue),
                    Label.FontSizeProperty.Set(20)
                })
            }
            .HorizontalOptions(LayoutOptions.Center)
        }
        .VerticalOptions(LayoutOptions.Center)
        .Padding(100);
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions