Skip to content

Commit

Permalink
Add Support for IValueConverter in Typed Bindings Extensions (#183)
Browse files Browse the repository at this point in the history
* Add Support for `IValueConverter`

* Update Unit Tests

* Update SettingsPage.cs
  • Loading branch information
brminnick committed Feb 14, 2023
1 parent 1c82eff commit 13e9a6f
Show file tree
Hide file tree
Showing 5 changed files with 276 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public SettingsPage(SettingsViewModel settingsViewModel) : base(settingsViewMode
.Bind(Entry.TextProperty, static (SettingsViewModel vm) => vm.NumberOfTopStoriesToFetch, static (SettingsViewModel vm, int text) => vm.NumberOfTopStoriesToFetch = text),

new Label()
.Bind<Label, int, int, string>(
.Bind(
Label.TextProperty,
binding1: new Binding { Source = SettingsService.MinimumStoriesToFetch },
binding2: new Binding { Source = SettingsService.MaximumStoriesToFetch },
Expand Down
22 changes: 12 additions & 10 deletions src/CommunityToolkit.Maui.Markup.UnitTests/BindingHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,23 @@ static class BindingHelpers
TBindingContext expectedSource,
Func<TSource?, TDest>? expectedConverter = null,
string? expectedFormat = null) where TBindable : BindableObject
=> AssertTypedBindingExists<TBindable, TBindingContext, TSource, object?, TDest>(
bindable, targetProperty, expectedBindingMode, expectedSource, expectedConverter, expectedStringFormat: expectedFormat);
{
var funcConverter = expectedConverter switch
{
null => null,
_ => new FuncConverter<TSource, TDest, object>(expectedConverter, null)
};

AssertTypedBindingExists<TBindable, TBindingContext, TSource, object?, TDest>(
bindable, targetProperty, expectedBindingMode, expectedSource, funcConverter, expectedStringFormat: expectedFormat);
}

internal static void AssertTypedBindingExists<TBindable, TBindingContext, TSource, TParam, TDest>(
TBindable bindable,
BindableProperty targetProperty,
BindingMode expectedBindingMode,
TBindingContext expectedSource,
Func<TSource?, TDest>? expectedConverter = null,
IValueConverter? expectedConverter = null,
string? expectedStringFormat = null,
TDest? expectedTargetNullValue = default,
TDest? expectedFallbackValue = default,
Expand All @@ -79,13 +87,7 @@ static class BindingHelpers

Assert.That(binding.Mode, Is.EqualTo(expectedBindingMode));

var funcConverter = expectedConverter switch
{
null => null,
_ => new FuncConverter<TSource, TDest, TParam>(expectedConverter, null)
};

Assert.That(binding.Converter?.ToString(), Is.EqualTo(funcConverter?.ToString()));
Assert.That(binding.Converter?.ToString(), Is.EqualTo(expectedConverter?.ToString()));

Assert.That(binding.ConverterParameter, Is.EqualTo(expectedConverterParameter));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="coverlet.collector" Version="3.2.0" PrivateAssets="all" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
<PackageReference Include="CommunityToolkit.Maui" Version="4.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using CommunityToolkit.Maui.Converters;
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using NUnit.Framework;
using NUnit.Framework.Internal;
Expand Down Expand Up @@ -289,8 +290,142 @@ public void ValueSetOnOneWayWithNestedPathBinding(bool setContextFirst, bool isD
Assert.AreEqual(textColor, entry.GetValue(Entry.TextColorProperty));
}

[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
[TestCase(true, true)]
public void ValueSetOnOneWayWithNestedPathBindingWithIValueConverter(bool setContextFirst, bool isDefault)
{
var label = new Label();
var bindingMode = isDefault ? BindingMode.Default : BindingMode.OneWay;

var viewmodel = new NestedViewModel
{
Model = new NestedViewModel
{
Model = new NestedViewModel()
}
};

var colorToHexRgbStringConverter = new ColorToHexRgbStringConverter();

if (setContextFirst)
{
label.BindingContext = viewmodel;
label.Bind<Label, NestedViewModel, Color?, string>(Label.TextProperty,
static (NestedViewModel vm) => vm.Model?.Model?.TextColor,
new (Func<NestedViewModel, object?>, string)[]
{
(vm => vm, nameof(NestedViewModel.Model)),
(vm => vm.Model, nameof(NestedViewModel.Model)),
(vm => vm.Model.Model, nameof(NestedViewModel.Model.TextColor))
},
static (NestedViewModel vm, Color? color) =>
{
if (vm.Model?.Model?.TextColor is not null && color is not null)
{
vm.Model.Model.TextColor = color;
}
},
bindingMode,
converter: colorToHexRgbStringConverter);
}
else
{
label.Bind<Label, NestedViewModel, Color?, string>(Label.TextProperty,
static (NestedViewModel vm) => vm.Model?.Model?.TextColor,
new (Func<NestedViewModel, object?>, string)[]
{
(vm => vm, nameof(NestedViewModel.Model)),
(vm => vm.Model, nameof(NestedViewModel.Model)),
(vm => vm.Model.Model, nameof(NestedViewModel.Model.TextColor))
},
static (NestedViewModel vm, Color? color) =>
{
if (vm.Model?.Model?.TextColor is not null && color is not null)
{
vm.Model.Model.TextColor = color;
}
},
bindingMode,
converter: colorToHexRgbStringConverter);

label.BindingContext = viewmodel;
}

Assert.AreEqual(ViewModel.DefaultColor, viewmodel.Model.Model.TextColor);
Assert.AreEqual(colorToHexRgbStringConverter.ConvertFrom(ViewModel.DefaultColor), label.GetValue(Label.TextProperty));

var textColor = Colors.Pink;

viewmodel.Model.Model.TextColor = textColor;
Assert.AreEqual(textColor, viewmodel.Model.Model.TextColor);
Assert.AreEqual(colorToHexRgbStringConverter.ConvertFrom(textColor), label.GetValue(Label.TextProperty));
}

[Test]
public async Task ConfirmReadOnlyTypedBindingWithIValueConverter()
{
ArgumentNullException.ThrowIfNull(viewModel);

var colorToHexRgbStringConverter = new ColorToHexRgbStringConverter();
var updatedTextColor = Colors.Pink;

bool didViewModelPropertyChangeFire = false;
int viewModelPropertyChangedEventCount = 0;
TaskCompletionSource<string?> viewModelPropertyChangedEventArgsTCS = new();

bool didLabelPropertyChangeFire = false;
int labelPropertyChangedEventCount = 0;
TaskCompletionSource<string?> labelPropertyChangedEventArgsTCS = new();

var label = new Label
{
BindingContext = viewModel
}.Bind<Label, ViewModel, Color, string>(Label.TextProperty,
static (ViewModel viewModel) => viewModel.TextColor,
converter: colorToHexRgbStringConverter);

label.PropertyChanged += HandleLabelPropertyChanged;
viewModel.PropertyChanged += HandleViewModelPropertyChanged;

BindingHelpers.AssertTypedBindingExists<Label, ViewModel, Color, object?, string>(
label,
Label.TextProperty,
BindingMode.Default,
viewModel,
expectedConverter: colorToHexRgbStringConverter);

viewModel.TextColor = updatedTextColor;
var viewModelPropertyName = await viewModelPropertyChangedEventArgsTCS.Task;
var labelPropertyName = await labelPropertyChangedEventArgsTCS.Task;

Assert.True(didViewModelPropertyChangeFire);
Assert.AreEqual(nameof(ViewModel.TextColor), viewModelPropertyName);
Assert.AreEqual(1, viewModelPropertyChangedEventCount);

Assert.True(didLabelPropertyChangeFire);
Assert.AreEqual(nameof(Label.Text), labelPropertyName);
Assert.AreEqual(1, labelPropertyChangedEventCount);
Assert.AreEqual(colorToHexRgbStringConverter.ConvertFrom(updatedTextColor), label.Text);

void HandleViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
didViewModelPropertyChangeFire = true;
viewModelPropertyChangedEventCount++;
viewModelPropertyChangedEventArgsTCS.TrySetResult(e.PropertyName);
}

void HandleLabelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
didLabelPropertyChangeFire = true;
labelPropertyChangedEventCount++;
labelPropertyChangedEventArgsTCS.TrySetResult(e.PropertyName);
}
}

[Test]
public async Task ConfirmReadOnlyTypedBindingWithConversion()
public async Task ConfirmReadOnlyTypedBindingWithFuncConversion()
{
ArgumentNullException.ThrowIfNull(viewModel);

Expand Down
135 changes: 126 additions & 9 deletions src/CommunityToolkit.Maui.Markup/TypedBindingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,33 @@ public static class TypedBindingExtensions
fallbackValue);
}

/// <summary>Bind to a specified property with inline conversion</summary>
public static TBindable Bind<TBindable, TBindingContext, TSource, TDest>(
this TBindable bindable,
BindableProperty targetProperty,
Expression<Func<TBindingContext, TSource>> getter,
Action<TBindingContext, TSource>? setter = null,
BindingMode mode = BindingMode.Default,
IValueConverter? converter = null,
string? stringFormat = null,
TBindingContext? source = default,
TDest? targetNullValue = default,
TDest? fallbackValue = default) where TBindable : BindableObject
{
return Bind<TBindable, TBindingContext, TSource, object?, TDest>(
bindable,
targetProperty,
getter,
setter,
mode,
converter,
null,
stringFormat,
source,
targetNullValue,
fallbackValue);
}

/// <summary>Bind to a specified property with inline conversion</summary>
public static TBindable Bind<TBindable, TBindingContext, TSource, TDest>(
this TBindable bindable,
Expand Down Expand Up @@ -120,6 +147,35 @@ public static class TypedBindingExtensions
fallbackValue);
}

/// <summary>Bind to a specified property with inline conversion</summary>
public static TBindable Bind<TBindable, TBindingContext, TSource, TDest>(
this TBindable bindable,
BindableProperty targetProperty,
Func<TBindingContext, TSource> getter,
(Func<TBindingContext, object?>, string)[] handlers,
Action<TBindingContext, TSource>? setter = null,
BindingMode mode = BindingMode.Default,
IValueConverter? converter = null,
string? stringFormat = null,
TBindingContext? source = default,
TDest? targetNullValue = default,
TDest? fallbackValue = default) where TBindable : BindableObject
{
return Bind<TBindable, TBindingContext, TSource, object?, TDest>(
bindable,
targetProperty,
getter,
handlers,
setter,
mode,
converter,
null,
stringFormat,
source,
targetNullValue,
fallbackValue);
}

/// <summary>Bind to a specified property with inline conversion and conversion parameter</summary>
public static TBindable Bind<TBindable, TBindingContext, TSource, TParam, TDest>(
this TBindable bindable,
Expand All @@ -135,9 +191,9 @@ public static class TypedBindingExtensions
TDest? targetNullValue = default,
TDest? fallbackValue = default) where TBindable : BindableObject
{
var getterFunc = convertExpressionToFunc(getter);
var getterFunc = ConvertExpressionToFunc(getter);

return Bind<TBindable, TBindingContext, TSource, TParam, TDest>(
return Bind(
bindable,
targetProperty,
getterFunc,
Expand All @@ -151,15 +207,37 @@ public static class TypedBindingExtensions
source,
targetNullValue,
fallbackValue);
}

static Func<TBindingContext, TSource> convertExpressionToFunc(in Expression<Func<TBindingContext, TSource>> expression) => expression.Compile();
/// <summary>Bind to a specified property with inline conversion and conversion parameter</summary>
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
{
var getterFunc = ConvertExpressionToFunc(getter);

static string GetMemberName<T>(in Expression<T> expression) => expression.Body switch
{
MemberExpression m => m.Member.Name,
UnaryExpression u when u.Operand is MemberExpression m => m.Member.Name,
_ => throw new InvalidOperationException("Could not retreive member name")
};
return Bind(
bindable,
targetProperty,
getterFunc,
new (Func<TBindingContext, object?>, string)[] { ((TBindingContext b) => b, GetMemberName(getter)) },
setter,
mode,
converter,
converterParameter,
stringFormat,
source,
targetNullValue,
fallbackValue);
}

/// <summary>Bind to a specified property with inline conversion and conversion parameter</summary>
Expand All @@ -184,6 +262,36 @@ public static class TypedBindingExtensions
_ => new FuncConverter<TSource, TDest, TParam>(convert, convertBack)
};

return Bind(
bindable,
targetProperty,
getter,
handlers,
setter,
mode,
converter,
converterParameter,
stringFormat,
source,
targetNullValue,
fallbackValue);
}

/// <summary>Bind to a specified property with inline conversion and conversion parameter</summary>
public static TBindable Bind<TBindable, TBindingContext, TSource, TParam, TDest>(
this TBindable bindable,
BindableProperty targetProperty,
Func<TBindingContext, TSource> getter,
(Func<TBindingContext, object?>, string)[] handlers,
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
{
bindable.SetBinding(targetProperty, new TypedBinding<TBindingContext, TSource>(bindingContext => (getter(bindingContext), true), setter, handlers.Select(x => x.ToTuple()).ToArray())
{
Mode = mode,
Expand All @@ -197,4 +305,13 @@ public static class TypedBindingExtensions

return bindable;
}

static Func<TBindingContext, TSource> ConvertExpressionToFunc<TBindingContext, TSource>(in Expression<Func<TBindingContext, TSource>> expression) => expression.Compile();

static string GetMemberName<T>(in Expression<T> expression) => expression.Body switch
{
MemberExpression m => m.Member.Name,
UnaryExpression u when u.Operand is MemberExpression m => m.Member.Name,
_ => throw new InvalidOperationException("Could not retreive member name")
};
}

0 comments on commit 13e9a6f

Please sign in to comment.