From 0e5b2455fd3e14d03c7e71cdfac0f63df746a329 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 18 Feb 2021 22:24:10 +0100 Subject: [PATCH] Added experimental typed bindings. Ported over from https://github.com/AvaloniaUI/Avalonia/pull/5510. --- .../Core/Parsers/ExpressionChainVisitor.cs | 49 +++ .../Data/Core/TypedBindingExpression`2.cs | 328 ++++++++++++++++++ .../Experimental/Data/DataContextRoot.cs | 52 +++ .../Data/ParentDataContextRoot.cs | 83 +++++ .../Experimental/Data/TypedBinding`1.cs | 63 ++++ .../Experimental/Data/TypedBinding`2.cs | 173 +++++++++ 6 files changed, 748 insertions(+) create mode 100644 src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/Parsers/ExpressionChainVisitor.cs create mode 100644 src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/TypedBindingExpression`2.cs create mode 100644 src/Avalonia.Controls.TreeDataGrid/Experimental/Data/DataContextRoot.cs create mode 100644 src/Avalonia.Controls.TreeDataGrid/Experimental/Data/ParentDataContextRoot.cs create mode 100644 src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs create mode 100644 src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`2.cs diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/Parsers/ExpressionChainVisitor.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/Parsers/ExpressionChainVisitor.cs new file mode 100644 index 00000000..4c45c398 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/Parsers/ExpressionChainVisitor.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +#nullable enable + +namespace Avalonia.Data.Core.Parsers +{ + public class ExpressionChainVisitor : ExpressionVisitor + { + private readonly LambdaExpression _rootExpression; + private List> _links = new List>(); + + public ExpressionChainVisitor(LambdaExpression expression) + { + _rootExpression = expression; + } + + public static Func[] Build(Expression> expression) + { + var visitor = new ExpressionChainVisitor(expression); + visitor.Visit(expression); + visitor._links.Reverse(); + return visitor._links.ToArray(); + } + + protected override Expression VisitMember(MemberExpression node) + { + if (node.Expression?.GetType().IsValueType == false) + { + var link = Expression.Lambda>(node.Expression, _rootExpression.Parameters); + _links.Add(link.Compile()); + } + + return base.VisitMember(node); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Object?.GetType().IsValueType == false) + { + var link = Expression.Lambda>(node.Object, _rootExpression.Parameters); + _links.Add(link.Compile()); + } + + return base.VisitMethodCall(node); + } + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/TypedBindingExpression`2.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/TypedBindingExpression`2.cs new file mode 100644 index 00000000..9a983989 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/Core/TypedBindingExpression`2.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Reactive.Subjects; +using Avalonia.Data; +using Avalonia.Reactive; +using Avalonia.Utilities; + +#nullable enable + +namespace Avalonia.Experimental.Data.Core +{ + /// + /// A binding expression which uses delegates to read and write a bound value. + /// + /// The input type of the binding expression. + /// The output type of the binding expression. + /// + /// A represents a typed binding which has been + /// instantiated on an object. + /// + public class TypedBindingExpression : LightweightObservableBase>, + ISubject>, + IDescription + where TIn : class + { + private readonly IObservable _rootSource; + private readonly Func _read; + private readonly Action? _write; + private readonly Link[]? _chain; + private readonly Optional _fallbackValue; + private IDisposable? _rootSourceSubsciption; + private WeakReference? _root; + private bool _rootHasFired; + private int _publishCount; + + public TypedBindingExpression( + IObservable root, + Func read, + Action? write, + Func[] links, + Optional fallbackValue) + { + _rootSource = root ?? throw new ArgumentNullException(nameof(root)); + _read = read; + _write = write; + _fallbackValue = fallbackValue; + + if (links != null) + { + _chain = new Link[links.Length]; + + for (var i = 0; i < links.Length; ++i) + { + _chain[i] = new Link(links[i]); + } + } + } + + public string Description => "TODO"; + + public void OnNext(BindingValue value) + { + if (value.HasValue && + _write is object && + _root is object && + _root.TryGetTarget(out var root)) + { + try + { + var c = _publishCount; + _write.Invoke(root, value.Value); + if (_publishCount == c) + PublishValue(); + } + catch + { + PublishValue(); + } + } + } + + void IObserver>.OnCompleted() + { + } + + void IObserver>.OnError(Exception error) + { + } + + protected override void Initialize() + { + _rootHasFired = false; + _rootSourceSubsciption = _rootSource.Subscribe(RootChanged); + } + + protected override void Deinitialize() + { + StopListeningToChain(0); + _rootSourceSubsciption?.Dispose(); + _rootSourceSubsciption = null; + } + + protected override void Subscribed(IObserver> observer, bool first) + { + // If this is the first subscription, `Initialize()` will have run which will + // already have produced a value. + if (!first) + { + var result = GetResult(); + + if (result.HasValue) + { + observer.OnNext(result.Value); + } + } + } + + private void RootChanged(TIn value) + { + _root = new WeakReference(value); + _rootHasFired = true; + StopListeningToChain(0); + ListenToChain(0); + PublishValue(); + } + + private void ListenToChain(int from) + { + if (_chain != null && _root != null && _root.TryGetTarget(out var root)) + { + object? last = null; + + try + { + for (var i = from; i < _chain.Length; ++i) + { + var o = _chain[i].Eval(root); + + if (o != last) + { + _chain[i].Value = new WeakReference(o); + + if (SubscribeToChanges(o)) + { + last = o; + } + } + } + } + catch + { + // Broken expression chain. + } + } + } + + private void StopListeningToChain(int from) + { + if (_chain != null && _root != null && _root.TryGetTarget(out _)) + { + for (var i = from; i < _chain.Length; ++i) + { + var link = _chain[i]; + + if (link.Value is object && link.Value.TryGetTarget(out var o)) + { + UnsubscribeToChanges(o); + } + } + } + } + + private bool SubscribeToChanges(object o) + { + if (o is null) + { + return false; + } + + var result = false; + + if (o is IAvaloniaObject ao) + { + WeakEventHandlerManager.Subscribe>( + ao, + nameof(ao.PropertyChanged), + ChainPropertyChanged); + result |= true; + } + else if (o is INotifyPropertyChanged inpc) + { + WeakEventHandlerManager.Subscribe>( + inpc, + nameof(inpc.PropertyChanged), + ChainPropertyChanged); + result |= true; + } + + if (o is INotifyCollectionChanged incc) + { + WeakEventHandlerManager.Subscribe>( + incc, + nameof(incc.CollectionChanged), + ChainCollectionChanged); + result |= true; + } + + return result; + } + + private void UnsubscribeToChanges(object o) + { + if (o is null) + { + return; + } + + if (o is IAvaloniaObject ao) + { + WeakEventHandlerManager.Unsubscribe>( + ao, + nameof(ao.PropertyChanged), + ChainPropertyChanged); + } + else if (o is INotifyPropertyChanged inpc) + { + WeakEventHandlerManager.Unsubscribe>( + inpc, + nameof(inpc.PropertyChanged), + ChainPropertyChanged); + } + + if (o is INotifyCollectionChanged incc) + { + WeakEventHandlerManager.Unsubscribe>( + incc, + nameof(incc.CollectionChanged), + ChainCollectionChanged); + } + } + + private BindingValue? GetResult() + { + if (_root != null && _root.TryGetTarget(out var root)) + { + try + { + var value = _read(root); + return new BindingValue(value); + } + catch (Exception e) + { + return BindingValue.BindingError(e, _fallbackValue); + } + } + else if (_rootHasFired) + { + return BindingValue.BindingError(new NullReferenceException(), _fallbackValue); + } + else + { + return null; + } + } + + private void PublishValue() + { + var result = GetResult(); + + if (result.HasValue) + { + unchecked + { ++_publishCount; } + PublishNext(result.Value); + } + } + + private int ChainIndexOf(object o) + { + if (_chain != null) + { + for (var i = 0; i < _chain.Length; ++i) + { + var link = _chain[i]; + + if (link.Value != null && + link.Value.TryGetTarget(out var q) && + ReferenceEquals(o, q)) + { + return i; + } + } + } + + return -1; + } + + private void ChainPropertyChanged(object sender) + { + var index = ChainIndexOf(sender); + + if (index != -1) + { + StopListeningToChain(index); + ListenToChain(index); + } + + PublishValue(); + } + + private void ChainPropertyChanged(object sender, PropertyChangedEventArgs e) => ChainPropertyChanged(sender); + private void ChainPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) => ChainPropertyChanged(sender); + private void ChainCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) => ChainPropertyChanged(sender); + + private struct Link + { + public Link(Func eval) + { + Eval = eval; + Value = null; + } + + public Func Eval; + public WeakReference? Value; + } + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/DataContextRoot.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/DataContextRoot.cs new file mode 100644 index 00000000..001921a0 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/DataContextRoot.cs @@ -0,0 +1,52 @@ +using Avalonia.Reactive; + +#nullable enable + +namespace Avalonia.Experimental.Data +{ + internal class DataContextRoot : SingleSubscriberObservableBase + where T : class + { + private readonly IStyledElement _source; + + public DataContextRoot(IStyledElement source) + { + _source = source; + } + + protected override void Subscribed() + { + _source.PropertyChanged += PropertyChanged; + PublishValue(); + } + + protected override void Unsubscribed() + { + _source.PropertyChanged -= PropertyChanged; + } + + private void PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == StyledElement.DataContextProperty) + { + PublishValue(); + } + } + + private void PublishValue() + { + if (_source.DataContext is null) + { + PublishNext(null); + } + else if (_source.DataContext is T value) + { + PublishNext(value); + } + else + { + // TODO: Log DataContext is unexpected type. + } + } + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/ParentDataContextRoot.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/ParentDataContextRoot.cs new file mode 100644 index 00000000..e6d71256 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/ParentDataContextRoot.cs @@ -0,0 +1,83 @@ +using System; +using Avalonia.Reactive; +using Avalonia.VisualTree; + +#nullable enable + +namespace Avalonia.Experimental.Data +{ + internal class ParentDataContextRoot : SingleSubscriberObservableBase + where T : class + { + private readonly IVisual _source; + + public ParentDataContextRoot(IVisual source) + { + _source = source; + } + + protected override void Subscribed() + { + ((IAvaloniaObject)_source).PropertyChanged += SourcePropertyChanged; + StartListeningToDataContext(_source.VisualParent); + PublishValue(); + } + + protected override void Unsubscribed() + { + ((IAvaloniaObject)_source).PropertyChanged -= SourcePropertyChanged; + } + + private void PublishValue() + { + var parent = _source.VisualParent as IStyledElement; + + if (parent?.DataContext is null) + { + PublishNext(null); + } + else if (parent.DataContext is T value) + { + PublishNext(value); + } + else + { + // TODO: Log DataContext is unexpected type. + } + } + + private void StartListeningToDataContext(IVisual visual) + { + if (visual is IStyledElement styled) + { + styled.PropertyChanged += ParentPropertyChanged; + } + } + + private void StopListeningToDataContext(IVisual visual) + { + if (visual is IStyledElement styled) + { + styled.PropertyChanged -= ParentPropertyChanged; + } + } + + private void SourcePropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == Visual.VisualParentProperty) + { + StopListeningToDataContext(_source.VisualParent); + StartListeningToDataContext(_source.VisualParent); + PublishValue(); + } + } + + private void ParentPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == StyledElement.DataContextProperty) + { + PublishValue(); + } + } + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs new file mode 100644 index 00000000..e21a065f --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`1.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq.Expressions; +using Avalonia.Data; +using Avalonia.Data.Core.Parsers; + +#nullable enable + +namespace Avalonia.Experimental.Data +{ + /// + /// Provides factory methods for creating objects from + /// C# lambda expressions. + /// + /// The input type of the binding. + public static class TypedBinding + where TIn : class + { + public static TypedBinding Default( + Expression> read, + Action write) + { + return new TypedBinding + { + Read = read.Compile(), + Write = write, + Links = ExpressionChainVisitor.Build(read), + Mode = BindingMode.Default, + }; + } + + public static TypedBinding OneWay(Expression> read) + { + return new TypedBinding + { + Read = read.Compile(), + Links = ExpressionChainVisitor.Build(read), + }; + } + + public static TypedBinding TwoWay( + Expression> read, + Action write) + { + return new TypedBinding + { + Read = read.Compile(), + Write = write, + Links = ExpressionChainVisitor.Build(read), + Mode = BindingMode.TwoWay, + }; + } + + public static TypedBinding OneTime(Expression> read) + { + return new TypedBinding + { + Read = read.Compile(), + Links = ExpressionChainVisitor.Build(read), + Mode = BindingMode.OneTime, + }; + } + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`2.cs b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`2.cs new file mode 100644 index 00000000..0293e5e0 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Experimental/Data/TypedBinding`2.cs @@ -0,0 +1,173 @@ +using System; +using System.Reactive.Disposables; +using Avalonia.Data; +using Avalonia.Experimental.Data.Core; +using Avalonia.Reactive; +using Avalonia.VisualTree; + +#nullable enable + +namespace Avalonia.Experimental.Data +{ + /// + /// A binding whose input and output are strongly-typed. + /// + /// The binding input. + /// The binding output. + /// + /// represents a strongly-typed binding as opposed to + /// which boxes value types. It is represented as a set of delegates: + /// + /// - reads the value given a binding input + /// - writes a value given a binding input + /// - holds a collection of delegates which when passed a binding input + /// return each object traversed by . For example if Read is implemented + /// as `x => x.Foo.Bar.Baz` then there would be three links: `x => x.Foo`, `x => x.Foo.Bar` + /// and `x => x.Foo.Bar.Baz`. These links are used to subscribe to change notifications. + /// + /// This class represents a binding which has not been instantiated on an object. When the + /// or + /// methods are called, then + /// an instance of is created which represents + /// the binding instantiated on that object. + /// + public class TypedBinding + where TIn : class + { + /// + /// Gets or sets the read function. + /// + public Func? Read { get; set; } + + /// + /// Gets or sets the write function. + /// + public Action? Write { get; set; } + + /// + /// Gets or sets the links in the binding chain. + /// + public Func[]? Links { get; set; } + + /// + /// Gets or sets the binding mode. + /// + public BindingMode Mode { get; set; } + + /// + /// Gets or sets the binding priority. + /// + public BindingPriority Priority { get; set; } + + /// + /// Gets or sets the value to use when the binding is unable to produce a value. + /// + public Optional FallbackValue { get; set; } + + /// + /// Gets or sets the source for the binding. + /// + /// + /// If unset the source is the target control's property. + /// + public Optional Source { get; set; } + + public IDisposable Bind(IAvaloniaObject target, StyledPropertyBase property) + { + var mode = GetMode(target, property); + var expression = CreateExpression(target, property, mode); + + switch (mode) + { + case BindingMode.Default: + case BindingMode.OneWay: + return target.Bind(property, expression, Priority); + case BindingMode.TwoWay: + return new CompositeDisposable( + target.Bind(property, expression, Priority), + target.GetBindingObservable(property).Subscribe(expression)); + default: + throw new ArgumentException("Invalid binding mode."); + } + } + + public IDisposable Bind(IAvaloniaObject target, DirectPropertyBase property) + { + var mode = GetMode(target, property); + var expression = CreateExpression(target, property, mode); + + switch (mode) + { + case BindingMode.Default: + case BindingMode.OneWay: + return target.Bind(property, expression, Priority); + case BindingMode.TwoWay: + return new CompositeDisposable( + target.Bind(property, expression, Priority), + target.GetBindingObservable(property).Subscribe(expression)); + case BindingMode.OneTime: + case BindingMode.OneWayToSource: + throw new NotImplementedException(); + default: + throw new ArgumentException("Invalid binding mode."); + } + } + + private TypedBindingExpression CreateExpression( + IAvaloniaObject target, + AvaloniaProperty property, + BindingMode mode) + { + if (Read is null) + { + throw new InvalidOperationException("Cannot bind TypedBinding: Read is uninitialized."); + } + + if (Links is null) + { + throw new InvalidOperationException("Cannot bind TypedBinding: Links is uninitialized."); + } + + if ((mode == BindingMode.TwoWay || mode == BindingMode.OneWayToSource) && Write is null) + { + throw new InvalidOperationException($"Cannot bind TypedBinding {Mode}: Write is uninitialized."); + } + + var targetIsDataContext = property == StyledElement.DataContextProperty; + var root = GetRoot(target, property); + var fallback = FallbackValue; + + // If we're binding to DataContext and our fallback is unset then override the fallback + // value to null, as broken bindings to DataContext must reset the DataContext in order + // to not propagate incorrect DataContexts to child controls. See + // TypedBinding.DataContext_Binding_Should_Produce_Correct_Results. + if (targetIsDataContext && !fallback.HasValue) + { + fallback = new Optional(default); + } + + return new TypedBindingExpression(root, Read, Write, Links, fallback); + } + + private BindingMode GetMode(IAvaloniaObject target, AvaloniaProperty property) + { + return Mode == BindingMode.Default ? property.GetMetadata(target.GetType()).DefaultBindingMode : Mode; + } + + private IObservable GetRoot(IAvaloniaObject target, AvaloniaProperty property) + { + if (Source.HasValue) + { + return ObservableEx.SingleValue(Source.Value); + } + else if (property == StyledElement.DataContextProperty) + { + return new ParentDataContextRoot((IVisual)target); + } + else + { + return new DataContextRoot((IStyledElement)target); + } + } + } +} \ No newline at end of file