diff --git a/src/Avalonia.Base/Data/Converters/StringFormatValueConverter.cs b/src/Avalonia.Base/Data/Converters/StringFormatValueConverter.cs new file mode 100644 index 0000000000..fc695762b8 --- /dev/null +++ b/src/Avalonia.Base/Data/Converters/StringFormatValueConverter.cs @@ -0,0 +1,49 @@ +using System; +using System.Globalization; + +namespace Avalonia.Data.Converters +{ + /// + /// A value converter which calls + /// + public class StringFormatValueConverter : IValueConverter + { + /// + /// Initializes a new instance of the class. + /// + /// The format string. + /// + /// An optional inner converter to be called before the format takes place. + /// + public StringFormatValueConverter(string format, IValueConverter inner) + { + Contract.Requires(format != null); + + Format = format; + Inner = inner; + } + + /// + /// Gets an inner value converter which will be called before the string format takes place. + /// + public IValueConverter Inner { get; } + + /// + /// Gets the format string. + /// + public string Format { get; } + + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + value = Inner?.Convert(value, targetType, parameter, culture) ?? value; + return string.Format(culture, Format, value); + } + + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException("Two way bindings are not supported with a string format"); + } + } +} diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs index 08ba26573f..1ceef5c824 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs @@ -43,8 +43,9 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions Path = Path, Priority = Priority, Source = Source, + StringFormat = StringFormat, RelativeSource = RelativeSource, - DefaultAnchor = new WeakReference(GetDefaultAnchor((ITypeDescriptorContext)serviceProvider)) + DefaultAnchor = new WeakReference(GetDefaultAnchor(descriptorContext)) }; } @@ -79,6 +80,8 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions public object Source { get; set; } + public string StringFormat { get; set; } + public RelativeSource RelativeSource { get; set; } } } diff --git a/src/Markup/Avalonia.Markup/Data/Binding.cs b/src/Markup/Avalonia.Markup/Data/Binding.cs index 03678c3b5a..4f18c682b4 100644 --- a/src/Markup/Avalonia.Markup/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup/Data/Binding.cs @@ -84,6 +84,11 @@ namespace Avalonia.Data /// public object Source { get; set; } + /// + /// Gets or sets the string format. + /// + public string StringFormat { get; set; } + public WeakReference DefaultAnchor { get; set; } /// @@ -181,11 +186,23 @@ namespace Avalonia.Data fallback = null; } + var converter = Converter; + var targetType = targetProperty?.PropertyType ?? typeof(object); + + // 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`). + if (!string.IsNullOrWhiteSpace(StringFormat) && + (targetType == typeof(string) || targetType == typeof(object))) + { + converter = new StringFormatValueConverter(StringFormat, converter); + } + var subject = new BindingExpression( observer, - targetProperty?.PropertyType ?? typeof(object), + targetType, fallback, - Converter ?? DefaultValueConverter.Instance, + converter ?? DefaultValueConverter.Instance, ConverterParameter, Priority); diff --git a/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Converters.cs b/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Converters.cs new file mode 100644 index 0000000000..123aadfda5 --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Converters.cs @@ -0,0 +1,146 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Data.Core; +using Xunit; + +namespace Avalonia.Markup.UnitTests.Data +{ + public class BindingTests_Converters + { + [Fact] + public void Converter_Should_Be_Used() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + Converter = StringConverters.NullOrEmpty, + }; + + var expressionObserver = (BindingExpression)target.Initiate( + textBlock, + TextBlock.TextProperty).Observable; + + Assert.Same(StringConverters.NullOrEmpty, expressionObserver.Converter); + } + + public class When_Binding_To_String + { + [Fact] + public void StringFormatConverter_Should_Be_Used_When_Binding_Has_StringFormat() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + StringFormat = "Hello {0}", + }; + + var expressionObserver = (BindingExpression)target.Initiate( + textBlock, + TextBlock.TextProperty).Observable; + + Assert.IsType(expressionObserver.Converter); + } + } + + public class When_Binding_To_Object + { + [Fact] + public void StringFormatConverter_Should_Be_Used_When_Binding_Has_StringFormat() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + StringFormat = "Hello {0}", + }; + + var expressionObserver = (BindingExpression)target.Initiate( + textBlock, + TextBlock.TagProperty).Observable; + + Assert.IsType(expressionObserver.Converter); + } + } + + public class When_Binding_To_Non_String_Or_Object + { + [Fact] + public void StringFormatConverter_Should_Not_Be_Used_When_Binding_Has_StringFormat() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + StringFormat = "Hello {0}", + }; + + var expressionObserver = (BindingExpression)target.Initiate( + textBlock, + TextBlock.MarginProperty).Observable; + + Assert.Same(DefaultValueConverter.Instance, expressionObserver.Converter); + } + } + + [Fact] + public void StringFormat_Should_Be_Applied() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + StringFormat = "Hello {0}", + }; + + textBlock.Bind(TextBlock.TextProperty, target); + + Assert.Equal("Hello foo", textBlock.Text); + } + + [Fact] + public void StringFormat_Should_Be_Applied_After_Converter() + { + var textBlock = new TextBlock + { + DataContext = new Class1(), + }; + + var target = new Binding(nameof(Class1.Foo)) + { + Converter = StringConverters.NotNullOrEmpty, + StringFormat = "Hello {0}", + }; + + textBlock.Bind(TextBlock.TextProperty, target); + + Assert.Equal("Hello True", textBlock.Text); + } + + private class Class1 + { + public string Foo { get; set; } = "foo"; + } + } +} diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs index f327e9ccf2..fef9dfb675 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs @@ -308,5 +308,27 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml Assert.Equal(5.6, AttachedPropertyOwner.GetDouble(textBlock)); } } + + [Fact] + public void Binding_To_TextBlock_Text_With_StringConverter_Works() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var xaml = @" + + +"; + var loader = new AvaloniaXamlLoader(); + var window = (Window)loader.Load(xaml); + var textBlock = window.FindControl("textBlock"); + + textBlock.DataContext = new { Foo = "world" }; + window.ApplyTemplate(); + + Assert.Equal("Hello world", textBlock.Text); + } + } } }