diff --git a/src/Avalonia.Base/Data/BindingExpressionBase.cs b/src/Avalonia.Base/Data/BindingExpressionBase.cs
index 24bed9ddd5..6e774405fa 100644
--- a/src/Avalonia.Base/Data/BindingExpressionBase.cs
+++ b/src/Avalonia.Base/Data/BindingExpressionBase.cs
@@ -17,6 +17,21 @@ public abstract class BindingExpressionBase : IDisposable, ISetterInstance
GC.SuppressFinalize(this);
}
+ ///
+ /// Sends the current binding target value to the binding source property in
+ /// or bindings.
+ ///
+ ///
+ /// This method does nothing when the Mode of the binding is not
+ /// or .
+ ///
+ /// If the UpdateSourceTrigger value of your binding is set to
+ /// , you must call the
+ /// method or the changes will not propagate back to the
+ /// source.
+ ///
+ public virtual void UpdateSource() { }
+
///
/// Forces a data transfer from the binding source to the binding target.
///
diff --git a/src/Avalonia.Base/Data/Core/BindingExpression.cs b/src/Avalonia.Base/Data/Core/BindingExpression.cs
index f4fd076f90..ae884fd37f 100644
--- a/src/Avalonia.Base/Data/Core/BindingExpression.cs
+++ b/src/Avalonia.Base/Data/Core/BindingExpression.cs
@@ -8,6 +8,8 @@ using System.Text;
using Avalonia.Data.Converters;
using Avalonia.Data.Core.ExpressionNodes;
using Avalonia.Data.Core.Parsers;
+using Avalonia.Input;
+using Avalonia.Interactivity;
using Avalonia.Logging;
using Avalonia.Utilities;
@@ -49,6 +51,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
///
/// A final type converter to be run on the produced value.
///
+ /// The trigger for updating the source value.
public BindingExpression(
object? source,
IReadOnlyList nodes,
@@ -61,11 +64,14 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
BindingPriority priority = BindingPriority.LocalValue,
string? stringFormat = null,
object? targetNullValue = null,
- TargetTypeConverter? targetTypeConverter = null)
+ TargetTypeConverter? targetTypeConverter = null,
+ UpdateSourceTrigger updateSourceTrigger = UpdateSourceTrigger.PropertyChanged)
: base(priority, enableDataValidation)
{
if (mode == BindingMode.Default)
throw new ArgumentException("Binding mode cannot be Default.", nameof(mode));
+ if (updateSourceTrigger == UpdateSourceTrigger.Default)
+ throw new ArgumentException("UpdateSourceTrigger cannot be Default.", nameof(updateSourceTrigger));
if (source == AvaloniaProperty.UnsetValue)
source = null;
@@ -79,8 +85,9 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
converterCulture is not null ||
converterParameter is not null ||
fallbackValue != AvaloniaProperty.UnsetValue ||
+ !string.IsNullOrWhiteSpace(stringFormat) ||
(targetNullValue is not null && targetNullValue != AvaloniaProperty.UnsetValue) ||
- !string.IsNullOrWhiteSpace(stringFormat))
+ updateSourceTrigger is not UpdateSourceTrigger.PropertyChanged)
{
_uncommon = new()
{
@@ -88,13 +95,14 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
_converterCulture = converterCulture,
_converterParameter = converterParameter,
_fallbackValue = fallbackValue,
- _targetNullValue = targetNullValue ?? AvaloniaProperty.UnsetValue,
_stringFormat = stringFormat switch
{
string s when string.IsNullOrWhiteSpace(s) => null,
string s when !s.Contains('{') => $"{{0:{stringFormat}}}",
_ => stringFormat,
},
+ _targetNullValue = targetNullValue ?? AvaloniaProperty.UnsetValue,
+ _updateSourceTrigger = updateSourceTrigger,
};
}
@@ -127,9 +135,16 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
public CultureInfo ConverterCulture => _uncommon?._converterCulture ?? CultureInfo.CurrentCulture;
public object? ConverterParameter => _uncommon?._converterParameter;
public object? FallbackValue => _uncommon is not null ? _uncommon._fallbackValue : AvaloniaProperty.UnsetValue;
- public object? TargetNullValue => _uncommon?._targetNullValue ?? AvaloniaProperty.UnsetValue;
public ExpressionNode LeafNode => _nodes[_nodes.Count - 1];
public string? StringFormat => _uncommon?._stringFormat;
+ public object? TargetNullValue => _uncommon?._targetNullValue ?? AvaloniaProperty.UnsetValue;
+ public UpdateSourceTrigger UpdateSourceTrigger => _uncommon?._updateSourceTrigger ?? UpdateSourceTrigger.PropertyChanged;
+
+ public override void UpdateSource()
+ {
+ if (_mode is BindingMode.TwoWay or BindingMode.OneWayToSource)
+ WriteTargetValueToSource();
+ }
public override void UpdateTarget()
{
@@ -353,7 +368,12 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
if (_mode is BindingMode.OneWayToSource)
PublishValue(target.GetValue(TargetProperty));
- target.PropertyChanged += OnTargetPropertyChanged;
+ var trigger = UpdateSourceTrigger;
+
+ if (trigger is UpdateSourceTrigger.PropertyChanged)
+ target.PropertyChanged += OnTargetPropertyChanged;
+ else if (trigger is UpdateSourceTrigger.LostFocus && target is IInputElement ie)
+ ie.LostFocus += OnTargetLostFocus;
}
}
else
@@ -370,7 +390,12 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
if (_mode is BindingMode.TwoWay or BindingMode.OneWayToSource &&
TryGetTarget(out var target))
{
- target.PropertyChanged -= OnTargetPropertyChanged;
+ var trigger = UpdateSourceTrigger;
+
+ if (trigger is UpdateSourceTrigger.PropertyChanged)
+ target.PropertyChanged += OnTargetPropertyChanged;
+ else if (trigger is UpdateSourceTrigger.LostFocus && target is IInputElement ie)
+ ie.LostFocus += OnTargetLostFocus;
}
}
@@ -450,7 +475,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
private void WriteTargetValueToSource()
{
- Debug.Assert(_mode == BindingMode.OneWayToSource);
+ Debug.Assert(_mode is BindingMode.TwoWay or BindingMode.OneWayToSource);
if (TryGetTarget(out var target) &&
TargetProperty is not null &&
@@ -461,14 +486,20 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
}
}
+ private void OnTargetLostFocus(object? sender, RoutedEventArgs e)
+ {
+ Debug.Assert(UpdateSourceTrigger is UpdateSourceTrigger.LostFocus);
+
+ WriteTargetValueToSource();
+ }
+
private void OnTargetPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
Debug.Assert(_mode is BindingMode.TwoWay or BindingMode.OneWayToSource);
+ Debug.Assert(UpdateSourceTrigger is UpdateSourceTrigger.PropertyChanged);
if (e.Property == TargetProperty)
- {
WriteValueToSource(e.NewValue);
- }
}
private object? ConvertFallback(object? fallback, string fallbackName)
@@ -512,5 +543,6 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
public object? _fallbackValue;
public string? _stringFormat;
public object? _targetNullValue;
+ public UpdateSourceTrigger _updateSourceTrigger;
}
}
diff --git a/src/Avalonia.Base/Data/UpdateSourceTrigger.cs b/src/Avalonia.Base/Data/UpdateSourceTrigger.cs
new file mode 100644
index 0000000000..846671b92f
--- /dev/null
+++ b/src/Avalonia.Base/Data/UpdateSourceTrigger.cs
@@ -0,0 +1,29 @@
+namespace Avalonia.Data;
+
+///
+/// Describes the timing of binding source updates.
+///
+public enum UpdateSourceTrigger
+{
+ ///
+ /// The default value of the binding target property.
+ /// This currently defaults to .
+ ///
+ Default,
+
+ ///
+ /// Updates the binding source immediately whenever the binding target property changes.
+ ///
+ PropertyChanged,
+
+ ///
+ /// Updates the binding source whenever the binding target element loses focus.
+ ///
+ LostFocus,
+
+ ///
+ /// Updates the binding source only when you call the
+ /// method.
+ ///
+ Explicit,
+}
diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs
index 9b933450ab..0c302dc0ac 100644
--- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs
+++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs
@@ -114,7 +114,9 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions
? sn.SelectSource(Source, target, DefaultAnchor?.Target)
: Source != AvaloniaProperty.UnsetValue ? Source : target;
- return new BindingExpression(
+ var (mode, trigger) = ResolveDefaultsFromMetadata(target, targetProperty);
+
+ return new BindingExpression(
source,
nodes,
FallbackValue,
@@ -122,10 +124,11 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions
converterCulture: ConverterCulture,
converterParameter: ConverterParameter,
enableDataValidation: enableDataValidation,
- mode: ResolveBindingMode(target, targetProperty),
+ mode: mode,
stringFormat: StringFormat,
targetNullValue: TargetNullValue,
- targetTypeConverter: TargetTypeConverter.GetDefaultConverter());
+ targetTypeConverter: TargetTypeConverter.GetDefaultConverter(),
+ updateSourceTrigger: trigger);
}
}
}
diff --git a/src/Markup/Avalonia.Markup/Data/Binding.cs b/src/Markup/Avalonia.Markup/Data/Binding.cs
index 9dc78483d4..c310257199 100644
--- a/src/Markup/Avalonia.Markup/Data/Binding.cs
+++ b/src/Markup/Avalonia.Markup/Data/Binding.cs
@@ -156,6 +156,8 @@ namespace Avalonia.Data
sn.SelectSource(Source, target, DefaultAnchor?.Target) :
Source != AvaloniaProperty.UnsetValue ? Source : target;
+ var (mode, trigger) = ResolveDefaultsFromMetadata(target, targetProperty);
+
return new BindingExpression(
source,
nodes,
@@ -164,10 +166,11 @@ namespace Avalonia.Data
converterCulture: ConverterCulture,
converterParameter: ConverterParameter,
enableDataValidation: enableDataValidation,
- mode: ResolveBindingMode(target, targetProperty),
+ mode: mode,
stringFormat: StringFormat,
targetNullValue: TargetNullValue,
- targetTypeConverter: TargetTypeConverter.GetReflectionConverter());
+ targetTypeConverter: TargetTypeConverter.GetReflectionConverter(),
+ updateSourceTrigger: trigger);
}
private INameScope? GetNameScope()
diff --git a/src/Markup/Avalonia.Markup/Data/BindingBase.cs b/src/Markup/Avalonia.Markup/Data/BindingBase.cs
index 49504156cc..598b056165 100644
--- a/src/Markup/Avalonia.Markup/Data/BindingBase.cs
+++ b/src/Markup/Avalonia.Markup/Data/BindingBase.cs
@@ -75,6 +75,12 @@ namespace Avalonia.Data
///
public string? StringFormat { get; set; }
+ ///
+ /// Gets or sets a value that determines the timing of binding source updates for
+ /// and bindings.
+ ///
+ public UpdateSourceTrigger UpdateSourceTrigger { get; set; }
+
public WeakReference? DefaultAnchor { get; set; }
public WeakReference? NameScope { get; set; }
@@ -90,13 +96,23 @@ namespace Avalonia.Data
private protected abstract BindingExpressionBase Instance(AvaloniaProperty targetProperty, AvaloniaObject target);
- private protected BindingMode ResolveBindingMode(AvaloniaObject target, AvaloniaProperty? targetProperty)
+ private protected (BindingMode, UpdateSourceTrigger) ResolveDefaultsFromMetadata(
+ AvaloniaObject target,
+ AvaloniaProperty? targetProperty)
{
- if (Mode != BindingMode.Default)
- return Mode;
- if (targetProperty is null)
- return BindingMode.OneWay;
- return targetProperty.GetMetadata(target.GetType()).DefaultBindingMode;
+ var mode = Mode;
+ var trigger = UpdateSourceTrigger == UpdateSourceTrigger.Default ?
+ UpdateSourceTrigger.PropertyChanged : UpdateSourceTrigger;
+
+ if (mode == BindingMode.Default)
+ {
+ if (targetProperty?.GetMetadata(target.GetType()) is { } metadata)
+ mode = metadata.DefaultBindingMode;
+ else
+ mode = BindingMode.OneWay;
+ }
+
+ return (mode, trigger);
}
BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty property) => Instance(property, target);
diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests_UpdateSourceTrigger.cs b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests_UpdateSourceTrigger.cs
new file mode 100644
index 0000000000..985e293b4f
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests_UpdateSourceTrigger.cs
@@ -0,0 +1,150 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Data.Core
+{
+ [InvariantCulture]
+ public class BindingExpressionTests_UpdateSourceTrigger
+ {
+ [Fact]
+ public void TwoWay_PropertyChanged_Should_Update_Source_On_Property_Changed()
+ {
+ using var app = Start();
+ var data = new ViewModel();
+ var binding = new Binding
+ {
+ Path = "Foo",
+ UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
+ };
+
+ var target = new TextBox { DataContext = data };
+ target.Bind(TextBox.TextProperty, binding);
+
+ var root = new TestRoot(target);
+
+ Assert.Equal("foo", target.Text);
+ Assert.Equal("foo", data.Foo);
+
+ target.Text = "bar";
+
+ Assert.Equal("bar", target.Text);
+ Assert.Equal("bar", data.Foo);
+ }
+
+ [Fact]
+ public void TwoWay_LostFocus_Should_Update_Source_On_Lost_Focus()
+ {
+ using var app = Start();
+ var data = new ViewModel();
+ var binding = new Binding
+ {
+ Path = "Foo",
+ UpdateSourceTrigger = UpdateSourceTrigger.LostFocus,
+ };
+
+ var target = new TextBox { DataContext = data };
+ target.Bind(TextBox.TextProperty, binding);
+
+ var root = new TestRoot(target);
+ root.Focusable = true;
+ target.Focus();
+
+ Assert.Equal("foo", target.Text);
+ Assert.Equal("foo", data.Foo);
+
+ target.Text = "bar";
+
+ Assert.Equal("bar", target.Text);
+ Assert.Equal("foo", data.Foo);
+
+ root.Focus();
+
+ Assert.Equal("bar", target.Text);
+ Assert.Equal("bar", data.Foo);
+ }
+
+ [Fact]
+ public void OneWayToSource_LostFocus_Should_Update_Source_On_Lost_Focus()
+ {
+ using var app = Start();
+ var data = new ViewModel();
+ var binding = new Binding
+ {
+ Path = "Foo",
+ Mode = BindingMode.OneWayToSource,
+ UpdateSourceTrigger = UpdateSourceTrigger.LostFocus,
+ };
+
+ var target = new TextBox { DataContext = data };
+ target.Bind(TextBox.TextProperty, binding);
+
+ var root = new TestRoot(target);
+ root.Focusable = true;
+ target.Focus();
+
+ Assert.Null(target.Text);
+ Assert.Null(data.Foo);
+
+ target.Text = "bar";
+
+ Assert.Equal("bar", target.Text);
+ Assert.Null(data.Foo);
+
+ root.Focus();
+
+ Assert.Equal("bar", target.Text);
+ Assert.Equal("bar", data.Foo);
+ }
+
+ [Fact]
+ public void TwoWay_Explicit_Should_Update_Source_On_Call_To_UpdateSource()
+ {
+ using var app = Start();
+ var data = new ViewModel();
+ var binding = new Binding
+ {
+ Path = "Foo",
+ UpdateSourceTrigger = UpdateSourceTrigger.Explicit,
+ };
+
+ var target = new TextBox { DataContext = data };
+ var expression = target.Bind(TextBox.TextProperty, binding);
+
+ var root = new TestRoot(target);
+ root.Focusable = true;
+ target.Focus();
+
+ Assert.Equal("foo", target.Text);
+ Assert.Equal("foo", data.Foo);
+
+ target.Text = "bar";
+
+ Assert.Equal("bar", target.Text);
+ Assert.Equal("foo", data.Foo);
+
+ root.Focus();
+
+ Assert.Equal("bar", target.Text);
+ Assert.Equal("foo", data.Foo);
+
+ expression.UpdateSource();
+
+ Assert.Equal("bar", target.Text);
+ Assert.Equal("bar", data.Foo);
+ }
+
+ private static IDisposable Start()
+ {
+ return UnitTestApplication.Start(TestServices.RealFocus);
+ }
+
+ private class ViewModel
+ {
+ public ViewModel(string foo = "foo") => Foo = foo;
+ public string Foo { get; set; }
+ }
+ }
+}