Skip to content

[BUG] .Bind to nested/complex model property not working #243

@Falco94

Description

@Falco94

Is there an existing issue for this?

  • I have searched the existing issues

Did you read the "Reporting a bug" section on Contributing file?

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

  1. Create a new Maui project with CommunityToolkit.Markup and CommunityToolkit.Mvvm packages installed.
  2. 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";
}
  1. 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: 7

Anything else?

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingunverifiedBug has not been verified by maintainers

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions