From d07a70ba197ea72ee65862640563cf75cc7992c0 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 3 Jul 2024 12:37:32 +0200 Subject: [PATCH 01/10] Update BindingBase.Instance signature. - Swap `target` and `targetProperty` order to make it consistent with other similar methods - Make `targetProperty` nullable as it will need to be null for `MultiBinding` --- .../MarkupExtensions/CompiledBindingExtension.cs | 4 ++-- src/Markup/Avalonia.Markup/Data/Binding.cs | 4 ++-- src/Markup/Avalonia.Markup/Data/BindingBase.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs index d88ba7bda7a..2e80f1ba8a1 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs @@ -59,11 +59,11 @@ public CompiledBindingExtension ProvideValue(IServiceProvider provider) } private protected override BindingExpressionBase Instance( - AvaloniaProperty targetProperty, AvaloniaObject target, + AvaloniaProperty? targetProperty, object? anchor) { - var enableDataValidation = targetProperty.GetMetadata(target).EnableDataValidation ?? false; + var enableDataValidation = targetProperty?.GetMetadata(target).EnableDataValidation ?? false; return InstanceCore(target, targetProperty, anchor, enableDataValidation); } diff --git a/src/Markup/Avalonia.Markup/Data/Binding.cs b/src/Markup/Avalonia.Markup/Data/Binding.cs index d654c7a74a4..5b07a1adb05 100644 --- a/src/Markup/Avalonia.Markup/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup/Data/Binding.cs @@ -73,11 +73,11 @@ public Binding(string path, BindingMode mode = BindingMode.Default) } private protected override BindingExpressionBase Instance( - AvaloniaProperty targetProperty, AvaloniaObject target, + AvaloniaProperty? targetProperty, object? anchor) { - var enableDataValidation = targetProperty.GetMetadata(target).EnableDataValidation ?? false; + var enableDataValidation = targetProperty?.GetMetadata(target).EnableDataValidation ?? false; return InstanceCore(targetProperty, target, anchor, enableDataValidation); } diff --git a/src/Markup/Avalonia.Markup/Data/BindingBase.cs b/src/Markup/Avalonia.Markup/Data/BindingBase.cs index be106ef5b49..07f64689554 100644 --- a/src/Markup/Avalonia.Markup/Data/BindingBase.cs +++ b/src/Markup/Avalonia.Markup/Data/BindingBase.cs @@ -95,8 +95,8 @@ public BindingBase(BindingMode mode = BindingMode.Default) bool enableDataValidation = false); private protected abstract BindingExpressionBase Instance( - AvaloniaProperty targetProperty, AvaloniaObject target, + AvaloniaProperty? targetProperty, object? anchor); private protected (BindingMode, UpdateSourceTrigger) ResolveDefaultsFromMetadata( @@ -120,7 +120,7 @@ private protected (BindingMode, UpdateSourceTrigger) ResolveDefaultsFromMetadata BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty property, object? anchor) { - return Instance(property, target, anchor); + return Instance(target, property, anchor); } } } From a0b8c2209b11b569dfb9e5b60f93a588b16d7c79 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 3 Jul 2024 12:58:47 +0200 Subject: [PATCH 02/10] IBinding2.Instance needs to accept a null target property. It will need to be null for `MultiBinding`. --- src/Avalonia.Base/AvaloniaObjectExtensions.cs | 2 +- src/Avalonia.Base/Data/Core/IBinding2.cs | 2 +- src/Avalonia.Base/Data/IndexerBinding.cs | 2 +- src/Avalonia.Base/Data/TemplateBinding.cs | 2 +- .../MarkupExtensions/DynamicResourceExtension.cs | 2 +- src/Markup/Avalonia.Markup/Data/BindingBase.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Avalonia.Base/AvaloniaObjectExtensions.cs b/src/Avalonia.Base/AvaloniaObjectExtensions.cs index 90465057bd6..0017f955830 100644 --- a/src/Avalonia.Base/AvaloniaObjectExtensions.cs +++ b/src/Avalonia.Base/AvaloniaObjectExtensions.cs @@ -375,7 +375,7 @@ public BindingAdaptor(IObservable source) return new InstancedBinding(expression, BindingMode.OneWay, BindingPriority.LocalValue); } - BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty property, object? anchor) + BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty? property, object? anchor) { return new UntypedObservableBindingExpression(_source, BindingPriority.LocalValue); } diff --git a/src/Avalonia.Base/Data/Core/IBinding2.cs b/src/Avalonia.Base/Data/Core/IBinding2.cs index 5e57bedd090..1dcbc15b0c7 100644 --- a/src/Avalonia.Base/Data/Core/IBinding2.cs +++ b/src/Avalonia.Base/Data/Core/IBinding2.cs @@ -15,6 +15,6 @@ internal interface IBinding2 : IBinding { BindingExpressionBase Instance( AvaloniaObject target, - AvaloniaProperty targetProperty, + AvaloniaProperty? targetProperty, object? anchor); } diff --git a/src/Avalonia.Base/Data/IndexerBinding.cs b/src/Avalonia.Base/Data/IndexerBinding.cs index 02bde6f774e..d12beb114bb 100644 --- a/src/Avalonia.Base/Data/IndexerBinding.cs +++ b/src/Avalonia.Base/Data/IndexerBinding.cs @@ -31,7 +31,7 @@ public IndexerBinding( return new InstancedBinding(expression, Mode, BindingPriority.LocalValue); } - BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty targetProperty, object? anchor) + BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty? targetProperty, object? anchor) { return new IndexerBindingExpression(Source, Property, target, targetProperty, Mode); } diff --git a/src/Avalonia.Base/Data/TemplateBinding.cs b/src/Avalonia.Base/Data/TemplateBinding.cs index fd3c0f5b62b..03176a78068 100644 --- a/src/Avalonia.Base/Data/TemplateBinding.cs +++ b/src/Avalonia.Base/Data/TemplateBinding.cs @@ -82,7 +82,7 @@ public TemplateBinding([InheritDataTypeFrom(InheritDataTypeFromScopeKind.Control return new(target, InstanceCore(), Mode, BindingPriority.Template); } - BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty property, object? anchor) + BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty? property, object? anchor) { return InstanceCore(); } diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/DynamicResourceExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/DynamicResourceExtension.cs index 7737aa19bf9..d6cd23a25d6 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/DynamicResourceExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/DynamicResourceExtension.cs @@ -56,7 +56,7 @@ public IBinding ProvideValue(IServiceProvider serviceProvider) return new InstancedBinding(target, expression, BindingMode.OneWay, _priority); } - BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty targetProperty, object? anchor) + BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty? targetProperty, object? anchor) { if (ResourceKey is null) throw new InvalidOperationException("DynamicResource must have a ResourceKey."); diff --git a/src/Markup/Avalonia.Markup/Data/BindingBase.cs b/src/Markup/Avalonia.Markup/Data/BindingBase.cs index 07f64689554..33113c33ca4 100644 --- a/src/Markup/Avalonia.Markup/Data/BindingBase.cs +++ b/src/Markup/Avalonia.Markup/Data/BindingBase.cs @@ -118,7 +118,7 @@ private protected (BindingMode, UpdateSourceTrigger) ResolveDefaultsFromMetadata return (mode, trigger); } - BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty property, object? anchor) + BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty? property, object? anchor) { return Instance(target, property, anchor); } From d267dbd5c1884f75110589bc9f94be768fd69058 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 3 Jul 2024 12:59:34 +0200 Subject: [PATCH 03/10] Attach needs to accept a null target property. It will need to be null for `MultiBinding`. --- .../Data/Core/UntypedBindingExpressionBase.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs b/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs index 1221bee8410..4646e12d07e 100644 --- a/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs +++ b/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using Avalonia.Data.Converters; @@ -204,7 +203,7 @@ internal override void Attach( internal void AttachAndStart( IBindingExpressionSink subscriber, AvaloniaObject target, - AvaloniaProperty targetProperty, + AvaloniaProperty? targetProperty, BindingPriority priority) { AttachCore(subscriber, null, target, targetProperty, priority); @@ -261,7 +260,7 @@ private void AttachCore( IBindingExpressionSink sink, ImmediateValueFrame? frame, AvaloniaObject target, - AvaloniaProperty targetProperty, + AvaloniaProperty? targetProperty, BindingPriority priority) { if (_sink is not null) @@ -273,7 +272,7 @@ private void AttachCore( _frame = frame; _target = new(target); TargetProperty = targetProperty; - TargetType = targetProperty.PropertyType; + TargetType = targetProperty?.PropertyType ?? typeof(object); Priority = priority; } From 218749112b1c08290a19f4b0f68dc529c40baa15 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 3 Jul 2024 13:51:56 +0200 Subject: [PATCH 04/10] Initial implementation of MultiBindingExpression. --- .../Data/Core/MultiBindingExpression.cs | 131 ++++++++++++++++++ .../Data/Core/UntypedBindingExpressionBase.cs | 3 + .../Avalonia.Markup/Data/MultiBinding.cs | 91 ++++-------- 3 files changed, 164 insertions(+), 61 deletions(-) create mode 100644 src/Avalonia.Base/Data/Core/MultiBindingExpression.cs diff --git a/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs b/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs new file mode 100644 index 00000000000..46c7f78f6db --- /dev/null +++ b/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Avalonia.Data.Core; + +internal class MultiBindingExpression : UntypedBindingExpressionBase, IBindingExpressionSink +{ + private static readonly object s_uninitialized = new object(); + private readonly IBinding[] _bindings; + private readonly IMultiValueConverter? _converter; + private readonly CultureInfo? _converterCulture; + private readonly object? _converterParameter; + private readonly UntypedBindingExpressionBase?[] _expressions; + private readonly object? _fallbackValue; + private readonly object? _targetNullValue; + private readonly object?[] _values; + private readonly ReadOnlyCollection _valuesView; + + public MultiBindingExpression( + BindingPriority priority, + IList bindings, + IMultiValueConverter? converter, + CultureInfo? converterCulture, + object? converterParameter, + object? fallbackValue, + object? targetNullValue) + : base(priority) + { + _bindings = [.. bindings]; + _converter = converter; + _converterCulture = converterCulture; + _converterParameter = converterParameter; + _expressions = new UntypedBindingExpressionBase[_bindings.Length]; + _fallbackValue = fallbackValue; + _targetNullValue = targetNullValue; + _values = new object?[_bindings.Length]; + _valuesView = new(_values); + +#if NETSTANDARD2_0 + for (var i = 0; i < _bindings.Length; ++i) + _values[i] = s_uninitialized; +#else + Array.Fill(_values, s_uninitialized); +#endif + } + + public override string Description => "MultiBinding"; + + protected override void StartCore() + { + if (!TryGetTarget(out var target)) + throw new AvaloniaInternalException("MultiBindingExpression has no target."); + + for (var i = 0; i < _bindings.Length; ++i) + { + var binding = _bindings[i]; + + if (binding is not IBinding2 b) + throw new NotSupportedException($"Unsupported IBinding implementation '{binding}'."); + + var expression = b.Instance(target, null, null); + + if (expression is not UntypedBindingExpressionBase e) + throw new NotSupportedException($"Unsupported BindingExpressionBase implementation '{expression}'."); + + _expressions[i] = e; + e.AttachAndStart(this, target, null, Priority); + } + } + + protected override void StopCore() + { + for (var i = 0; i < _expressions.Length; ++i) + { + _expressions[i]?.Dispose(); + _expressions[i] = null; + _values[i] = s_uninitialized; + } + } + + void IBindingExpressionSink.OnChanged( + UntypedBindingExpressionBase instance, + bool hasValueChanged, + bool hasErrorChanged, + object? value, + BindingError? error) + { + var i = Array.IndexOf(_expressions, instance); + Debug.Assert(i != -1); + + _values[i] = BindingNotification.ExtractValue(value); + PublishValue(); + } + + void IBindingExpressionSink.OnCompleted(UntypedBindingExpressionBase instance) + { + // Nothing to do here. + } + + private void PublishValue() + { + foreach (var v in _values) + { + if (v == s_uninitialized) + return; + } + + var culture = _converterCulture ?? CultureInfo.CurrentCulture; + + if (_converter is not null) + { + var converted = _converter.Convert(_valuesView, TargetType, _converterParameter, culture); + + converted = BindingNotification.ExtractValue(converted); + + if (converted == null) + converted = _targetNullValue; + if (converted == AvaloniaProperty.UnsetValue) + converted = _fallbackValue; + PublishValue(converted); + } + else + { + PublishValue(_valuesView); + } + } +} diff --git a/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs b/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs index 4646e12d07e..5e51881cf04 100644 --- a/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs +++ b/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using Avalonia.Data.Converters; @@ -408,6 +409,8 @@ protected void Log(AvaloniaObject target, string error, LogEventLevel level = Lo /// The new binding or data validation error. private protected void PublishValue(object? value, BindingError? error = null) { + Debug.Assert(value is not BindingNotification); + if (!IsRunning) return; diff --git a/src/Markup/Avalonia.Markup/Data/MultiBinding.cs b/src/Markup/Avalonia.Markup/Data/MultiBinding.cs index 5128232dc78..f894300a07e 100644 --- a/src/Markup/Avalonia.Markup/Data/MultiBinding.cs +++ b/src/Markup/Avalonia.Markup/Data/MultiBinding.cs @@ -6,6 +6,7 @@ using Avalonia.Data.Converters; using Avalonia.Metadata; using Avalonia.Data.Core; +using System.ComponentModel; namespace Avalonia.Data { @@ -25,6 +26,16 @@ public class MultiBinding : IBinding2 /// public IMultiValueConverter? Converter { get; set; } + /// + /// Gets or sets the culture in which to evaluate the converter. + /// + /// The default value is null. + /// + /// If this property is not set then will be used. + /// + [TypeConverter(typeof(CultureInfoIetfLanguageTagConverter))] + public CultureInfo? ConverterCulture { get; set; } + /// /// Gets or sets a parameter to pass to . /// @@ -73,33 +84,25 @@ public MultiBinding() object? anchor = null, bool enableDataValidation = false) { - var input = InstanceCore(target, targetProperty); - var mode = Mode == BindingMode.Default ? - targetProperty?.GetMetadata(target).DefaultBindingMode : Mode; - - switch (mode) - { - case BindingMode.OneTime: - return InstancedBinding.OneTime(input, Priority); - case BindingMode.OneWay: - return InstancedBinding.OneWay(input, Priority); - default: - throw new NotSupportedException( - "MultiBinding currently only supports OneTime and OneWay BindingMode."); - } + var expression = InstanceCore(target, targetProperty); + return new InstancedBinding(target, expression, Mode, Priority); } - BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty property, object? anchor) + BindingExpressionBase IBinding2.Instance( + AvaloniaObject target, + AvaloniaProperty? targetProperty, + object? anchor) { - // TODO: Implement MultiBindingExpression instead of wrapping an observable. - var o = InstanceCore(target, property); - return new UntypedObservableBindingExpression(o, BindingPriority.LocalValue); + return InstanceCore(target, targetProperty); } - private IObservable InstanceCore(AvaloniaObject target, AvaloniaProperty? targetProperty) + private MultiBindingExpression InstanceCore( + AvaloniaObject target, + AvaloniaProperty? targetProperty) { var targetType = targetProperty?.PropertyType ?? typeof(object); var converter = Converter; + // We only respect `StringFormat` if the type of the property we're assigning to will // accept a string. Note that this is slightly different to WPF in that WPF only applies // `StringFormat` for target type `string` (not `object`). @@ -109,48 +112,14 @@ BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty converter = new StringFormatMultiValueConverter(StringFormat!, converter); } - var children = Bindings.Select(x => x.Initiate(target, null)); - - return children.Select(x => x?.Source) - .Where(x => x is not null)! - .CombineLatest() - .Select(x => ConvertValue(x, targetType, converter)) - .Where(x => x != BindingOperations.DoNothing); - } - - private object ConvertValue(IList values, Type targetType, IMultiValueConverter? converter) - { - for (var i = 0; i < values.Count; ++i) - { - if (values[i] is BindingNotification notification) - { - values[i] = notification.Value; - } - } - - var culture = CultureInfo.CurrentCulture; - values = new System.Collections.ObjectModel.ReadOnlyCollection(values); - object? converted; - if (converter != null) - { - converted = converter.Convert(values, targetType, ConverterParameter, culture); - } - else - { - converted = values; - } - - if (converted == null) - { - converted = TargetNullValue; - } - - if (converted == AvaloniaProperty.UnsetValue) - { - converted = FallbackValue; - } - - return converted; + return new MultiBindingExpression( + Priority, + Bindings, + converter, + ConverterCulture, + ConverterParameter, + FallbackValue, + TargetNullValue); } } } From 833636da4f36976deefcd96f12dc6d4c74b11821 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 3 Jul 2024 13:54:24 +0200 Subject: [PATCH 05/10] Fix failing template binding test. Only publish unset value if we've already published a value. --- src/Avalonia.Base/Data/TemplateBinding.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Data/TemplateBinding.cs b/src/Avalonia.Base/Data/TemplateBinding.cs index 03176a78068..2b0e054c07c 100644 --- a/src/Avalonia.Base/Data/TemplateBinding.cs +++ b/src/Avalonia.Base/Data/TemplateBinding.cs @@ -21,6 +21,7 @@ public partial class TemplateBinding : UntypedBindingExpressionBase, IDisposable { private bool _isSetterValue; + private bool _hasPublishedValue; public TemplateBinding() : base(BindingPriority.Template) @@ -108,6 +109,7 @@ internal override bool WriteValueToSource(object? value) protected override void StartCore() { + _hasPublishedValue = false; OnTemplatedParentChanged(); if (TryGetTarget(out var target)) target.PropertyChanged += OnTargetPropertyChanged; @@ -199,11 +201,12 @@ private void PublishValue() value = ConvertToTargetType(value); PublishValue(value, error); + _hasPublishedValue = true; if (Mode == BindingMode.OneTime) Stop(); } - else + else if (_hasPublishedValue) { PublishValue(AvaloniaProperty.UnsetValue); } From bc4355df0df411f2b77fa3606c9581ea33736a4d Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 3 Jul 2024 14:07:08 +0200 Subject: [PATCH 06/10] Enabled nullability annotations. --- .../Data/MultiBindingTests.cs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs b/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs index 0a20c25b15f..d8fc9bae115 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs @@ -3,12 +3,13 @@ using System.Globalization; using System.Linq; using System.Reactive.Linq; -using Moq; -using Avalonia.Controls; -using Xunit; using System.Threading.Tasks; -using Avalonia.Data.Converters; +using Avalonia.Controls; using Avalonia.Data; +using Avalonia.Data.Converters; +using Xunit; + +#nullable enable namespace Avalonia.Markup.UnitTests.Data { @@ -30,7 +31,7 @@ public async Task OneWay_Binding_Should_Be_Set_Up() }; var target = new Control { DataContext = source }; - var observable = binding.Initiate(target, null).Source; + var observable = binding.Initiate(target, null)!.Source; var result = await observable.Take(1); Assert.Equal("1,2,3", result); @@ -59,7 +60,7 @@ public async Task Nested_MultiBinding_Should_Be_Set_Up() }; var target = new Control { DataContext = source }; - var observable = binding.Initiate(target, null).Source; + var observable = binding.Initiate(target, null)!.Source; var result = await observable.Take(1); Assert.Equal("1,2,3", result); @@ -204,7 +205,7 @@ public void Converter_Can_Return_BindingNotification() private class ConcatConverter : IMultiValueConverter { - public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) { return string.Join(",", values); } @@ -212,7 +213,7 @@ public object Convert(IList values, Type targetType, object parameter, C private class UnsetValueConverter : IMultiValueConverter { - public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) { return AvaloniaProperty.UnsetValue; } @@ -220,7 +221,7 @@ public object Convert(IList values, Type targetType, object parameter, C private class NullValueConverter : IMultiValueConverter { - public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) { return null; } @@ -228,7 +229,7 @@ public object Convert(IList values, Type targetType, object parameter, C private class BindingNotificationConverter : IMultiValueConverter { - public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) { return new BindingNotification( new ArgumentException(), From 9cfdce848dafbc6838f5beb7dc0d2351b127dd96 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 3 Jul 2024 14:12:21 +0200 Subject: [PATCH 07/10] Added passing test for #16084. --- .../Data/MultiBindingTests.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs b/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs index d8fc9bae115..9af7323ed48 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs @@ -7,6 +7,7 @@ using Avalonia.Controls; using Avalonia.Data; using Avalonia.Data.Converters; +using Avalonia.UnitTests; using Xunit; #nullable enable @@ -203,6 +204,46 @@ public void Converter_Can_Return_BindingNotification() Assert.Equal("1,2,3-BindingNotification", target.Text); } + [Fact] + public void Converter_Should_Be_Called_On_PropertyChanged_Even_If_Property_Not_Changed() + { + // Issue #16084 + var data = new TestModel(); + var target = new TextBlock { DataContext = data }; + + var binding = new MultiBinding + { + Converter = new TestModelMemberConverter(), + Bindings = + { + new Binding(), + new Binding(nameof(data.NotifyingValue)), + }, + }; + + target.Bind(TextBlock.TextProperty, binding); + Assert.Equal("0", target.Text); + + data.NonNotifyingValue = 1; + Assert.Equal("0", target.Text); + + data.NotifyingValue = new object(); + Assert.Equal("1", target.Text); + } + + private partial class TestModel : NotifyingBase + { + private object? _notifyingValue; + + public int? NonNotifyingValue { get; set; } = 0; + + public object? NotifyingValue + { + get => _notifyingValue; + set => SetField(ref _notifyingValue, value); + } + } + private class ConcatConverter : IMultiValueConverter { public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) @@ -237,5 +278,18 @@ private class BindingNotificationConverter : IMultiValueConverter string.Join(",", values) + "-BindingNotification"); } } + + private class TestModelMemberConverter : IMultiValueConverter + { + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values[0] is not TestModel model) + { + return string.Empty; + } + + return model.NonNotifyingValue.ToString(); + } + } } } From b2285e1877767c9740d1e0aefeef56c9b2780b99 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 3 Jul 2024 16:47:51 +0200 Subject: [PATCH 08/10] Remove obsolete API usages. --- .../Data/MultiBindingTests.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs b/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs index 9af7323ed48..45c7c8daa7d 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests.cs @@ -17,7 +17,7 @@ namespace Avalonia.Markup.UnitTests.Data public class MultiBindingTests { [Fact] - public async Task OneWay_Binding_Should_Be_Set_Up() + public void OneWay_Binding_Should_Be_Set_Up() { var source = new { A = 1, B = 2, C = 3 }; var binding = new MultiBinding @@ -32,14 +32,13 @@ public async Task OneWay_Binding_Should_Be_Set_Up() }; var target = new Control { DataContext = source }; - var observable = binding.Initiate(target, null)!.Source; - var result = await observable.Take(1); + target.Bind(Control.TagProperty, binding); - Assert.Equal("1,2,3", result); + Assert.Equal("1,2,3", target.Tag); } [Fact] - public async Task Nested_MultiBinding_Should_Be_Set_Up() + public void Nested_MultiBinding_Should_Be_Set_Up() { var source = new { A = 1, B = 2, C = 3 }; var binding = new MultiBinding @@ -61,10 +60,9 @@ public async Task Nested_MultiBinding_Should_Be_Set_Up() }; var target = new Control { DataContext = source }; - var observable = binding.Initiate(target, null)!.Source; - var result = await observable.Take(1); + target.Bind(Control.TagProperty, binding); - Assert.Equal("1,2,3", result); + Assert.Equal("1,2,3", target.Tag); } [Fact] From 4f928e4dc318f70f798c043388357c9821d86d78 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 5 Aug 2024 12:09:37 +0200 Subject: [PATCH 09/10] Bind to Tag not Text. Prevents test passing when it shouldn't. See https://github.com/AvaloniaUI/Avalonia/pull/16219#discussion_r1665466968 --- .../Converters/MultiValueConverterTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/MultiValueConverterTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/MultiValueConverterTests.cs index 466ae1bf7cd..d4300a111a6 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/MultiValueConverterTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/MultiValueConverterTests.cs @@ -21,12 +21,12 @@ public void MultiValueConverter_Special_Values_Work() xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml' xmlns:c='clr-namespace:Avalonia.Markup.Xaml.UnitTests.Converters;assembly=Avalonia.Markup.Xaml.UnitTests'> - + - + "; var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); @@ -35,13 +35,13 @@ public void MultiValueConverter_Special_Values_Work() window.ApplyTemplate(); window.DataContext = Tuple.Create(2, 2); - Assert.Equal("foo", textBlock.Text); + Assert.Equal("foo", textBlock.Tag); window.DataContext = Tuple.Create(-3, 3); - Assert.Equal("foo", textBlock.Text); + Assert.Equal("foo", textBlock.Tag); window.DataContext = Tuple.Create(0, 2); - Assert.Equal("bar", textBlock.Text); + Assert.Equal("bar", textBlock.Tag); } } } From 57f0f9f1a13bde4bc6fbc624012f0109ba9e7b44 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 5 Aug 2024 12:13:29 +0200 Subject: [PATCH 10/10] Handle DoNothing in MultiBindingExpression. --- .../Data/Core/MultiBindingExpression.cs | 16 +++++++++------- .../Data/Core/UntypedBindingExpressionBase.cs | 1 + 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs b/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs index 46c7f78f6db..cd012f9b213 100644 --- a/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs +++ b/src/Avalonia.Base/Data/Core/MultiBindingExpression.cs @@ -109,19 +109,21 @@ private void PublishValue() return; } - var culture = _converterCulture ?? CultureInfo.CurrentCulture; - if (_converter is not null) { + var culture = _converterCulture ?? CultureInfo.CurrentCulture; var converted = _converter.Convert(_valuesView, TargetType, _converterParameter, culture); converted = BindingNotification.ExtractValue(converted); - if (converted == null) - converted = _targetNullValue; - if (converted == AvaloniaProperty.UnsetValue) - converted = _fallbackValue; - PublishValue(converted); + if (converted != BindingOperations.DoNothing) + { + if (converted == null) + converted = _targetNullValue; + if (converted == AvaloniaProperty.UnsetValue) + converted = _fallbackValue; + PublishValue(converted); + } } else { diff --git a/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs b/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs index 5e51881cf04..6b52bbe259b 100644 --- a/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs +++ b/src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs @@ -410,6 +410,7 @@ protected void Log(AvaloniaObject target, string error, LogEventLevel level = Lo private protected void PublishValue(object? value, BindingError? error = null) { Debug.Assert(value is not BindingNotification); + Debug.Assert(value != BindingOperations.DoNothing); if (!IsRunning) return;