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

[Proposal] Compiling typed bindings using source generators #20574

Closed
simonrozsival opened this issue Feb 14, 2024 · 1 comment
Closed

[Proposal] Compiling typed bindings using source generators #20574

simonrozsival opened this issue Feb 14, 2024 · 1 comment
Assignees
Labels

Comments

@simonrozsival
Copy link
Member

simonrozsival commented Feb 14, 2024

Description

This is an alternative proposal to #19912

I've been thinking about ways to expose the type-safe and trimming-safe TypedBinding<TSource, TProperty> to all developers. Earlier, I explored the possibility of constructing typed bindings from expressions. This approach works (surprisingly) well and it is both type safe and trimming safe (PoC: #19995). The downside of the expression-based approach is the cost of transforming the expression into the typed binding instance. This takes both longer and allocates more memory than constructing the TypedBinding instance directly and parsing the path string.

The .NET runtime team solved a similar issue with Regex in .NET 7 using source generators. Instead of parsing and compiling the regex pattern string at runtime, they added a new attribute [GeneratedRegex("...")] that allowed them to precompile the regex.

I took inspiration from the regex work and applied it to bindings.

Public API Changes

We would need a new public attribute:

namespace Microsoft.Maui.Controls;

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
class GeneratedBindingAttribute : Attribute
{
    public GeneratedBindingAttribute(string? bindingMemberName = null)
    {
         BindingMemberName = bindingMemberName;
    }

    public string? BindingMemberName { get; set; }

    public object? Source { get; set; }
    public RelativeBindingSourceMode RelativeBindingSourceMode { get; }
    public Type? AncestorType { get; set; }
    public int AncestorLevel { get; set; }

    public BindingMode Mode { get; set; }
    public Type? ValueConverterType { get; set; }
    public object? ConverterParameter { get; set; }
    public object? TargetNullValue { get; set; }
    public object? FallbackValue { get; set; }
}
  • It is only possible to pass constant values to the attribute arguments, so the usefulness of most of the properties is very limited. Dynamic values need to be passed to the factory method (see example below).
  • certain combinations of the properties will be invalid (especially RelativeBindingSourceMode + AncestorType + AncestorLevel), the source generator will produce warnings in those cases
    • more detailed spec TBD

Intended Use-Case

Developers will create getter methods (convertible into Func<TSource, TProperty>) and applying the new attribute with some optional defaults. The generator will generate a factory method that will either be called ${GetterMethodName}Binding} or alternatively the developer can specify the name of the member through an attribute argument:

partial class StudentDetailPage
{
    [GeneratedBinding(Mode = BindingMode.OneWay, TargetNullValue = "")]
    private static string? StudentFirstName(StudentViewModel vm) => vm.Name?.LastName;
    
    [GeneratedBinding("LastNameBinding")]
    private static string? StudentLastName(StudentViewModel vm) => vm.Name?.LastName;
   
    public void Configure(Label firstNameLabel, Label lastNameLabel, StudentViewModel vm)
    {
        firstNameLabel.SetBinding(Label.TextProperty, StudentFirstNameBinding(source: vm));
        lastNameLabel.SetBinding(Label.TextProperty, LastNameBinding(source: vm, targetNullValue: ""));
    }
}
                  
class StudentViewModel
{
    public NameViewModel? Name { get; set; }
}

class NameViewModel(string firstName, string lastName)
{
    public string FirstName { get; set; } = firstName;
    public string LastName { get; set; } = lastName;
}

The generated code for the app code would look something like this:

partial class StudentDetailPage
{
    private static BindingBase StudentFirstNameBinding(
        object? source = null,
        BindingMode mode = BindingMode.OneWay, // default value based on the attribute value
        IValueConverter? converter = null,
        object? converterParameter = null,
        string? stringFormat = null,
        object? targetNullValue = null,
        object? fallbackValue = null)
        => new TypedBinding<StudentViewModel, string?>(
            getter: static (studentViewModel) => (studentViewModel.Name?.FirstName, studentViewModel.Name is not null),
            setter: static (studentViewModel, value) =>
            {
                if (studentViewModel.Name is not null)
                {
                    studentViewModel.Name.FirstName = value;
                }
            },
            handlers: new Tuple<Func<StudentViewModel, object?>, string>[]
            {
                new(static (studentViewModel) => studentViewModel, nameof(StudentViewModel.Name)),
                new(static (studentViewModel) => studentViewModel.Name, nameof(NameViewModel.FirstName)),
            })
        {
            Source = source,
            Mode = mode,
            Converter = converter,
            ConverterParameter = converterParameter,
            StringFormat = stringFormat,
            TargetNullValue = targetNullValue ?? "", // default TargetNullValue passed to the attribute
            FallbackValue = fallbackValue,
        };
    
    // The name of the method was given explicitly throught the attribute
    private static BindingBase LastNameBinding(
        object? source = null,
        BindingMode mode = BindingMode.Default,
        IValueConverter? converter = null,
        object? converterParameter = null,
        string? stringFormat = null,
        object? targetNullValue = null,
        object? fallbackValue = null)
        => new TypedBinding<StudentViewModel, string?>(
            getter: static (studentViewModel) => (studentViewModel.Name?.LastName, studentViewModel.Name is not null),
            setter: static (studentViewModel, value) =>
            {
                if (studentViewModel.Name is not null)
                {
                    studentViewModel.Name.LastName = value;
                }
            },
            handlers: new Tuple<Func<StudentViewModel, object?>, string>[]
            {
                new(static (studentViewModel) => studentViewModel, nameof(StudentViewModel.Name)),
                new(static (studentViewModel) => studentViewModel.Name, nameof(NameViewModel.LastName)),
            })
        {
            Source = source,
            Mode = mode,
            Converter = converter,
            ConverterParameter = converterParameter,
            StringFormat = stringFormat,
            TargetNullValue = targetNullValue,
            FallbackValue = fallbackValue,
        };
}

Relative binding sources

The relative binding sources could be defined this way:

partial class MyComponent
{
    [GeneratedBinding(RelativeBindingSourceMode = RelativeBindingSourceMode.FindAncestor, AncestorType = typeof(IFontElement))]
    private static string FontFamily(IFontElement fontElement) => fontElement.FontFamily;
}

The source generator would use this information to set Source:

// ...
Source = source ?? new RelativeBindingSource(RelativeBindingSourceMode.FindAncestor, typeof(IFontElement)),
// ...

Alternative design

@StephaneDelcroix suggested using a simpler API, closer to the one from #19912:

public static partial class BindableObjectExtensions
{
    public static void SetBinding<TSource, TProperty>(
        this BindableObject self,
        BindableProperty property,
        Func<TSource, TProperty> getter,
        Action<TSource, TProperty>? setter = null,
	BindingMode mode = BindingMode.Default,
	IValueConverter? converter = null,
	object? converterParameter = null,
	string? stringFormat = null,
	object? source = null,
	object? fallbackValue = null,
	object? targetNullValue = null)
    {
        throw new InvalidOperationException($"The method call to {nameof(SetBinding<TSource, TProperty>)} was not intercepted.");
    }
}

This method would be intercepted by a source generator or XamlC and we would generate the code that creates the right TypedBinding<TSource, TProperty> binding instance and calls self.SetBinding(property, binding).

@drasticactions drasticactions changed the title [Proposal] Compiling typed bindings using soruce generators [Proposal] Compiling typed bindings using source generators Feb 14, 2024
@simonrozsival simonrozsival self-assigned this Feb 14, 2024
@PureWeen PureWeen added this to the .NET 9 Planning milestone Feb 14, 2024
@jfversluis jfversluis added t/enhancement ☀️ New feature or request area-xaml XAML, CSS, Triggers, Behaviors labels Mar 30, 2024
@simonrozsival
Copy link
Member Author

We have implemented this in #21725 and it was merged into the net9.0 branch

@github-actions github-actions bot locked and limited conversation to collaborators Jul 6, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests

5 participants