Skip to content

Commit

Permalink
Extend .Bind() to Support TypedBinding (#156)
Browse files Browse the repository at this point in the history
* Add TypedBindingExtensions

* Update StoryDataTemplate.cs

* Update TypedBindingExtensions.cs

* Update TypedBindingExtensions.cs

* Update TypedBindingExtensions.cs

* Update TypedBindingExtensions.cs

* Add Unit Tests

* `dotnet format`

* Remove duplicate code

* Add Handlers, Change default value of `BindingMode mode = BindingMode.Default`

* Update TypedBindingExtensionsTests.cs

* `dotnet format`

* Update Sample

* Update TypedBindingExtensions.cs

* Remove `string propertyName`

* Update TypedBindingExtensions.cs

* Improve Handlers

* Update Bindings

* Add Handlers Support

* Update TypedBindingExtensionsTests.cs

* Remove Non-required Generic Parameters

* `dotnet format`

* `dotnet format`

* Add `ConfirmReadOnlyTypedBindingWithConversion` Test

* Remove unnecessary `const`

---------

Co-authored-by: Shaun Lawrence <shaunrlawrence@gmail.com>
  • Loading branch information
brminnick and bijington committed Feb 3, 2023
1 parent 94c95bd commit 1c82eff
Show file tree
Hide file tree
Showing 38 changed files with 764 additions and 185 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ sealed class NewsDetailPage : BaseContentPage<NewsDetailViewModel>
{
public NewsDetailPage(NewsDetailViewModel newsDetailViewModel) : base(newsDetailViewModel, newsDetailViewModel.Title)
{
this.Bind(TitleProperty, nameof(NewsDetailViewModel.Title));
this.Bind(TitleProperty, static (NewsDetailViewModel vm) => vm.Title);

Content = new FlexLayout
{
Expand All @@ -17,22 +17,22 @@ public NewsDetailPage(NewsDetailViewModel newsDetailViewModel) : base(newsDetail
{
new WebView()
.Grow(1).AlignSelf(FlexAlignSelf.Stretch)
.Bind(WebView.SourceProperty, nameof(NewsDetailViewModel.Uri), BindingMode.OneWay),
.Bind(WebView.SourceProperty, static (NewsDetailViewModel vm) => vm.Uri, mode: BindingMode.OneWay),

new Button()
.Text("Launch in Browser \uf35d")
.Font(size: 20, family: "FontAwesome")
.Basis(50)
.Style(AppStyles.ButtonStyle)
.Bind(Button.CommandProperty, nameof(NewsDetailViewModel.OpenBrowserCommand), BindingMode.OneWay)
.Bind(Button.CommandProperty, static (NewsDetailViewModel vm) => vm.OpenBrowserCommand, mode: BindingMode.OneWay)
.SemanticHint("Launches the news article in the devices browser."),

new Label()
.TextCenter()
.AlignSelf(FlexAlignSelf.Stretch)
.Paddings(bottom: 20)
.Style(AppStyles.LabelStyle)
.Bind(Label.TextProperty, nameof(NewsDetailViewModel.ScoreDescription), BindingMode.OneWay)
.Bind(Label.TextProperty, static (NewsDetailViewModel vm) => vm.ScoreDescription, mode: BindingMode.OneWay)
.SemanticHint("Displays the score of the news article."),
}
};
Expand Down
6 changes: 3 additions & 3 deletions samples/CommunityToolkit.Maui.Markup.Sample/Pages/NewsPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ sealed class NewsPage : BaseContentPage<NewsViewModel>
}.BackgroundColor(Colors.Transparent)
.ItemTemplate(new StoryDataTemplate())
.Invoke(collectionView => collectionView.SelectionChanged += HandleSelectionChanged)
.Bind(CollectionView.ItemsSourceProperty, nameof(NewsViewModel.TopStoryCollection))
.Bind(CollectionView.ItemsSourceProperty, static (NewsViewModel vm) => vm.TopStoryCollection)
.AutomationId("NewsCollectionView")

}.Bind(RefreshView.IsRefreshingProperty, nameof(NewsViewModel.IsListRefreshing))
.Bind(RefreshView.CommandProperty, nameof(NewsViewModel.PullToRefreshCommand))
}.Bind(RefreshView.IsRefreshingProperty, static (NewsViewModel vm) => vm.IsListRefreshing, static (NewsViewModel vm, bool isRefreshing) => vm.IsListRefreshing = isRefreshing)
.Bind(RefreshView.CommandProperty, static (NewsViewModel vm) => vm.PullToRefreshCommand)
.AppThemeColorBinding(RefreshView.RefreshColorProperty, Colors.Black, Colors.LightGray)
.Assign(out refreshView);
}
Expand Down
118 changes: 59 additions & 59 deletions samples/CommunityToolkit.Maui.Markup.Sample/Pages/SettingsPage.cs
Original file line number Diff line number Diff line change
@@ -1,60 +1,60 @@
using Microsoft.Maui.Layouts;

namespace CommunityToolkit.Maui.Markup.Sample.Pages;

sealed class SettingsPage : BaseContentPage<SettingsViewModel>
{
public SettingsPage(SettingsViewModel settingsViewModel) : base(settingsViewModel, "Settings")
{
Content = new AbsoluteLayout
{
Children =
{
new Image().Source("dotnet_bot.png").Opacity(0.25).IsOpaque(false).Aspect(Aspect.AspectFit)
.LayoutFlags(AbsoluteLayoutFlags.SizeProportional | AbsoluteLayoutFlags.PositionProportional)
.LayoutBounds(0.5, 0.5, 0.5, 0.5)
.AutomationIsInAccessibleTree(false),

new Label()
.Text("Top Stories To Fetch")
.AppThemeBinding(Label.TextColorProperty,AppStyles.BlackColor, AppStyles.PrimaryTextColorDark)
.LayoutFlags(AbsoluteLayoutFlags.XProportional | AbsoluteLayoutFlags.WidthProportional)
.LayoutBounds(0, 0, 1, 40)
.TextCenterHorizontal()
.TextBottom()
.Assign(out Label topNewsStoriesToFetchLabel),

new Entry { Keyboard = Keyboard.Numeric }
.BackgroundColor(Colors.White)
.Placeholder($"Provide a value between {SettingsService.MinimumStoriesToFetch} and {SettingsService.MaximumStoriesToFetch}", Colors.Grey)
.LayoutFlags(AbsoluteLayoutFlags.XProportional | AbsoluteLayoutFlags.WidthProportional)
.LayoutBounds(0.5, 45, 0.8, 40)
.Behaviors(new NumericValidationBehavior
{
Flags = ValidationFlags.ValidateOnValueChanged,
MinimumValue = SettingsService.MinimumStoriesToFetch,
MaximumValue = SettingsService.MaximumStoriesToFetch,
ValidStyle = AppStyles.ValidEntryNumericValidationBehaviorStyle,
InvalidStyle = AppStyles.InvalidEntryNumericValidationBehaviorStyle,
})
.Bind(Entry.TextProperty, nameof(SettingsViewModel.NumberOfTopStoriesToFetch))
.TextCenter()
.SemanticDescription(topNewsStoriesToFetchLabel.Text),

new Label()
.Bind<Label, int, int, string>(
Label.TextProperty,
binding1: new Binding { Source = SettingsService.MinimumStoriesToFetch },
binding2: new Binding { Source = SettingsService.MaximumStoriesToFetch },
convert: ((int minimum, int maximum) values) => string.Format(CultureInfo.CurrentUICulture, $"The number must be between {values.minimum} and {values.maximum}."),
mode: BindingMode.OneTime)
.LayoutFlags(AbsoluteLayoutFlags.XProportional | AbsoluteLayoutFlags.WidthProportional)
.LayoutBounds(0, 90, 1, 40)
.TextCenter()
.AppThemeColorBinding(Label.TextColorProperty,AppStyles.BlackColor, AppStyles.PrimaryTextColorDark)
.Font(size: 12, italic: true)
.SemanticHint($"The minimum and maximum possible values for the {topNewsStoriesToFetchLabel.Text} field above.")
}
};
}
using Microsoft.Maui.Layouts;

namespace CommunityToolkit.Maui.Markup.Sample.Pages;

sealed class SettingsPage : BaseContentPage<SettingsViewModel>
{
public SettingsPage(SettingsViewModel settingsViewModel) : base(settingsViewModel, "Settings")
{
Content = new AbsoluteLayout
{
Children =
{
new Image().Source("dotnet_bot.png").Opacity(0.25).IsOpaque(false).Aspect(Aspect.AspectFit)
.LayoutFlags(AbsoluteLayoutFlags.SizeProportional | AbsoluteLayoutFlags.PositionProportional)
.LayoutBounds(0.5, 0.5, 0.5, 0.5)
.AutomationIsInAccessibleTree(false),

new Label()
.Text("Top Stories To Fetch")
.AppThemeBinding(Label.TextColorProperty,AppStyles.BlackColor, AppStyles.PrimaryTextColorDark)
.LayoutFlags(AbsoluteLayoutFlags.XProportional | AbsoluteLayoutFlags.WidthProportional)
.LayoutBounds(0, 0, 1, 40)
.TextCenterHorizontal()
.TextBottom()
.Assign(out Label topNewsStoriesToFetchLabel),

new Entry { Keyboard = Keyboard.Numeric }
.BackgroundColor(Colors.White)
.Placeholder($"Provide a value between {SettingsService.MinimumStoriesToFetch} and {SettingsService.MaximumStoriesToFetch}", Colors.Grey)
.LayoutFlags(AbsoluteLayoutFlags.XProportional | AbsoluteLayoutFlags.WidthProportional)
.LayoutBounds(0.5, 45, 0.8, 40)
.Behaviors(new NumericValidationBehavior
{
Flags = ValidationFlags.ValidateOnValueChanged,
MinimumValue = SettingsService.MinimumStoriesToFetch,
MaximumValue = SettingsService.MaximumStoriesToFetch,
ValidStyle = AppStyles.ValidEntryNumericValidationBehaviorStyle,
InvalidStyle = AppStyles.InvalidEntryNumericValidationBehaviorStyle,
})
.TextCenter()
.SemanticDescription(topNewsStoriesToFetchLabel.Text)
.Bind(Entry.TextProperty, static (SettingsViewModel vm) => vm.NumberOfTopStoriesToFetch, static (SettingsViewModel vm, int text) => vm.NumberOfTopStoriesToFetch = text),

new Label()
.Bind<Label, int, int, string>(
Label.TextProperty,
binding1: new Binding { Source = SettingsService.MinimumStoriesToFetch },
binding2: new Binding { Source = SettingsService.MaximumStoriesToFetch },
convert: ((int minimum, int maximum) values) => string.Format(CultureInfo.CurrentUICulture, $"The number must be between {values.minimum} and {values.maximum}."),
mode: BindingMode.OneTime)
.LayoutFlags(AbsoluteLayoutFlags.XProportional | AbsoluteLayoutFlags.WidthProportional)
.LayoutBounds(0, 90, 1, 40)
.TextCenter()
.AppThemeColorBinding(Label.TextColorProperty,AppStyles.BlackColor, AppStyles.PrimaryTextColorDark)
.Font(size: 12, italic: true)
.SemanticHint($"The minimum and maximum possible values for the {topNewsStoriesToFetchLabel.Text} field above.")
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ static Grid CreateGrid() => new()
.Row(Row.Title)
.Font(size: 16).AppThemeBinding(Label.TextColorProperty, AppStyles.BlackColor, AppStyles.PrimaryTextColorDark)
.Top().Padding(10, 0)
.Bind(Label.TextProperty, nameof(StoryModel.Title))
.Bind(Label.TextProperty, static (StoryModel m) => m.Title, mode: BindingMode.OneTime)
.SemanticHint("The title of the news article."),

new Label().Row(Row.Description)
.Font(size: 13).AppThemeBinding(Label.TextColorProperty, AppStyles.SecondaryTextColorLight, AppStyles.SecondaryTextColorDark)
.Paddings(10, 0, 10, 5)
.Bind(Label.TextProperty, nameof(StoryModel.Description))
.Bind(Label.TextProperty, static (StoryModel m) => m.Description, mode: BindingMode.OneTime)
.SemanticHint("The description of the news article.")
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Layouts;
using NUnit.Framework;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
using System;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Controls;

namespace CommunityToolkit.Maui.Markup.UnitTests;
namespace CommunityToolkit.Maui.Markup.UnitTests;

public static class ApplicationTestHelpers
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using Microsoft.Maui;
using Microsoft.Maui.Controls;
using NUnit.Framework;

namespace CommunityToolkit.Maui.Markup.UnitTests;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System;
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using Microsoft.Maui.Controls;
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using NUnit.Framework;

namespace CommunityToolkit.Maui.Markup.UnitTests;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
using System;
using System.Linq;
using System.Windows.Input;
using System.Windows.Input;
using BindableObjectViews;
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using CommunityToolkit.Maui.Markup.UnitTests.BindableObjectViews;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Graphics;
using NUnit.Framework;

namespace CommunityToolkit.Maui.Markup.UnitTests
Expand Down Expand Up @@ -777,7 +772,7 @@ class ViewModel
}
}

namespace CommunityToolkit.Maui.Markup.UnitTests.BindableObjectViews // This namespace simulates derived controls defined in a separate app, for use in the tests in this file only
namespace BindableObjectViews // This namespace simulates derived controls defined in a separate app, for use in the tests in this file only
{
class DerivedFromLabel : Label
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using Microsoft.Maui.Controls;
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using NUnit.Framework;

namespace CommunityToolkit.Maui.Markup.UnitTests;
Expand Down
79 changes: 62 additions & 17 deletions src/CommunityToolkit.Maui.Markup.UnitTests/BindingHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Internals;
using NUnit.Framework;

namespace CommunityToolkit.Maui.Markup.UnitTests;
Expand Down Expand Up @@ -46,6 +44,57 @@ static class BindingHelpers
bindable, targetProperty, path, mode, assertConverterInstanceIsAnyNotNull, converter, null,
stringFormat, source, targetNullValue, fallbackValue, assertConvert);

internal static void AssertTypedBindingExists<TBindable, TBindingContext, TSource>(
TBindable bindable,
BindableProperty targetProperty,
BindingMode expectedBindingMode,
TBindingContext expectedSource,
string? expectedFormat = null) where TBindable : BindableObject
=> AssertTypedBindingExists<TBindable, TBindingContext, TSource, object?, object?>(
bindable, targetProperty, expectedBindingMode, expectedSource, expectedStringFormat: expectedFormat);

internal static void AssertTypedBindingExists<TBindable, TBindingContext, TSource, TDest>(
TBindable bindable,
BindableProperty targetProperty,
BindingMode expectedBindingMode,
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);

internal static void AssertTypedBindingExists<TBindable, TBindingContext, TSource, TParam, TDest>(
TBindable bindable,
BindableProperty targetProperty,
BindingMode expectedBindingMode,
TBindingContext expectedSource,
Func<TSource?, TDest>? expectedConverter = null,
string? expectedStringFormat = null,
TDest? expectedTargetNullValue = default,
TDest? expectedFallbackValue = default,
TParam? expectedConverterParameter = default) where TBindable : BindableObject
{
var binding = GetTypedBinding(bindable, targetProperty) ?? throw new NullReferenceException();
Assert.That(binding, Is.Not.Null);

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.ConverterParameter, Is.EqualTo(expectedConverterParameter));

Assert.IsInstanceOf<TBindingContext>(expectedSource);
Assert.That(binding.StringFormat, Is.EqualTo(expectedStringFormat));
Assert.That(binding.TargetNullValue, Is.EqualTo(expectedTargetNullValue));
Assert.That(binding.FallbackValue, Is.EqualTo(expectedFallbackValue));
}

internal static void AssertBindingExists<TDest, TParam>(
BindableObject bindable,
BindableProperty targetProperty,
Expand Down Expand Up @@ -133,39 +182,35 @@ static class BindingHelpers
assertConvert?.Invoke(binding.Converter);
}

internal static Binding? GetBinding(BindableObject bindable, BindableProperty property) => GetBindingBase(bindable, property) as Binding;
internal static Binding? GetBinding(BindableObject bindable, BindableProperty property) => GetBindingBase<Binding>(bindable, property);

internal static TypedBindingBase? GetTypedBinding(BindableObject bindable, BindableProperty property) => GetBindingBase<TypedBindingBase>(bindable, property);

internal static MultiBinding? GetMultiBinding(BindableObject bindable, BindableProperty property) => GetBindingBase(bindable, property) as MultiBinding;
internal static MultiBinding? GetMultiBinding(BindableObject bindable, BindableProperty property) => GetBindingBase<MultiBinding>(bindable, property);

/// <remarks>
/// Note that we are only testing whether the Markup helpers create the correct bindings,
/// we are not testing the binding mechanism itself; this is why it is justified to access
/// private binding API's here for testing.
/// </remarks>
internal static BindingBase? GetBindingBase(BindableObject bindable, BindableProperty property)
internal static TBinding? GetBindingBase<TBinding>(BindableObject bindable, BindableProperty property) where TBinding : BindingBase
{
if (getContextMethodInfo is null)
{
getContextMethodInfo = typeof(BindableObject).GetMethod("GetContext", BindingFlags.NonPublic | BindingFlags.Instance);
}
getContextMethodInfo ??= typeof(BindableObject).GetMethod("GetContext", BindingFlags.NonPublic | BindingFlags.Instance);

var context = getContextMethodInfo?.Invoke(bindable, new object[] { property });
if (context is null)
{
return null;
}

if (bindingFieldInfo is null)
{
bindingFieldInfo = context?.GetType().GetField("Binding");
}
bindingFieldInfo ??= context?.GetType().GetField("Binding");

return bindingFieldInfo?.GetValue(context) as BindingBase;
return bindingFieldInfo?.GetValue(context) as TBinding;
}

internal static IValueConverter AssertConvert<TValue, TConvertedValue>(this IValueConverter converter, TValue value, object? parameter, TConvertedValue expectedConvertedValue, bool twoWay = false, bool backOnly = false, CultureInfo? culture = null)
{
Assert.That(converter.Convert(value, typeof(object), parameter, culture), Is.EqualTo(backOnly ? default(TConvertedValue) : expectedConvertedValue));
Assert.That(converter.Convert(value, typeof(object), parameter, culture), Is.EqualTo(backOnly ? default : expectedConvertedValue));
Assert.That(converter.ConvertBack(expectedConvertedValue, typeof(object), parameter, culture), Is.EqualTo(twoWay || backOnly ? value : default(TValue)));
return converter;
}
Expand Down Expand Up @@ -196,5 +241,5 @@ internal static IMultiValueConverter AssertConvert<TConvertedValue>(this IMultiV
}

internal static IMultiValueConverter AssertConvert<TConvertedValue>(this IMultiValueConverter converter, object[] values, TConvertedValue expectedConvertedValue, bool twoWay = false, bool backOnly = false, CultureInfo? culture = null)
=> AssertConvert(converter, values, null, expectedConvertedValue, twoWay, backOnly, culture);
=> AssertConvert(converter, values, null, expectedConvertedValue, twoWay, backOnly, culture);
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using CommunityToolkit.Maui.Markup.UnitTests.Base;
using NUnit.Framework;

namespace CommunityToolkit.Maui.Markup.UnitTests
{
using CommunityToolkit.Maui.Markup.UnitTests.DefaultBindablePropertiesViews;
// These usings are placed here to avoid ambiguities
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Shapes;

[TestFixture]
class DefaultBindablePropertiesTests : BaseMarkupTestFixture
Expand Down
Loading

0 comments on commit 1c82eff

Please sign in to comment.