-
Notifications
You must be signed in to change notification settings - Fork 39
[BUG] .Bind to nested/complex model property not working #243
Description
Is there an existing issue for this?
- I have searched the existing issues
Did you read the "Reporting a bug" section on Contributing file?
- I have read the "Reporting a bug" section on Contributing file: https://github.com/CommunityToolkit/Maui.Markup/blob/main/CONTRIBUTING.md#reporting-a-bug
Current Behavior
Binding to an nested model property (property of an object inside the model) does not work. The internal TypedBinding has always just one handler, which does not cover complex bindings:
public static TBindable Bind<TBindable, TBindingContext, TSource, TParam, TDest>(
this TBindable bindable,
BindableProperty targetProperty,
Expression<Func<TBindingContext, TSource>> getter,
Action<TBindingContext, TSource>? setter = null,
BindingMode mode = BindingMode.Default,
IValueConverter? converter = null,
TParam? converterParameter = default,
string? stringFormat = null,
TBindingContext? source = default,
TDest? targetNullValue = default,
TDest? fallbackValue = default) where TBindable : BindableObject
{
return Bind(
bindable,
targetProperty,
getterFunc,
// only one handler added here
new (Func<TBindingContext, object?>, string)[] { ((TBindingContext b) => b, GetMemberName(getter)) },
setter,
mode,
converter,
converterParameter,
stringFormat,
source,
targetNullValue,
fallbackValue);
}
As you can find in the original Microsoft.Maui Unit Test public void ValueSetOnTwoWayWithComplexPathBinding(bool setContextFirst, bool isDefault) they created multiple handlers for each subproperty:
var binding = new TypedBinding<ComplexMockViewModel, string>(
cmvm => (cmvm.Model.Model.Text, true),
(cmvm, s) => cmvm.Model.Model.Text = s, new[] {
new Tuple<Func<ComplexMockViewModel, object>, string>(cmvm=>cmvm, "Model"),
new Tuple<Func<ComplexMockViewModel, object>, string>(cmvm=>cmvm.Model, "Model"),
new Tuple<Func<ComplexMockViewModel, object>, string>(cmvm=>cmvm.Model.Model, "Text")
})
{ Mode = bindingMode };
Therefore i've added some functionality which takes the original Expression and create the handlers for the TypedBinding.
I'm not familiar with posting possible solutions, or creating Expression magic. Maybe this code snippet will still help, or give a better idea.
public static TBindable Bind<TBindable, TBindingContext, TSource, TParam, TDest>(
this TBindable bindable,
BindableProperty targetProperty,
Expression<Func<TBindingContext, TSource>> getter,
Action<TBindingContext, TSource>? setter = null,
BindingMode mode = BindingMode.Default,
Func<TSource?, TParam?, TDest>? convert = null,
Func<TDest?, TParam?, TSource>? convertBack = null,
TParam? converterParameter = default,
string? stringFormat = null,
TBindingContext? source = default,
TDest? targetNullValue = default,
TDest? fallbackValue = default) where TBindable : BindableObject
{
var getterFunc = ConvertExpressionToFunc(getter);
// Create a default setter, if no explicit one is set
if(setter == null)
{
setter = ExpressionHelpers.CreateDefaultSetterFromGetter(getter);
}
return Bind(
bindable,
targetProperty,
getterFunc,
ExpressionHelpers.GetPropertyAccessExpressions(getter),
//new (Func<TBindingContext, object?>, string)[] { ((TBindingContext b) => b, GetMemberName(getter)) },
setter,
mode,
convert,
convertBack,
converterParameter,
stringFormat,
source,
targetNullValue,
fallbackValue);
}
public static class ExpressionHelpers
{
/// <summary>
///
/// </summary>
/// <typeparam name="TModel"></typeparam>
/// <typeparam name="TProperty"></typeparam>
/// <param name="expression"></param>
/// <returns></returns>
public static (Func<TModel, object?>, string)[] GetPropertyAccessExpressions<TModel, TProperty>(
Expression<Func<TModel, TProperty>> expression)
{
var expressionParamater = expression.Parameters.FirstOrDefault();
if (expressionParamater == null)
{
throw new Exception();
}
var propertyExpressions = new List<(Func<TModel, object?>, string)>();
ExtractPropertyExpressions(expression.Body, expressionParamater, propertyExpressions);
propertyExpressions.Reverse();
return propertyExpressions.ToArray();
}
/// <summary>
///
/// </summary>
/// <typeparam name="TModel"></typeparam>
/// <param name="expression"></param>
/// <param name="parameterExpression"></param>
/// <param name="propertyExpressions"></param>
static void ExtractPropertyExpressions<TModel>(
Expression expression,
ParameterExpression parameterExpression,
List<(Func<TModel, object?>, string)> propertyExpressions)
{
while (expression is MemberExpression memberExpression)
{
var memberName = memberExpression.Member.Name;
if (memberExpression.Expression == null)
{
throw new Exception();
}
var lambdaExpression = Expression.Lambda<Func<TModel, object?>>(memberExpression.Expression, parameterExpression);
propertyExpressions.Add((lambdaExpression.Compile(), memberName));
expression = lambdaExpression.Body;
}
}
/// <summary>
///
/// </summary>
/// <typeparam name="TModel"></typeparam>
/// <typeparam name="TProperty"></typeparam>
/// <param name="expression"></param>
/// <returns></returns>
public static Action<TModel, TProperty> CreateDefaultSetterFromGetter<TModel, TProperty>(
Expression<Func<TModel, TProperty>> expression)
{
var modelParam = expression.Parameters[0];
var valueParam = Expression.Parameter(typeof(TProperty), "val");
var assignment = Expression.Assign(expression.Body, valueParam);
var setterLambda = Expression.Lambda<Action<TModel, TProperty>>(
assignment,
modelParam,
valueParam
);
return setterLambda.Compile();
}
}
Expected Behavior
Bindings like
.Bind(Label.TextProperty, getter: static (TestModel myModel) => myModel.NestedObject.Test)
should work
Steps To Reproduce
- Create a new Maui project with CommunityToolkit.Markup and CommunityToolkit.Mvvm packages installed.
- Create a view with model:
public class TestView : ContentPage
{
TestModel model;
public TestView()
{
model = new TestModel();
BindingContext = model;
Content = new Grid
{
Children =
{
new VerticalStackLayout
{
new Label
{
}.Bind(Label.TextProperty, path: "NestedObject.Test"),
new Label
{
}.Bind(Label.TextProperty, getter: static (TestModel myModel) => myModel.NestedObject.Test),
new Entry
{
}.Bind(Entry.TextProperty, static (TestModel model) => model.NestedObject.Test, (TestModel model, string value) => model.NestedObject.Test = value),
}
}
};
}
}
public partial class TestModel : ObservableObject
{
[ObservableProperty]
NestedObject nestedObject = new();
[ObservableProperty]
NestedObject? nestedObject2;
[ObservableProperty]
string title = "My Title";
}
public partial class NestedObject : ObservableObject
{
[ObservableProperty]
string? test = "Initial";
}
- Start, and change the value of the entry. Only the first (default Microsoft) Binding should work.
Link to public reproduction project repository
https://github.com/Falco94/CommunityToolkit.IssueTest
Environment
- .NET MAUI C# Markup CommunityToolkit: 3.2
- OS: Windows 10 Build 10.0.19044
- .NET MAUI: 7Anything else?
No response