From 62ab6239b6187992b5d641f4998453003183024d Mon Sep 17 00:00:00 2001 From: Michael Stonis Date: Sat, 2 Mar 2024 12:48:00 -0600 Subject: [PATCH 1/3] First pass at observing nested property changes Added a new method to observe changes in nested properties of INotifyPropertyChanged objects. This allows for more granular observation of property changes, particularly useful when dealing with complex objects. Also updated the test suite to cover this new functionality. --- src/R3/Factories/ObserveProperty.cs | 35 +++++++++++++++++-- .../FactoryTests/ObservePropertyTest.cs | 27 ++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/R3/Factories/ObserveProperty.cs b/src/R3/Factories/ObserveProperty.cs index d055fd3b..4a643929 100644 --- a/src/R3/Factories/ObserveProperty.cs +++ b/src/R3/Factories/ObserveProperty.cs @@ -13,7 +13,7 @@ public static Observable ObservePropertyChanged(this T Func propertySelector, bool pushCurrentValueOnSubscribe = true, CancellationToken cancellationToken = default, - [CallerArgumentExpression("propertySelector")] string? expr = null) + [CallerArgumentExpression(nameof(propertySelector))] string? expr = null) where T : INotifyPropertyChanged { if (expr == null) throw new ArgumentNullException(expr); @@ -22,6 +22,37 @@ public static Observable ObservePropertyChanged(this T return new ObservePropertyChanged(value, propertySelector, propertyName, pushCurrentValueOnSubscribe, cancellationToken); } + /// + /// Convert INotifyPropertyChanged to Observable. + /// `propertySelector` must be a Func specifying a simple property. For example, it extracts "Foo" from `x => x.Foo`. + /// + public static Observable ObservePropertyChanged(this T value, + Func propertySelector1, + Func propertySelector2, + bool pushCurrentValueOnSubscribe = true, + CancellationToken cancellationToken = default, + [CallerArgumentExpression(nameof(propertySelector1))] string? propertySelector1Expr = null, + [CallerArgumentExpression(nameof(propertySelector2))] string? propertySelector2Expr = null) + where T : INotifyPropertyChanged + where TProperty1 : INotifyPropertyChanged + { + if (propertySelector1Expr == null) throw new ArgumentNullException(propertySelector1Expr); + if (propertySelector2Expr == null) throw new ArgumentNullException(propertySelector2Expr); + + var property1Name = propertySelector1Expr!.Substring(propertySelector1Expr.LastIndexOf('.') + 1); + var property2Name = propertySelector2Expr!.Substring(propertySelector2Expr.LastIndexOf('.') + 1); + + var firstPropertyChanged = new ObservePropertyChanged(value, propertySelector1, property1Name, pushCurrentValueOnSubscribe, cancellationToken); + + return firstPropertyChanged + .Select( + (propertySelector2, property2Name, pushCurrentValueOnSubscribe, cancellationToken), + (firstPropertyValue, state) => + (Observable)new ObservePropertyChanged(firstPropertyValue, state.propertySelector2, state.property2Name, state.pushCurrentValueOnSubscribe, state.cancellationToken)) + .Switch(); + } + + /// /// Convert INotifyPropertyChanging to Observable. /// `propertySelector` must be a Func specifying a simple property. For example, it extracts "Foo" from `x => x.Foo`. @@ -30,7 +61,7 @@ public static Observable ObservePropertyChanging(this T Func propertySelector, bool pushCurrentValueOnSubscribe = true, CancellationToken cancellationToken = default, - [CallerArgumentExpression("propertySelector")] string? expr = null) + [CallerArgumentExpression(nameof(propertySelector))] string? expr = null) where T : INotifyPropertyChanging { if (expr == null) throw new ArgumentNullException(expr); diff --git a/tests/R3.Tests/FactoryTests/ObservePropertyTest.cs b/tests/R3.Tests/FactoryTests/ObservePropertyTest.cs index 31fbbabe..59219e07 100644 --- a/tests/R3.Tests/FactoryTests/ObservePropertyTest.cs +++ b/tests/R3.Tests/FactoryTests/ObservePropertyTest.cs @@ -21,6 +21,26 @@ public void PropertyChanged() liveList.AssertEqual([0, 1]); } + [Fact] + public void NestedPropertyChanged() + { + ChangesProperty propertyChanger = new(); + + using var liveList = propertyChanger + .ObservePropertyChanged(x => x.InnerPropertyChanged, x => x.Value) + .ToLiveList(); + + liveList.AssertEqual([]); + + propertyChanger.InnerPropertyChanged = new(); + + liveList.AssertEqual([0]); + + propertyChanger.InnerPropertyChanged.Value = 1; + + liveList.AssertEqual([0, 1]); + } + [Fact] public void PropertyChanging() { @@ -40,6 +60,7 @@ public void PropertyChanging() class ChangesProperty : INotifyPropertyChanged, INotifyPropertyChanging { private int _value; + private ChangesProperty _innerPropertyChanged; public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangingEventHandler? PropertyChanging; @@ -50,6 +71,12 @@ public int Value set => SetField(ref _value, value); } + public ChangesProperty InnerPropertyChanged + { + get => _innerPropertyChanged; + set => SetField(ref _innerPropertyChanged, value); + } + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); From 996bc836842728d6dc19bf88c68c93630875571e Mon Sep 17 00:00:00 2001 From: Michael Stonis Date: Mon, 4 Mar 2024 10:39:43 -0600 Subject: [PATCH 2/3] Enhanced Observable functionality The ObservePropertyChanged and ObservePropertyChanging methods now support multiple property selectors, allowing for nested observation of property changes. Corresponding unit tests have also been added. --- src/R3/Factories/ObserveProperty.cs | 160 ++++++++++++++++-- .../FactoryTests/ObservePropertyTest.cs | 80 +++++++++ 2 files changed, 227 insertions(+), 13 deletions(-) diff --git a/src/R3/Factories/ObserveProperty.cs b/src/R3/Factories/ObserveProperty.cs index 4a643929..2d5fe36b 100644 --- a/src/R3/Factories/ObserveProperty.cs +++ b/src/R3/Factories/ObserveProperty.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Runtime.CompilerServices; namespace R3; @@ -24,7 +24,7 @@ public static Observable ObservePropertyChanged(this T /// /// Convert INotifyPropertyChanged to Observable. - /// `propertySelector` must be a Func specifying a simple property. For example, it extracts "Foo" from `x => x.Foo`. + /// `propertySelector1` and `propertySelector2` must be a Func specifying a simple property. For example, it extracts "Foo" from `x => x.Foo`. /// public static Observable ObservePropertyChanged(this T value, Func propertySelector1, @@ -42,16 +42,61 @@ public static Observable ObservePropertyChanged(value, propertySelector1, property1Name, pushCurrentValueOnSubscribe, cancellationToken); - - return firstPropertyChanged + return new ObservePropertyChanged(value, propertySelector1, property1Name, true, cancellationToken) .Select( (propertySelector2, property2Name, pushCurrentValueOnSubscribe, cancellationToken), (firstPropertyValue, state) => - (Observable)new ObservePropertyChanged(firstPropertyValue, state.propertySelector2, state.property2Name, state.pushCurrentValueOnSubscribe, state.cancellationToken)) + firstPropertyValue is not null + ? new ObservePropertyChanged(firstPropertyValue, + state.propertySelector2, state.property2Name, state.pushCurrentValueOnSubscribe, + state.cancellationToken) + : Empty()) .Switch(); } + /// + /// Convert INotifyPropertyChanged to Observable. + /// `propertySelector1`, `propertySelector2`, and `propertySelector3` must be a Func specifying a simple property. For example, it extracts "Foo" from `x => x.Foo`. + /// + public static Observable ObservePropertyChanged(this T value, + Func propertySelector1, + Func propertySelector2, + Func propertySelector3, + bool pushCurrentValueOnSubscribe = true, + CancellationToken cancellationToken = default, + [CallerArgumentExpression(nameof(propertySelector1))] string? propertySelector1Expr = null, + [CallerArgumentExpression(nameof(propertySelector2))] string? propertySelector2Expr = null, + [CallerArgumentExpression(nameof(propertySelector3))] string? propertySelector3Expr = null) + where T : INotifyPropertyChanged + where TProperty1 : INotifyPropertyChanged + where TProperty2 : INotifyPropertyChanged + { + if (propertySelector1Expr == null) throw new ArgumentNullException(propertySelector1Expr); + if (propertySelector2Expr == null) throw new ArgumentNullException(propertySelector2Expr); + if (propertySelector3Expr == null) throw new ArgumentNullException(propertySelector3Expr); + + var property1Name = propertySelector1Expr!.Substring(propertySelector1Expr.LastIndexOf('.') + 1); + var property2Name = propertySelector2Expr!.Substring(propertySelector2Expr.LastIndexOf('.') + 1); + var property3Name = propertySelector3Expr!.Substring(propertySelector3Expr.LastIndexOf('.') + 1); + + return new ObservePropertyChanged(value, propertySelector1, property1Name, true, cancellationToken) + .Select( + (propertySelector2, property2Name, propertySelector3, property3Name, pushCurrentValueOnSubscribe, cancellationToken), + (firstPropertyValue, state) => + firstPropertyValue is not null + ? new ObservePropertyChanged(firstPropertyValue, state.propertySelector2, state.property2Name, true, state.cancellationToken) + .Select( + (state.propertySelector3, state.property3Name, pushCurrentValueOnSubscribe, cancellationToken), + (secondPropertyValue, state2) => + secondPropertyValue is not null + ? new ObservePropertyChanged(secondPropertyValue, + state2.propertySelector3, state2.property3Name, state2.pushCurrentValueOnSubscribe, + state2.cancellationToken) + : Empty()) + .Switch() + : Empty()) + .Switch(); + } /// /// Convert INotifyPropertyChanging to Observable. @@ -67,11 +112,95 @@ public static Observable ObservePropertyChanging(this T if (expr == null) throw new ArgumentNullException(expr); var propertyName = expr!.Substring(expr.LastIndexOf('.') + 1); - return new ObservePropertyChanging(value, propertySelector, propertyName, pushCurrentValueOnSubscribe, cancellationToken); + return new ObservePropertyChanging(value, propertySelector, propertyName, + pushCurrentValueOnSubscribe, cancellationToken); + } + + /// + /// Convert INotifyPropertyChanging to Observable. + /// `propertySelector1` and `propertySelector2` must be a Func specifying a simple property. For example, it extracts "Foo" from `x => x.Foo`. + /// + public static Observable ObservePropertyChanging(this T value, + Func propertySelector1, + Func propertySelector2, + bool pushCurrentValueOnSubscribe = true, + CancellationToken cancellationToken = default, + [CallerArgumentExpression(nameof(propertySelector1))] string? propertySelector1Expr = null, + [CallerArgumentExpression(nameof(propertySelector2))] string? propertySelector2Expr = null) + where T : INotifyPropertyChanged + where TProperty1 : INotifyPropertyChanging + { + if (propertySelector1Expr == null) throw new ArgumentNullException(propertySelector1Expr); + if (propertySelector2Expr == null) throw new ArgumentNullException(propertySelector2Expr); + + var property1Name = propertySelector1Expr!.Substring(propertySelector1Expr.LastIndexOf('.') + 1); + var property2Name = propertySelector2Expr!.Substring(propertySelector2Expr.LastIndexOf('.') + 1); + + + return new ObservePropertyChanged(value, propertySelector1, property1Name, true, cancellationToken) + .Select( + (propertySelector2, property2Name, pushCurrentValueOnSubscribe, cancellationToken), + (firstPropertyValue, state) => + firstPropertyValue is not null + ? new ObservePropertyChanging(firstPropertyValue, + state.propertySelector2, state.property2Name, state.pushCurrentValueOnSubscribe, + state.cancellationToken) + : Empty()) + .Switch(); + } + + /// + /// Convert INotifyPropertyChanging to Observable. + /// `propertySelector1`, `propertySelector2`, and `propertySelector3` must be a Func specifying a simple property. For example, it extracts "Foo" from `x => x.Foo`. + /// + public static Observable ObservePropertyChanging(this T value, + Func propertySelector1, + Func propertySelector2, + Func propertySelector3, + bool pushCurrentValueOnSubscribe = true, + CancellationToken cancellationToken = default, + [CallerArgumentExpression(nameof(propertySelector1))] string? propertySelector1Expr = null, + [CallerArgumentExpression(nameof(propertySelector2))] string? propertySelector2Expr = null, + [CallerArgumentExpression(nameof(propertySelector3))] string? propertySelector3Expr = null) + where T : INotifyPropertyChanged + where TProperty1 : INotifyPropertyChanged + where TProperty2 : INotifyPropertyChanging + { + if (propertySelector1Expr == null) throw new ArgumentNullException(propertySelector1Expr); + if (propertySelector2Expr == null) throw new ArgumentNullException(propertySelector2Expr); + if (propertySelector3Expr == null) throw new ArgumentNullException(propertySelector3Expr); + + var property1Name = propertySelector1Expr!.Substring(propertySelector1Expr.LastIndexOf('.') + 1); + var property2Name = propertySelector2Expr!.Substring(propertySelector2Expr.LastIndexOf('.') + 1); + var property3Name = propertySelector3Expr!.Substring(propertySelector3Expr.LastIndexOf('.') + 1); + + return new ObservePropertyChanged(value, propertySelector1, property1Name, pushCurrentValueOnSubscribe, cancellationToken) + .Select( + (propertySelector2, property2Name, propertySelector3, property3Name, pushCurrentValueOnSubscribe, cancellationToken), + (firstPropertyValue, state) => + (firstPropertyValue is not null + ? new ObservePropertyChanged( + firstPropertyValue, state.propertySelector2, state.property2Name, true, state.cancellationToken) + .Select( + (state.propertySelector3, state.property3Name, pushCurrentValueOnSubscribe, cancellationToken), + (secondPropertyValue, state2) => + secondPropertyValue is not null + ? new ObservePropertyChanging(secondPropertyValue, + state2.propertySelector3, state2.property3Name, state2.pushCurrentValueOnSubscribe, + state2.cancellationToken) + : Empty()) + .Switch() + : Empty())) + .Switch(); } } -internal sealed class ObservePropertyChanged(T value, Func propertySelector, string propertyName, bool pushCurrentValueOnSubscribe, CancellationToken cancellationToken) +internal sealed class ObservePropertyChanged( + T value, + Func propertySelector, + string propertyName, + bool pushCurrentValueOnSubscribe, + CancellationToken cancellationToken) : Observable where T : INotifyPropertyChanged { protected override IDisposable SubscribeCore(Observer observer) @@ -150,7 +279,12 @@ public void Dispose() } } -internal sealed class ObservePropertyChanging(T value, Func propertySelector, string propertyName, bool pushCurrentValueOnSubscribe, CancellationToken cancellationToken) +internal sealed class ObservePropertyChanging( + T value, + Func propertySelector, + string propertyName, + bool pushCurrentValueOnSubscribe, + CancellationToken cancellationToken) : Observable where T : INotifyPropertyChanging { protected override IDisposable SubscribeCore(Observer observer) @@ -160,10 +294,10 @@ protected override IDisposable SubscribeCore(Observer observer) observer.OnNext(propertySelector(value)); } - return new _ObservePropertyChanged(observer, value, propertySelector, propertyName, cancellationToken); + return new _ObservePropertyChanging(observer, value, propertySelector, propertyName, cancellationToken); } - sealed class _ObservePropertyChanged : IDisposable + sealed class _ObservePropertyChanging : IDisposable { readonly Observer observer; readonly T value; @@ -172,7 +306,7 @@ sealed class _ObservePropertyChanged : IDisposable PropertyChangingEventHandler? eventHandler; CancellationTokenRegistration cancellationTokenRegistration; - public _ObservePropertyChanged(Observer observer, T value, Func propertySelector, string propertyName, CancellationToken cancellationToken) + public _ObservePropertyChanging(Observer observer, T value, Func propertySelector, string propertyName, CancellationToken cancellationToken) { this.observer = observer; this.value = value; @@ -186,7 +320,7 @@ public _ObservePropertyChanged(Observer observer, T value, Func { - var s = (_ObservePropertyChanged)state!; + var s = (_ObservePropertyChanging)state!; s.CompleteDispose(); }, this); } diff --git a/tests/R3.Tests/FactoryTests/ObservePropertyTest.cs b/tests/R3.Tests/FactoryTests/ObservePropertyTest.cs index 59219e07..13039f93 100644 --- a/tests/R3.Tests/FactoryTests/ObservePropertyTest.cs +++ b/tests/R3.Tests/FactoryTests/ObservePropertyTest.cs @@ -39,6 +39,34 @@ public void NestedPropertyChanged() propertyChanger.InnerPropertyChanged.Value = 1; liveList.AssertEqual([0, 1]); + + propertyChanger.InnerPropertyChanged.Value = 2; + + liveList.AssertEqual([0, 1, 2]); + } + + [Fact] + public void DoubleNestedPropertyChanged() + { + ChangesProperty propertyChanger = new(); + + using var liveList = propertyChanger + .ObservePropertyChanged(x => x.InnerPropertyChanged, x => x.InnerPropertyChanged, x => x.Value) + .ToLiveList(); + + liveList.AssertEqual([]); + + propertyChanger.InnerPropertyChanged = new(); + + liveList.AssertEqual([]); + + propertyChanger.InnerPropertyChanged.InnerPropertyChanged = new(); + + liveList.AssertEqual([0]); + + propertyChanger.InnerPropertyChanged.InnerPropertyChanged.Value = 1; + + liveList.AssertEqual([0, 1]); } [Fact] @@ -57,6 +85,58 @@ public void PropertyChanging() liveList.AssertEqual([0, 0]); } + [Fact] + public void NestedPropertyChanging() + { + ChangesProperty propertyChanger = new(); + + using var liveList = propertyChanger + .ObservePropertyChanging(x => x.InnerPropertyChanged, x => x.Value) + .ToLiveList(); + + liveList.AssertEqual([]); + + propertyChanger.InnerPropertyChanged = new(); + + liveList.AssertEqual([0]); + + propertyChanger.InnerPropertyChanged.Value = 1; + + liveList.AssertEqual([0, 0]); + + propertyChanger.InnerPropertyChanged.Value = 2; + + liveList.AssertEqual([0, 0, 1]); + } + + [Fact] + public void DoubleNestedPropertyChanging() + { + ChangesProperty propertyChanger = new(); + + using var liveList = propertyChanger + .ObservePropertyChanging(x => x.InnerPropertyChanged, x => x.InnerPropertyChanged, x => x.Value) + .ToLiveList(); + + liveList.AssertEqual([]); + + propertyChanger.InnerPropertyChanged = new(); + + liveList.AssertEqual([]); + + propertyChanger.InnerPropertyChanged.InnerPropertyChanged = new(); + + liveList.AssertEqual([0]); + + propertyChanger.InnerPropertyChanged.InnerPropertyChanged.Value = 1; + + liveList.AssertEqual([0, 0]); + + propertyChanger.InnerPropertyChanged.InnerPropertyChanged.Value = 2; + + liveList.AssertEqual([0, 0, 1]); + } + class ChangesProperty : INotifyPropertyChanged, INotifyPropertyChanging { private int _value; From e79ad4fc0a3dd2bc9830f92512736757146b4fc2 Mon Sep 17 00:00:00 2001 From: Michael Stonis Date: Mon, 4 Mar 2024 10:56:29 -0600 Subject: [PATCH 3/3] Refactored ObservePropertyChanging methods These are dependent on `PropertyChanged` until the end of the chain where we will switch to `PropertyChanging`, so these have been updated to leverage the `ObservePropertyChanged` methods. --- src/R3/Factories/ObserveProperty.cs | 33 ++++++++--------------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/src/R3/Factories/ObserveProperty.cs b/src/R3/Factories/ObserveProperty.cs index 2d5fe36b..b66596af 100644 --- a/src/R3/Factories/ObserveProperty.cs +++ b/src/R3/Factories/ObserveProperty.cs @@ -130,14 +130,11 @@ public static Observable ObservePropertyChanging(value, propertySelector1, property1Name, true, cancellationToken) + return ObservePropertyChanged(value, propertySelector1, true, cancellationToken, propertySelector1Expr) .Select( (propertySelector2, property2Name, pushCurrentValueOnSubscribe, cancellationToken), (firstPropertyValue, state) => @@ -166,31 +163,19 @@ public static Observable ObservePropertyChanging(value, propertySelector1, property1Name, pushCurrentValueOnSubscribe, cancellationToken) + return ObservePropertyChanged(value, propertySelector1, propertySelector2, true, cancellationToken, propertySelector1Expr, propertySelector2Expr) .Select( - (propertySelector2, property2Name, propertySelector3, property3Name, pushCurrentValueOnSubscribe, cancellationToken), - (firstPropertyValue, state) => - (firstPropertyValue is not null - ? new ObservePropertyChanged( - firstPropertyValue, state.propertySelector2, state.property2Name, true, state.cancellationToken) - .Select( - (state.propertySelector3, state.property3Name, pushCurrentValueOnSubscribe, cancellationToken), - (secondPropertyValue, state2) => - secondPropertyValue is not null - ? new ObservePropertyChanging(secondPropertyValue, - state2.propertySelector3, state2.property3Name, state2.pushCurrentValueOnSubscribe, - state2.cancellationToken) - : Empty()) - .Switch() - : Empty())) + (propertySelector3, property3Name, pushCurrentValueOnSubscribe, cancellationToken), + (secondPropertyValue, state) => + secondPropertyValue is not null + ? new ObservePropertyChanging(secondPropertyValue, + state.propertySelector3, state.property3Name, state.pushCurrentValueOnSubscribe, + state.cancellationToken) + : Empty()) .Switch(); } }