Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplification of user interface declaration in code #12728

Closed
wants to merge 5 commits into from

Conversation

idexus
Copy link

@idexus idexus commented Jan 17, 2023

Description of Change

It adds the ability to declare content inside curly braces.

This PR:

  • implements the IEnumerable interface for classes that do not already have the IList interface implemented and have the [ContentProperty] attribute.
  • adds a sample page - FluentExamplePage.cs with sample extension methods (Maui.Controls.Sample/Pages/Fluent) and places it in the "Others" section of the sample application
  • implements unit tests in ContentPropertyUnitTest.cs file to test the content property attribute and implementation of the IEnumerable interface
  • adds missing content property attributes for classes
[ContentProperty(nameof(Points))]
public sealed partial class Polyline { ... }

[ContentProperty(nameof(Data))]
public sealed partial class Path { ... }

[ContentProperty(nameof(Points))]
public sealed partial class Polygon { ... }

[ContentProperty(nameof(Content))]
public partial class RadioButton { ... }

[ContentProperty(nameof(Setters))]
public sealed class VisualState { ... }

Usage example:

sample extension methods are declared in the Maui.Controls.Sample application folder /Pages/Fluent/Extensions

new ScrollView 
{
    new VerticalStackLayout
    {
        new Label()
            .Text("Fluent API Page")
            .FontSize(50)
            .TextColor(ExampleStyleResources.PrimaryColor)
            .HorizontalOptions(LayoutOptions.Center),

        new Border
        {
            new Grid
            {
                new Label()
                    .Text("I'm .NET Bot")
                    .TextColor(Colors.LightGray)
                    .FontSize(40)
                    .HorizontalOptions(LayoutOptions.Center)
                    .VerticalOptions(LayoutOptions.Center),

                new Image()
                    .Row(1)
                    .Source("dotnet_bot.png"),

                new Label()
                    .Text("Hello, World!")
                    .Row(2)
                    .TextColor(Colors.LightGray)
                    .FontSize(30)
                    .Margin(new Thickness(10))
                    .HorizontalOptions(LayoutOptions.Center),

                new Button()
                    .Text("Click me")
                    .Row(3)
                    .WidthRequest(220)
                    .HorizontalOptions(LayoutOptions.Center)
                    .Margin(new Thickness(40))
                    .OnClicked(button =>
                    {
                        count++;
                        button.Text = $"Clicked {count} ";
                        button.Text += count == 1 ? "time" : "times";
                    })

            }
            .RowDefinitions(e => e.Star().Star(2).Auto().Auto())
        }
        .StrokeShape(new RoundRectangle().CornerRadius(30))
        .BackgroundColor(Colors.DarkSlateGrey)
    }
}

Issues Fixed

Fixes #12678

@ghost ghost added the community ✨ Community Contribution label Jan 17, 2023
@ghost
Copy link

ghost commented Jan 17, 2023

Hey there @idexus! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

public partial class Label : View, IFontElement, ITextElement, ITextAlignmentElement, ILineHeightElement, IElementConfiguration<Label>, IDecorableTextElement, IPaddingElement
public partial class Label : View, IFontElement, ITextElement, ITextAlignmentElement, ILineHeightElement, IElementConfiguration<Label>, IDecorableTextElement, IPaddingElement, IEnumerable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure Label (and similar) are useful? Maybe these "primitive content" ones don't need this? And maybe Span too?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be useful when you are writing very long text

new Label
{
	$"Lorem Ipsum is simply dummy text of the printing and typesetting " +
	$"industry. Lorem Ipsum has been the industry's standard dummy text ever " +
	$"since the 1500s, when an unknown printer took a galley of type and scrambled " +
	$"it to make a type specimen book. It has survived not only five centuries, " +
	$"but also the leap into electronic typesetting, remaining essentially " +
	$"unchanged. It was popularised in the 1960s with the release of Letraset " +
	$"sheets containing Lorem Ipsum passages, and more recently with desktop " +
	$"publishing software like Aldus PageMaker including versions of Lorem Ipsum."
},

@mattleibow
Copy link
Member

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 2 pipeline(s).

@mattleibow
Copy link
Member

mattleibow commented Jan 20, 2023

Thanks for the PR. Still going over this and will need some discussion. I think generally it is a sound addition but not sure what the team thinks. Also, we may wish to make sure the various community toolkit peoples get a say so we don't add something that gets in the way of existing things.

This is a personal thing, but I see that the view is constructed with the content first and then the properties:

var border = new Border
{
    new Button()
    .Text("Hello")
}
.BorderColor(Red);

Once the views get deep, the properties may get lost. Is there way to get more fluent without separating properties and instances?

var border = new Border()
    .BorderColor(Red)
    .Content(
        new Button()
        .Text("Hello")
    )

This is just preference, but what is the advantage of having the initializers and properties after as opposed to an alternate?

@idexus
Copy link
Author

idexus commented Jan 20, 2023

When using the IDE, vertical lines help you when using curly braces, but not when using round braces, and honestly with more complex ones it gets unreadable.

Screenshot 2023-01-20 at 01 16 24

vs

Screenshot 2023-01-20 at 01 25 47

@idexus
Copy link
Author

idexus commented Jan 20, 2023

The solution for this could be add constructors

public Border(System.Action<Border> configure) 
{
    configure(this);
}

then you could

Screenshot 2023-01-20 at 01 33 53

@idexus
Copy link
Author

idexus commented Jan 20, 2023

I did something like this in my library with extra constructors for easy inline assignment

public Slider(out Slider slider) 
{
    slider = this;
}

then you can do:

this.Content = new VerticalStackLayout
{
    new Slider(out var slider)
        .Minimum(1)
        .Maximum(20),

    new Label()
        .Text(e => e.Path("Value").Source(slider).StringFormat("Value: {0}"))
        .FontSize(28)
        .TextColor(Colors.Blue)
};

@idexus
Copy link
Author

idexus commented Jan 20, 2023

If there is such a decision, I can also prepare a PR with the constructors.

@jfversluis
Copy link
Member

@brminnick this seems something in your area of expertise/liking. Any thoughts?

@hartez hartez changed the title Simplification of interface declaration in code Simplification of user interface declaration in code Jan 20, 2023
@TheCodeTraveler
Copy link
Contributor

TheCodeTraveler commented Jan 22, 2023

@brminnick this seems something in your area of expertise/liking. Any thoughts?

Thanks for looping me in! I think this is a great first step for improving the out-of-the-box experience for devs for prefer to use C# (no XAML) for creating .NET MAUI UIs 💯

Not related to this PR, but I'd it if we also overloaded the constructors too to allow devs to set any Property in-line 👇

Overloaded Constructors with Default Parameters

Overloaded Constructors with Default Parameters

Add a constructor accepting every Property as a default/optional parameter.

Implementation

Since every control is already a partial class, I recommend using source generators to create a constructor that accepts every Property as a default parameter

  • Use Source Generators to create a new Constructor that accepts every Property as a default parameter
  • In the constructor, assign the Properties from their appropriate parameters

Source Generator Output

namespace Microsoft.Maui.Controls;

public partial class Button
{
  public Button(Thickness padding = default, 
                  LineBreakMode lineBreakMode = default,
                  Thickness margin = default
                  // continue adding a parameter for every Property of `Button`, including the properties inherited by `View`
                  )
  {
    this.Padding = padding;
    this.LineBreakMode = lineBreakMode;
    this.Margin = margin;
    // continuing assigning every parameter to its Property 
  }
}

Example Usage

Content = new Button(text = "Hello World", textColor = Colors.Green);

@idexus
Copy link
Author

idexus commented Jan 22, 2023

@brminnick Thanks for your support on this topic 💯 :) You said it right, need better out-of-the-box support directly in the maui project for people who are far from XAML. Creating an interface in code gives you much more possibilities, and with some improvements, sometimes cosmetic, it can become the number one choice for creating applications in MAUI.

As for your proposal and overloading the constructors with all properties with default values, it will create some performance problem, because if you want to set one or two properties, all will be assigned (for only Button and View -> there are 21 bindable properties). Here, it would be better to have compiler support, in which it would be possible to write extension methods omitting e => e

instead of

new Border(e => e
    .StrokeShape(new RoundRectangle().CornerRadius(10))
    .Stroke(e => e.Path(nameof(BorderColor)))  // <= using parameters you can't do this
    .BackgroundColor(e => e.Path(nameof(CardColor)))
    .SizeRequest(220, 350)                     // <= using parameters you can't do that either
    .Margin(50)
    .Padding(30)) 
{
    new Slider(out var slider)
        .Minimum(1)
        .Maximum(20),

    new Label()
        .Text(e => e.Path("Value").Source(slider).StringFormat("Value: {0}"))
        .FontSize(28)
        .TextColor(Colors.Blue)
}

you could write (but it's a song of the future ;)

new Border(
    .StrokeShape(new RoundRectangle().CornerRadius(10))
    .Stroke(.Path(nameof(BorderColor)))          
    .BackgroundColor(.Path(nameof(CardColor)))
    .SizeRequest(220, 350)                      
    .Margin(50)
    .Padding(30)) 
{
    new Slider(out var slider)
        .Minimum(1)
        .Maximum(20),

    new Label()
        .Text(.Path("Value").Source(slider).StringFormat("Value: {0}"))
        .FontSize(28)
        .TextColor(Colors.Blue)
}

Anyway, I think adding constructors that can use extension methods has more power (like in LINQ), and many of ext. methods are in your CommuityToolkit Markup now. If you add the possibility of in-line property binding to this, creating a UI in the code will become very user-friendly.

@idexus
Copy link
Author

idexus commented Jan 24, 2023

@mattleibow This approach will also make it easier to build user interface with HotReload support for the MVVM pattern, only in the code.

This is an example from my library which is a wrapper on top of yours

Hot Reload Support

@idexus
Copy link
Author

idexus commented Feb 8, 2023

@mattleibow @jfversluis Are there any decisions regarding this PR?

@mattleibow
Copy link
Member

@brminnick @VincentH-Net any thoughts either way? Not sure if @PureWeen has thoughts after doing fabulous things...

I'll give the code a re-review and see what changed. But I think this as the concept is good to merge.

All I need is a few approvals from other code based things so we don't break them.

@VincentH-Net
Copy link
Contributor

VincentH-Net commented Feb 9, 2023

@brminnick @VincentH-Net any thoughts either way?

Thanks for involving me - I'm glad to see MAUI improvements for developers using C# for markup!

My 2cts on above discussion:

  • Imo the ability to declare content inside curly braces is a step in the right direction. In C# Markup 2, this is achieved by making child controls params of a parent control constructor, so it uses parentheses (since C# Markup 2 eliminates the need for new, it cannot use the curly braces approach). Curly braces have the (slight) benefit over parentheses of having guidelines and being collapsible in the editor without requiring an editor extension. Note that given the next point this is not a big plus, since functions have guidelines as well.

  • Children being declared before properties in a parent is in my experience not an issue in real world applications: users of C# markup tend to break up the markup into smaller functions long before it gets unwieldy (so it reads like a story, top-down), E.g.

    StackPanel Tweet() => VStack(
        TweetHeader(),
        TweetBody(),
        TweetFooter()
    );

    In general, devs tend to structure C# markup more finely than equivalent XAML because of the ease and many options of C# to do so. Another difference is styles - they tend to be used less because C# has more powerful & dev friendly alternatives for reuse.

  • Not for this PR, but on adding constructors that take property values (I assume with codegen?):

    • There is no inheritance, so you would have to duplicate that code for every property in all ancestors, leading to a many times bigger API implementation size. The bulk of properties is defined in base classes.
    • C# Markup 2 only generates ctor parameters for the properties defined in the class itself, not for inherited properties (unless codegen parameters instruct to do that for specific base classes or parameters). In addition it defines convenience ctor overloads, with only the most commonly used properties. This corresponds to SwiftUI concept of important properties. Finally, only parameters that have a non-default value are assigned, which reduces the performance impact.

@idexus
Copy link
Author

idexus commented Feb 10, 2023

@mattleibow @PureWeen @hartez @jfversluis @davidortinau

To convince you, in XAML such a solution is rather not possible, in the code it is. Example from my lib:

calc_code

using

public static void Add<T>(this T list, Action<IList<IView>> itemsBuilder)
    where T : IList<IView>
{
    List<IView> items = new List<IView>();
    itemsBuilder(items);
    foreach (var item in items)
        list.Add(item);
}

you get this:

calc

@idexus
Copy link
Author

idexus commented Feb 16, 2023

@mattleibow @PureWeen @hartez @jfversluis @davidortinau

Are there any decisions regarding the addition of the IEnumerable interface to classes in the next release?

Other examples of use

Using IEnumerable interface, there is also no need to create constructors to put properties at the top, and more.

You can implement extension Add methods like these:

public static void Add<T>(this T obj, Func<T, T> configure)
    where T : IEnumerable
{
    configure(obj);
}

public static void Add<T>(this T layout, IEnumerable<View> items)
    where T : Layout
{
    foreach (var item in items)
        layout.Children.Add(item);
}

and you get

  • properties declaration inside the body
  • even support for LINQ inside the body
public class KeypadPage : ContentPage
{
    string[] labels = new[] { "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "0", "#" };

    public KeypadPage(KeypadViewModel vm)
    {		
        BindingContext = vm;

        Content = new Grid
        {
            // ---- properties ----

            e => e
                .RowDefinitions(e => e.Auto(count: 5))
                .ColumnDefinitions(e => e.Absolute(100, count: 3))
                .HorizontalOptions(LayoutOptions.Center)
                .VerticalOptions(LayoutOptions.Center)
                .ColumnSpacing(10)
                .RowSpacing(10),

            // ---- content here ----

            new Label()
                .ColumnSpan(2)
                .Text(e => e.Path("DisplayText"))
                .LineBreakMode(LineBreakMode.HeadTruncation)
                .VerticalTextAlignment(TextAlignment.Center)
                .HorizontalTextAlignment(TextAlignment.End)
                .Margin(new Thickness(0,0,10,0)),

            new Button("\x21E6").Command(vm.DeleteCharCommand).Column(2),

            // using LINQ inside

            labels.Select((label, i) =>
                new Button(label)
                    .Row(i/3+1).Column(i%3)
                    .Command(vm.AddCharCommand).CommandParameter(label))
        };
    }
}

You can compare this to the XAML code:

Equivalent of code in XAML

from:

https://learn.microsoft.com/en-us/dotnet/maui/xaml/fundamentals/mvvm?view=net-maui-7.0

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples"
             x:Class="XamlSamples.KeypadPage"
             Title="Keypad Page">
    <ContentPage.BindingContext>
        <local:KeypadViewModel />
    </ContentPage.BindingContext>

    <Grid HorizontalOptions="Center" VerticalOptions="Center">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="80" />
            <ColumnDefinition Width="80" />
            <ColumnDefinition Width="80" />
        </Grid.ColumnDefinitions>

        <Label Text="{Binding DisplayText}"
               Margin="0,0,10,0" FontSize="20" LineBreakMode="HeadTruncation"
               VerticalTextAlignment="Center" HorizontalTextAlignment="End"
               Grid.ColumnSpan="2" />

        <Button Text="&#x21E6;" Command="{Binding DeleteCharCommand}" Grid.Column="2"/>

        <Button Text="1" Command="{Binding AddCharCommand}" CommandParameter="1" Grid.Row="1" />
        <Button Text="2" Command="{Binding AddCharCommand}" CommandParameter="2" Grid.Row="1" Grid.Column="1" />
        <Button Text="3" Command="{Binding AddCharCommand}" CommandParameter="3" Grid.Row="1" Grid.Column="2" />

        <Button Text="4" Command="{Binding AddCharCommand}" CommandParameter="4" Grid.Row="2" />
        <Button Text="5" Command="{Binding AddCharCommand}" CommandParameter="5" Grid.Row="2" Grid.Column="1" />
        <Button Text="6" Command="{Binding AddCharCommand}" CommandParameter="6" Grid.Row="2" Grid.Column="2" />

        <Button Text="7" Command="{Binding AddCharCommand}" CommandParameter="7" Grid.Row="3" />
        <Button Text="8" Command="{Binding AddCharCommand}" CommandParameter="8" Grid.Row="3" Grid.Column="1" />
        <Button Text="9" Command="{Binding AddCharCommand}" CommandParameter="9" Grid.Row="3" Grid.Column="2" />

        <Button Text="*" Command="{Binding AddCharCommand}" CommandParameter="*" Grid.Row="4" />
        <Button Text="0" Command="{Binding AddCharCommand}" CommandParameter="0" Grid.Row="4" Grid.Column="1" />
        <Button Text="#" Command="{Binding AddCharCommand}" CommandParameter="#" Grid.Row="4" Grid.Column="2" />
    </Grid>
</ContentPage>

@idexus
Copy link
Author

idexus commented Feb 20, 2023

@mattleibow

I've created a project, that shows what an interface declaration would look like in code. Classes such as Grid or VerticalStackLayout already implement IEnumerable, so I could use braces for them. The Border class, on the other hand, does not implement IEnumerable now and I could not use it for it.

I used my library for this but only imported the package with extension methods. It covers all properties of MAUI controls.

You can load an example:

git clone /~https://github.com/idexus/HotReloadTest.git -b onlyextensions

Please reply if there is any progress on this PR.

Example Code Screenshot 2023-02-20 at 21 41 48

@hartez
Copy link
Contributor

hartez commented Feb 21, 2023

I'll try to look at this later this week.

@idexus
Copy link
Author

idexus commented Feb 21, 2023

@hartez

I'll try to look at this later this week.

I'm looking forward to it :)

You can also use the same extension methods to build styles in a type-safe way (it's from my example)

Screenshot 2023-02-23 at 01 01 39

@hartez
Copy link
Contributor

hartez commented Feb 25, 2023

I've taken a look; letting this percolate a bit and I'll write up my thoughts on Monday.

@idexus
Copy link
Author

idexus commented Feb 27, 2023

@hartez you can also see my project page to better understand my approach

@hartez
Copy link
Contributor

hartez commented Feb 27, 2023

If I'm understanding this PR correctly, the whole point is to abuse the collection initializer syntax to avoid having to specify a property in the object initializer syntax. In order to do that, the PR marks several types which are not collections or things which could be considered enumerable as IEnumerable. It then implements the interface by returning a single arbitrary property as if it were an item in a collection (or, in some cases, by enumerating an arbitrary collection in the class).

And then it creates a set of extension methods named Add() which do not actually "add" an item (the generally understood purpose of an Add() method), but instead set a property.

So it's violating the defintion and spirit of IEnumerable and Add(), and it's doing so to shoehorn in an unconventional and potentially confusing use of a language feature in order to save some typing and make the code line up a particular way in the IDE.

This is a "no" right from the top. But to address some of the points brought up in the PR discussion:

This already works for things that are actual collections (e.g., the layouts), because that's the intent of the collection initializer syntax.

For things that aren't collections, like Label, it changes

new Label { Text = "text" };

to

new Label { "text" };

There's a comment about this being helpful for long text, but AFAICT the interface/extension method have no effect on that.

Removing this single property name doesn't strike me as a compelling reason to abuse the collection initializer syntax.

The stuff you mention in this comment is nifty, but you can already do that with layouts. Is there some compelling version of that example which requires Label or Button be IEnumerable?

There are some other comments and examples, but they don't seem to hinge on this PR. For example, in one comment you demonstrate procedurally generating a set of buttons. But this PR isn't required for that; I can already procedurally generate a UI in C# without any of the changes you're proposing. You point out that this isn't possible in XAML, but that's a given; XAML is a declarative UI language, so of course it can't do that.

You compare your code examples to XAML, or showing things that XAML can't do. But that's not the compelling case you have to make here. We can already build a UI without XAML; you would need to show why hacking collection initializers gives us a more compelling API in C#, which can already do all these things.

To be perfectly clear: I'm not advocating against folks building fluent extension APIs. I am specifically advocating against abusing language features for what amounts to a formatting change. That kind of feature abuse needs to come with a very compelling reason if I'm going to have to spend the next few years explaining to developers why Label.Add("text") replaces the the text of a Label rather than appending it.

@idexus
Copy link
Author

idexus commented Feb 28, 2023

@hartez first of all, thank you for your comprehensive answer

If I'm understanding this PR correctly, the whole point is to abuse the collection initializer syntax to avoid having to specify a property in the object initializer syntax. In order to do that, the PR marks several types which are not collections or things which could be considered enumerable as IEnumerable. It then implements the interface by returning a single arbitrary property as if it were an item in a collection (or, in some cases, by enumerating an arbitrary collection in the class).

Generally speaking, it's about adding the IEnermerable interface for classes that have the [ContentProperty] attribute, and thus are containers, and logically speaking, you can put something in them, and in my understanding to give the possibility to use the Add method then. From a logical point of view, it can be a container that can hold many things, but there can also be a container that can hold one thing, in short, you can "add" one thing to it, and that was the original meaning of this PR.

And then it creates a set of extension methods named Add() which do not actually "add" an item (the generally understood purpose of an Add() method), but instead set a property.

This PR is not intended to add Add methods to the library, it is only to give the possibility of treating classes as containers for "things", and the sense of use would depend on the application creator or dependent library, but it gives such a possibility.

So it's violating the defintion and spirit of IEnumerable and Add(), and it's doing so to shoehorn in an unconventional and potentially confusing use of a language feature in order to save some typing and make the code line up a particular way in the IDE.

This is a "no" right from the top. But to address some of the points brought up in the PR discussion:

I don't quite agree, as I said, since the classes already have the [ContentProperty] attribute in your library they are defined to be containers right now. Let's take "ScrollView" for example, yes, you can insert one view into it. But also asking how many objects are in it, you can answer "1" and return it if necessary. Of course, I could give an example here that in XAML this approach was actually used by you, hence the [ContentProperty] attribute. So I don't understand the resistance why not translate this directly into C# syntax.

This already works for things that are actual collections (e.g., the layouts), because that's the intent of the collection initializer syntax.

For things that aren't collections, like Label, it changes

new Label { Text = "text" };

to

new Label { "text" };

There's a comment about this being helpful for long text, but AFAICT the interface/extension method have no effect on that.

Removing this single property name doesn't strike me as a compelling reason to abuse the collection initializer syntax.

And in this case, I can agree. For views such as Label, I don't see the need to implement the IEnumerable interface. I only did this to be consistent with your approach, as it has a [ContentProperty] attribute defined in your library. So my argument followed what is already implemented.

The stuff you mention in this comment is nifty, but you can already do that with layouts. Is there some compelling version of that example which requires Label or Button be IEnumerable?

This PR does not include adding an IEnumerable to a view like a Button. Its task is to add it only to multi-item and one-item containers. And the Label case I described above.

There are some other comments and examples, but they don't seem to hinge on this PR. For example, in one comment you demonstrate procedurally generating a set of buttons. But this PR isn't required for that; I can already procedurally generate a UI in C# without any of the changes you're proposing. You point out that this isn't possible in XAML, but that's a given; XAML is a declarative UI language, so of course it can't do that.

Yes, I have provided this example, and in fact I can already add a proper extension method for a type that implements IEnumerable, or just do it directly in the code somewhere else. My point here is only to show the consistency of my approach, and to enable the creation of the interface in a declarative way in the code, so that it reflects the structure of what will be displayed as closely as possible, which is an advantage in the case of XAML.

You compare your code examples to XAML, or showing things that XAML can't do. But that's not the compelling case you have to make here. We can already build a UI without XAML; you would need to show why hacking collection initializers gives us a more compelling API in C#, which can already do all these things.

Yes, I agree many of the things I'm talking about can already be done directly in the code, but what I'm talking about is creating a method that is consistent in its intention, and gives you the ability to create an interface in an intuitive way, giving it similarity to XAML, but also power of C#, while not departing from the MVVM model. This is what I am trying to get in my library.

To be perfectly clear: I'm not advocating against folks building fluent extension APIs. I am specifically advocating against abusing language features for what amounts to a formatting change. That kind of feature abuse needs to come with a very compelling reason if I'm going to have to spend the next few years explaining to developers why Label.Add("text") replaces the the text of a Label rather than appending it.

I've already explained the topic of Label, so I won't repeat myself :) As for overusing language syntax, sometimes you have to think differently to get ahead. And I wouldn't call it abuse, but using the syntax to achieve a goal :)

@idexus
Copy link
Author

idexus commented Feb 28, 2023

I will not compare this solution to XAML here, and I will not give such examples, because I see that this is a sensitive point. However, I will show a comparison between the two approaches in creating an interface directly in the C# code.

The following two examples create the same view. In the first, I assume, apart from adding IEnumerable, the existence of additional constructors, which were mentioned in the discussion.

Please judge for yourself.
Content = new ScrollView(e => e.BackgroundColor(Colors.Black))
{
    new VerticalStackLayout(out var vStack, e => e.VerticalOptions(LayoutOptions.Center))
    {
        new Label(out var label)
            .TextColor(e => e.DynamicResource("myColor"))
            .Text("Only in Code :)")
            .FontSize(45),

        new Slider(out var slider)
            .Minimum(1).Maximum(30)
            .WidthRequest(400)
            .Value(e => e.Path("SliderValue"))
            .Margin(50, 30)
            .OnValueChanged(slider => button.IsEnabled = slider.Value < 10),

        new Border(e => e
            .SizeRequest(270, 450)
            .BackgroundColor(AppColors.Gray950)
            .StrokeShape(new RoundRectangle().CornerRadius(40)))
        {
            new Grid(e => e.RowDefinitions(e => e.Star(1.3).Star(3).Star().Star()))
            {
                new Label()
                    .Text(e => e.Path("Value").Source(slider).StringFormat("Value : {0:F1}"))
                    .FontSize(40),

                new Image().Source("dotnet_bot.png").Row(1),

                new Label()
                    .Text("Hello, World!")
                    .Row(2)
                    .FontSize(30)
                    .TextColor(Colors.DarkGray),

                new Switch(out testSwitch).Row(3)
                    .CenterInContainer()
            },
        },

        new Button(out button)
            .Text("Click me")
            .Margin(30)
            .OnClicked(async (Button b) =>
            {
                count++;
                b.Text = $"Clicked {count} ";
                b.Text += count == 1 ? "time" : "times";

                await vStack.RotateYTo(((count % 4) switch { 0 => 0, 1 => 20, 2 => 0, _ => -20 }));
                await label.RotateTo(360 * (count % 2), 300);
            })
    }
};

VS:

// inside constructor 

var scrollView = new ScrollView();
scrollView.BackgroundColor = Colors.Black;

var vStack = new VerticalStackLayout();
vStack.VerticalOptions = LayoutOptions.Center;

var label = new Label
{
    Text = "Only in Code :)",
    FontSize = 45
};
label.SetDynamicResource(Label.TextColorProperty, "myColor");
        
var slider = new Slider
{
    Minimum = 1,
    Maximum = 30,
    WidthRequest = 400,
    Margin = new Thickness(50, 30)
};
slider.SetBinding(Slider.ValueProperty, "SliderValue");
slider.ValueChanged += (sender, e) =>
{
    button.IsEnabled = e.NewValue < 10;
};

var grid = new Grid
{
    RowDefinitions = new RowDefinitionCollection()
    {
        new RowDefinition() { Height = new GridLength(1.3, GridUnitType.Star) },
        new RowDefinition() { Height = new GridLength(3, GridUnitType.Star) },
        new RowDefinition() { Height = new GridLength(1, GridUnitType.Star) },
        new RowDefinition() { Height = new GridLength(1, GridUnitType.Star) }
    }
};

var valueLabel = new Label();
valueLabel.FontSize = 40;
valueLabel.SetBinding(Label.TextProperty, new Binding("Value", source: slider, stringFormat: "Value : {0:F1}"));
grid.Children.Add(valueLabel);

var image = new Image();
image.Source = "dotnet_bot.png";
Grid.SetRow(image, 1);
grid.Children.Add(image);

var helloLabel = new Label
{
    Text = "Hello, World!",
    FontSize = 30,
    TextColor = Colors.DarkGray
};
Grid.SetRow(helloLabel, 2);
grid.Children.Add(helloLabel);

var testSwitch = new Switch();
testSwitch.HorizontalOptions(LayoutOptions.Center);
Grid.SetRow(testSwitch, 3);
Grid.SetColumn(testSwitch, 0);
Grid.SetColumnSpan(testSwitch, 2);
grid.Children.Add(testSwitch);

var border = new Border 
{ 
    WidthRequest = 270, 
    HeightRequest = 450, 
    StrokeShape = new RoundRectangle { CornerRadius = 40 },
    BackgroundColor = AppColors.Gray950,
    Content = grid
};

button = new Button
{
    Text = "Click me",
    Margin = 30
};

button.Clicked += async (sender, e) =>
{
    count++;
    button.Text = $"Clicked {count} ";
    button.Text += count == 1 ? "time" : "times";
    await vStack.RotateYTo(((count % 4) switch { 0 => 0, 1 => 20, 2 => 0, _ => -20 }));
    await label.RotateTo(360 * (count % 2), 300);
};

vStack.Children.Add(label);
vStack.Children.Add(slider);
vStack.Children.Add(border);
vStack.Children.Add(button);

scrollView.Content = vStack;

Content = scrollView;

@idexus
Copy link
Author

idexus commented Feb 28, 2023

I decided to give AI some examples and teach it the rules, and check how its code generation is going according to these rules.

here is the result Screenshot 2023-02-28 at 16 50 57 Screenshot 2023-02-28 at 16 51 46 Screenshot 2023-02-28 at 17 54 22 Screenshot 2023-02-28 at 17 58 50

@hartez
Copy link
Contributor

hartez commented Feb 28, 2023

Generally speaking, it's about adding the IEnermerable interface for classes that have the [ContentProperty] attribute, and thus are containers, and logically speaking, you can put something in them, and in my understanding to give the possibility to use the Add method then. From a logical point of view, it can be a container that can hold many things, but there can also be a container that can hold one thing, in short, you can "add" one thing to it, and that was the original meaning of this PR.

IEnumerable and ContentProperty are two different things with two different purposes.

ContentProperty is just a mapping from XML content to a single property on an object; the "content" is the content of the XML tag. Label is not a "container" for text; Label has a Text property, and the ContentProperty attribute tells the XAML parser to map the XML content of the <Label/> tag to the Label.Text property.

IEnumerable just means that a thing can list a set of items; it does not imply that it's a container. And generally speaking, it's meant for something which can list "zero to many things", not "exactly one thing". Most importantly here, though, is that it doesn't allow you to "set content". IEnumerable has nothing to do with setting content unless you are trying to support collection initializers, and then only if you also create an Add() method.

So without the extension methods you provide in the sample section, this PR doesn't do anything except add IEnumerable to some classes which are not collections and don't otherwise have any use for the IEnumerable interface. At best, it's giving other folks the option of adding confusing Add() extension methods which do not add things to collections.

And in this case, I can agree. For views such as Label, I don't see the need to implement the IEnumerable interface. I only did this to be consistent with your approach, as it has a [ContentProperty] attribute defined in your library. So my argument followed what is already implemented.

Since ContentProperty and IEnumerable are not the same thing, then, we can remove IEnumerable from all of the places where this PR adds it. What, then, is left?

Yes, I agree many of the things I'm talking about can already be done directly in the code, but what I'm talking about is creating a method that is consistent in its intention, and gives you the ability to create an interface in an intuitive way, giving it similarity to XAML, but also power of C#, while not departing from the MVVM model. This is what I am trying to get in my library.

I've already explained the topic of Label, so I won't repeat myself :) As for overusing language syntax, sometimes you have to think differently to get ahead. And I wouldn't call it abuse, but using the syntax to achieve a goal :)

So the "goal" you're trying to achieve is to have a declarative API in C# that mirrors what's already available in XAML, and allows some procedural C# as well? That's fine; you're more than free to do that in your own library. And as far as I can tell, you've been able to do without this PR, except for having to do

Content = new ScrollView()
{
    Content = new VerticalStackLayout()
    {

instead of

Content = new ScrollView()
{
    new VerticalStackLayout()

Aside from abusing collection initializers to initialize things which are not collections, what does this PR allow that cannot already be done? If the only thing is gives anyone is the possibility of abusing collection initializers, then I can't see any reason for it to be merged.

@hartez
Copy link
Contributor

hartez commented Feb 28, 2023

As a thought exercise, let's say Label : IEnumerable, and that we add your extension method:

public static T Add<T>(this T element, string text)
	where T : Label
{
	element.Text = text;
	return element;
}

This would now be legal syntax:

var label = new Label { "Some text", "Some more text", "Yet more text" };

After which label.Text is "Yet more text".

Or Border : IEnumerable, with

public static T Add<T>(this T element, View content)
	where T : Border
{
	element.Content = content;
	return element;
}

Which makes this legal:

var border = new Border { new Button { Text = "Button 1" }, new Button { Text = "Button 2" }, new Button { Text = "Button 3" } };

But after that runs, border.Content is just a single button.

This is what I mean by abuse of collection initializers. The language feature has a specific purpose - to make the syntax for creating collections less verbose. With this stuff in place, we'd be violating the expectations of users about what happens when you use this language feature. We'd also be wildly inconsistent with everything else in the C# ecosystem.

@idexus
Copy link
Author

idexus commented Feb 28, 2023

I think we see the problem differently.

I perfectly understand the meaning of the [ContentProperty] attribute. I'll take a thought exercise too. Suppose we have a bicycle, At first no one sits on it. Is the number of cyclists that can ride this bike enumerable? Yes, it is and is one. Can two people ride this bike, no. There comes a time when you have to tell the other person that he won't come in.

public static T Add<T>(this T bike, Rider rider)
	where T : Bike
{
    if (bike.Rider != null) throw new ArgumentException("Sorry, this bike is taken");
    bike.Rider = rider;
    return bike;
}

In addition, what we put into the container using the Add method is always enumerable. Whether it's a function, View, or some other object. And here is the big difference between IEnumerable<T> and IEnumerable because the first interface tells us what we can put in the bag and the second one not if you think outside the box ;)

@idexus
Copy link
Author

idexus commented Mar 1, 2023

This is what I mean by abuse of collection initializers. The language feature has a specific purpose - to make the syntax for creating collections less verbose. With this stuff in place, we'd be violating the expectations of users about what happens when you use this language feature. We'd also be wildly inconsistent with everything else in the C# ecosystem.

I will say one more thing, it all depends on the level of abstraction at which we want to stay, and I'm not just talking about the curly braces or the Add method, I'm talking about the whole spectrum of possibilities that can be achieved even with the current language syntax.

@hartez
Copy link
Contributor

hartez commented Mar 7, 2023

We don't need all this extra effort to enforce the idea of a "object which has exactly one of something". The language already has that, and it's called a "property".

If I want to ensure that a bike has exactly 1 rider, I just need a property Rider on my Bike class (and in fact, you already did this). If I want to "enumerate" all the riders of my bike, I can call myBike.Rider.

0, 1, and n are fundamentally different concepts with different concerns; that's why we treat them differently. You're trying to force 1 into n so you can use a feature designed for n. By your logic, we'd add IEnumerable to every control class and pick one property to promote to the "special" status where you could set it with a collection initializer.

But that would confuse everyone who understands what IEnumerable and Add normally mean. A user would expect that they could do something like

var bike = new Bike { rider1, rider2, rider3 };

because it compiles, and they would only find out at runtime that it doesn't work that way.

If we really want to promote one property to be "special" for the purposes of initialization, we can do that with a constructor overload:

public Bike(Rider rider){ Rider = rider; }

There, now it's new Bike(rider) instead of new Bike{rider}. Same number of characters, and we didn't have to do anything weird and confusing with language features. In fact, this would be a much closer mapping to the idea of the ContentProperty. We just generally don't bother doing this with controls because XAML requires us to have an empty constructor anyway.

Thinking outside the box is not a virtue unto itself; the ideas we find outside that box have to add more value than they remove. And this is programming; we value clarity and consistency.

@hartez hartez closed this Mar 7, 2023
@idexus
Copy link
Author

idexus commented Mar 8, 2023

Thinking outside the box is not a virtue unto itself; the ideas we find outside that box have to add more value than they remove. And this is programming; we value clarity and consistency.

I can't help it you can't see what it brings.

I'm sorry to say, but I see a big inconsistency in your approach and reasoning. The XAML below also compiles, in fact it doesn't even throw an exception and sorry, I don't see any clarity or consistency here.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="testApp.MainPage">

    <Border>
        <Label Text="Rider 1"></Label>
        <Label Text="Rider 2"></Label>
        <Label Text="Rider 3"></Label>
    </Border>

</ContentPage>

I will leave it without further comment.

@idexus idexus deleted the ienumerable branch March 8, 2023 07:07
@github-actions github-actions bot locked and limited conversation to collaborators Dec 13, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
community ✨ Community Contribution proposal/open
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Simplification of interface declaration in code
6 participants