-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Description
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)
.