Created
October 7, 2025 01:30
-
-
Save maxkatz6/d6ad3c5c8dad9d9e67483185fc9c4e79 to your computer and use it in GitHub Desktop.
Generic WhenAnyValue over INotifyPropertyChanged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| using System.ComponentModel; | |
| using System.Runtime.CompilerServices; | |
| namespace AvaloniaUI.Mvvm; | |
| public static class PropertyChangeTrackerExtensions | |
| { | |
| public static PropertyChangeTracker<TRes> WhenAnyValue<TIn, TRes>( | |
| this TIn vm, | |
| Func<TIn, TRes> selector, | |
| [CallerArgumentExpression(nameof(selector))] | |
| string? expression = null) | |
| where TIn : INotifyPropertyChanged | |
| { | |
| var tracker = new PropertyChangeTracker<TRes>(); | |
| tracker.AddSegment(vm, o => selector((TIn)o), PropertyChangeTracker<TRes>.ParsePropertyName(expression)); | |
| return tracker; | |
| } | |
| internal class PropertySegment | |
| { | |
| public PropertySegment(INotifyPropertyChanged? source, | |
| Func<object, object?> selector, | |
| string propertyName) | |
| { | |
| if (string.IsNullOrWhiteSpace(propertyName)) | |
| throw new ArgumentException("Property name cannot be null or whitespace.", nameof(propertyName)); | |
| Source = source; | |
| Selector = selector ?? throw new ArgumentNullException(nameof(selector)); | |
| PropertyName = propertyName; | |
| } | |
| public INotifyPropertyChanged? Source { get; } | |
| public Func<object, object?> Selector { get; } | |
| public string PropertyName { get; } | |
| } | |
| } | |
| public class PropertyChangeTracker<TRes> | |
| { | |
| private readonly List<PropertyChangeTrackerExtensions.PropertySegment> _segments = []; | |
| internal void AddSegment(INotifyPropertyChanged source, Func<object, object?> selector, string propertyName) | |
| { | |
| _segments.Add(new PropertyChangeTrackerExtensions.PropertySegment(source, selector, propertyName)); | |
| } | |
| public PropertyChangeTracker<TResNested> WhenAnyValue<TResNested>( | |
| Func<TRes, TResNested> selector, | |
| [CallerArgumentExpression(nameof(selector))] | |
| string? expression = null) | |
| { | |
| var propertyName = ParsePropertyName(expression); | |
| var nestedTracker = new PropertyChangeTracker<TResNested>(); | |
| // Copy existing segments | |
| foreach (var segment in _segments) | |
| { | |
| nestedTracker._segments.Add(segment); | |
| } | |
| // Add new segment (source will be determined during subscription) | |
| nestedTracker._segments.Add( | |
| new PropertyChangeTrackerExtensions.PropertySegment(null!, o => selector((TRes)o), propertyName)); | |
| return nestedTracker; | |
| } | |
| public IDisposable Subscribe(Action<TRes?> onNext) | |
| { | |
| var subscription = new ChainSubscription<TRes>(_segments, onNext); | |
| subscription.Initialize(); | |
| return subscription; | |
| } | |
| internal static string ParsePropertyName(string? expression) | |
| { | |
| if (string.IsNullOrWhiteSpace(expression)) | |
| throw new ArgumentException("Expression cannot be null or empty", nameof(expression)); | |
| var parts = expression.Split(["=>"], StringSplitOptions.RemoveEmptyEntries); | |
| if (parts.Length != 2) | |
| throw new ArgumentException($"Invalid expression format: {expression}", nameof(expression)); | |
| var propertyPath = parts[1].Trim(); | |
| var lastDot = propertyPath.LastIndexOf('.'); | |
| var propertyName = lastDot >= 0 ? propertyPath.Substring(lastDot + 1) : propertyPath; | |
| if (string.IsNullOrWhiteSpace(propertyName)) | |
| throw new ArgumentException($"Could not extract property name from: {expression}", nameof(expression)); | |
| return propertyName; | |
| } | |
| private class ChainSubscription<T>( | |
| List<PropertyChangeTrackerExtensions.PropertySegment> segments, | |
| Action<T?> onNext) | |
| : IDisposable | |
| { | |
| private readonly List<SegmentSubscription> _activeSubscriptions = new(); | |
| private bool _isDisposed; | |
| private T? _lastValue; | |
| public void Initialize() | |
| { | |
| ReevaluateChain(); | |
| } | |
| private void ReevaluateChain() | |
| { | |
| if (_isDisposed) return; | |
| object? currentValue = null; | |
| lock (_activeSubscriptions) | |
| { | |
| if (_isDisposed) return; | |
| // Unsubscribe from all previous subscriptions | |
| foreach (var sub in _activeSubscriptions) | |
| { | |
| sub.Dispose(); | |
| } | |
| _activeSubscriptions.Clear(); | |
| if (_isDisposed || segments.Count == 0) | |
| return; | |
| var currentSource = segments[0].Source; | |
| for (var i = 0; i < segments.Count; i++) | |
| { | |
| var segment = segments[i]; | |
| // For segments after the first, source comes from previous value | |
| if (i > 0) | |
| { | |
| currentSource = currentValue as INotifyPropertyChanged; | |
| } | |
| if (currentSource == null) | |
| { | |
| // Chain is broken, notify with null and stop | |
| onNext(default(T)); | |
| return; | |
| } | |
| var subscription = new SegmentSubscription( | |
| currentSource, | |
| segment.PropertyName, | |
| this); | |
| subscription.Initialize(); | |
| _activeSubscriptions.Add(subscription); | |
| // Get the next value in the chain | |
| currentValue = segment.Selector(currentSource); | |
| } | |
| } | |
| // Notify with the final value | |
| var currentTyped = (T?)currentValue; | |
| if (!EqualityComparer<T>.Default.Equals(_lastValue, currentTyped)) | |
| { | |
| _lastValue = currentTyped; | |
| onNext(currentTyped); | |
| } | |
| } | |
| public void Dispose() | |
| { | |
| lock (_activeSubscriptions) | |
| { | |
| if (_isDisposed) | |
| return; | |
| _isDisposed = true; | |
| foreach (var sub in _activeSubscriptions) | |
| { | |
| sub.Dispose(); | |
| } | |
| _activeSubscriptions.Clear(); | |
| } | |
| } | |
| private class SegmentSubscription( | |
| INotifyPropertyChanged source, | |
| string propertyName, | |
| ChainSubscription<T> chain) | |
| : IDisposable | |
| { | |
| public void Initialize() | |
| { | |
| source.PropertyChanged += OnPropertyChanged; | |
| } | |
| private void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) | |
| { | |
| if (args.PropertyName == propertyName || string.IsNullOrEmpty(args.PropertyName)) | |
| { | |
| chain.ReevaluateChain(); | |
| } | |
| } | |
| public void Dispose() => source.PropertyChanged -= OnPropertyChanged; | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.ComponentModel; | |
| using AvaloniaUI.Mvvm; | |
| using Xunit; | |
| namespace AvaloniaUI.Core.UnitTests; | |
| public class PropertyChangeTrackerTests | |
| { | |
| private class TestViewModel : INotifyPropertyChanged | |
| { | |
| public string? Name | |
| { | |
| get; | |
| set | |
| { | |
| field = value; | |
| PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); | |
| } | |
| } | |
| public NestedViewModel? Nested | |
| { | |
| get; | |
| set | |
| { | |
| field = value; | |
| PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Nested))); | |
| } | |
| } | |
| public event PropertyChangedEventHandler? PropertyChanged; | |
| } | |
| private class NestedViewModel : INotifyPropertyChanged | |
| { | |
| public string? Value | |
| { | |
| get; | |
| set | |
| { | |
| field = value; | |
| PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); | |
| } | |
| } | |
| public DeeplyNestedViewModel? Deep | |
| { | |
| get; | |
| set | |
| { | |
| field = value; | |
| PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Deep))); | |
| } | |
| } | |
| public event PropertyChangedEventHandler? PropertyChanged; | |
| } | |
| private class DeeplyNestedViewModel : INotifyPropertyChanged | |
| { | |
| public int Count | |
| { | |
| get; | |
| set | |
| { | |
| field = value; | |
| PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); | |
| } | |
| } | |
| public event PropertyChangedEventHandler? PropertyChanged; | |
| } | |
| private class ViewModelWithSubscriptionCheck : INotifyPropertyChanged | |
| { | |
| private PropertyChangedEventHandler? _propertyChangedInternal; | |
| public ViewModelWithSubscriptionCheck? Nested { get; set; } | |
| public int PropertyChangedSubscribers { get; private set; } = 0; | |
| public event PropertyChangedEventHandler? PropertyChanged | |
| { | |
| add | |
| { | |
| PropertyChangedSubscribers++; | |
| _propertyChangedInternal += value; | |
| } | |
| remove | |
| { | |
| PropertyChangedSubscribers--; | |
| _propertyChangedInternal -= value; | |
| } | |
| } | |
| public void OnPropertyChanged(string propertyName) | |
| { | |
| _propertyChangedInternal?.Invoke(this, new PropertyChangedEventArgs(propertyName)); | |
| } | |
| } | |
| [Fact] | |
| public void Subscribe_CallsCallbackWithInitialValue() | |
| { | |
| var vm = new TestViewModel { Name = "Initial" }; | |
| string? receivedValue = null; | |
| var sub = vm.WhenAnyValue(x => x.Name) | |
| .Subscribe(value => receivedValue = value); | |
| Assert.Equal("Initial", receivedValue); | |
| sub.Dispose(); | |
| } | |
| [Fact] | |
| public void Subscribe_CallsCallbackWhenPropertyChanges() | |
| { | |
| var vm = new TestViewModel { Name = "Initial" }; | |
| var callCount = 0; | |
| string? receivedValue = null; | |
| var sub = vm.WhenAnyValue(x => x.Name) | |
| .Subscribe(value => | |
| { | |
| receivedValue = value; | |
| callCount++; | |
| }); | |
| vm.Name = "Updated"; | |
| Assert.Equal("Updated", receivedValue); | |
| Assert.Equal(2, callCount); // Initial + change | |
| sub.Dispose(); | |
| } | |
| [Fact] | |
| public void NestedProperty_TracksChanges() | |
| { | |
| var vm = new TestViewModel { Nested = new NestedViewModel { Value = "Initial" } }; | |
| string? receivedValue = null; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Value) | |
| .Subscribe(value => receivedValue = value); | |
| Assert.Equal("Initial", receivedValue); | |
| vm.Nested.Value = "Updated"; | |
| Assert.Equal("Updated", receivedValue); | |
| sub.Dispose(); | |
| } | |
| [Fact] | |
| public void NestedProperty_TracksParentChange() | |
| { | |
| var vm = new TestViewModel { Nested = new NestedViewModel { Value = "First" } }; | |
| var callCount = 0; | |
| string? receivedValue = null; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Value) | |
| .Subscribe(value => | |
| { | |
| receivedValue = value; | |
| callCount++; | |
| }); | |
| vm.Nested = new NestedViewModel { Value = "Second" }; | |
| Assert.Equal("Second", receivedValue); | |
| Assert.Equal(2, callCount); | |
| sub.Dispose(); | |
| } | |
| [Fact] | |
| public void Subscribe_DoesNotCallbackWhenValueIsSame() | |
| { | |
| var vm = new TestViewModel { Name = "Same" }; | |
| var callCount = 0; | |
| var sub = vm.WhenAnyValue(x => x.Name) | |
| .Subscribe(_ => callCount++); | |
| vm.Name = "Same"; | |
| Assert.Equal(1, callCount); // Only initial | |
| sub.Dispose(); | |
| } | |
| [Fact] | |
| public void DeeplyNestedProperty_TracksChanges() | |
| { | |
| var vm = new TestViewModel { Nested = new NestedViewModel { Deep = new DeeplyNestedViewModel { Count = 1 } } }; | |
| var receivedValue = 0; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Deep) | |
| .WhenAnyValue(x => x!.Count) | |
| .Subscribe(value => receivedValue = value); | |
| Assert.Equal(1, receivedValue); | |
| vm.Nested.Deep.Count = 42; | |
| Assert.Equal(42, receivedValue); | |
| sub.Dispose(); | |
| } | |
| [Fact] | |
| public void Dispose_UnsubscribesFromPropertyChanged() | |
| { | |
| var vm = new TestViewModel { Name = "Test" }; | |
| var callCount = 0; | |
| var sub = vm.WhenAnyValue(x => x.Name) | |
| .Subscribe(_ => callCount++); | |
| sub.Dispose(); | |
| vm.Name = "Updated"; | |
| Assert.Equal(1, callCount); // Only initial | |
| } | |
| [Fact] | |
| public void NullNestedProperty_HandlesGracefully() | |
| { | |
| var vm = new TestViewModel { Nested = null }; | |
| string? receivedValue = "NotNull"; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Value) | |
| .Subscribe(value => receivedValue = value); | |
| Assert.Null(receivedValue); | |
| vm.Nested = new NestedViewModel { Value = "Now has value" }; | |
| Assert.Equal("Now has value", receivedValue); | |
| sub.Dispose(); | |
| } | |
| [Fact] | |
| public void NestedPropertyBecomesNull_HandlesGracefully() | |
| { | |
| var vm = new TestViewModel { Nested = new NestedViewModel { Value = "Initial" } }; | |
| string? receivedValue = null; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Value) | |
| .Subscribe(value => receivedValue = value); | |
| vm.Nested = null; | |
| Assert.Null(receivedValue); | |
| sub.Dispose(); | |
| } | |
| [Fact] | |
| public void MultipleSubscriptions_WorkIndependently() | |
| { | |
| var vm = new TestViewModel { Name = "Test" }; | |
| var count1 = 0; | |
| var count2 = 0; | |
| var sub1 = vm.WhenAnyValue(x => x.Name).Subscribe(_ => count1++); | |
| var sub2 = vm.WhenAnyValue(x => x.Name).Subscribe(_ => count2++); | |
| vm.Name = "Updated"; | |
| Assert.Equal(2, count1); | |
| Assert.Equal(2, count2); | |
| sub1.Dispose(); | |
| vm.Name = "Again"; | |
| Assert.Equal(2, count1); // Stopped after dispose | |
| Assert.Equal(3, count2); // Still tracking | |
| sub2.Dispose(); | |
| } | |
| [Fact] | |
| public void Dispose_UnsubscribesFromSingleProperty() | |
| { | |
| var vm = new ViewModelWithSubscriptionCheck(); | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .Subscribe(_ => { }); | |
| Assert.Equal(1, vm.PropertyChangedSubscribers); | |
| sub.Dispose(); | |
| Assert.Equal(0, vm.PropertyChangedSubscribers); | |
| } | |
| [Fact] | |
| public void Dispose_UnsubscribesFromNestedProperties() | |
| { | |
| var nested = new ViewModelWithSubscriptionCheck(); | |
| var vm = new ViewModelWithSubscriptionCheck { Nested = nested }; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Nested) | |
| .Subscribe(_ => { }); | |
| Assert.Equal(1, vm.PropertyChangedSubscribers); | |
| Assert.Equal(1, nested.PropertyChangedSubscribers); | |
| sub.Dispose(); | |
| Assert.Equal(0, vm.PropertyChangedSubscribers); | |
| Assert.Equal(0, nested.PropertyChangedSubscribers); | |
| } | |
| [Fact] | |
| public void Dispose_UnsubscribesFromDeeplyNestedProperties() | |
| { | |
| var deepNested = new ViewModelWithSubscriptionCheck(); | |
| var nested = new ViewModelWithSubscriptionCheck { Nested = deepNested }; | |
| var vm = new ViewModelWithSubscriptionCheck { Nested = nested }; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Nested) | |
| .WhenAnyValue(x => x!.Nested) | |
| .Subscribe(_ => { }); | |
| Assert.Equal(1, vm.PropertyChangedSubscribers); | |
| Assert.Equal(1, nested.PropertyChangedSubscribers); | |
| Assert.Equal(1, deepNested.PropertyChangedSubscribers); | |
| sub.Dispose(); | |
| Assert.Equal(0, vm.PropertyChangedSubscribers); | |
| Assert.Equal(0, nested.PropertyChangedSubscribers); | |
| Assert.Equal(0, deepNested.PropertyChangedSubscribers); | |
| } | |
| [Fact] | |
| public void ParentChange_UnsubscribesFromOldNested() | |
| { | |
| var oldNested = new ViewModelWithSubscriptionCheck(); | |
| var newNested = new ViewModelWithSubscriptionCheck(); | |
| var vm = new ViewModelWithSubscriptionCheck { Nested = oldNested }; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Nested) | |
| .Subscribe(_ => { }); | |
| Assert.Equal(1, vm.PropertyChangedSubscribers); | |
| Assert.Equal(1, oldNested.PropertyChangedSubscribers); | |
| Assert.Equal(0, newNested.PropertyChangedSubscribers); | |
| // Change parent property - should unsubscribe from old, subscribe to new | |
| vm.Nested = newNested; | |
| vm.OnPropertyChanged(nameof(ViewModelWithSubscriptionCheck.Nested)); | |
| Assert.Equal(1, vm.PropertyChangedSubscribers); | |
| Assert.Equal(0, oldNested.PropertyChangedSubscribers); // Unsubscribed from old | |
| Assert.Equal(1, newNested.PropertyChangedSubscribers); // Subscribed to new | |
| sub.Dispose(); | |
| Assert.Equal(0, vm.PropertyChangedSubscribers); | |
| Assert.Equal(0, oldNested.PropertyChangedSubscribers); | |
| Assert.Equal(0, newNested.PropertyChangedSubscribers); | |
| } | |
| [Fact] | |
| public void NestedChange_UnsubscribesFromOldDeepNested() | |
| { | |
| var oldDeep = new ViewModelWithSubscriptionCheck(); | |
| var newDeep = new ViewModelWithSubscriptionCheck(); | |
| var nested = new ViewModelWithSubscriptionCheck { Nested = oldDeep }; | |
| var vm = new ViewModelWithSubscriptionCheck { Nested = nested }; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Nested) | |
| .WhenAnyValue(x => x!.Nested) | |
| .Subscribe(_ => { }); | |
| Assert.Equal(1, oldDeep.PropertyChangedSubscribers); | |
| Assert.Equal(0, newDeep.PropertyChangedSubscribers); | |
| // Change nested property - should resubscribe entire chain | |
| nested.Nested = newDeep; | |
| nested.OnPropertyChanged(nameof(ViewModelWithSubscriptionCheck.Nested)); | |
| Assert.Equal(0, oldDeep.PropertyChangedSubscribers); // Unsubscribed from old | |
| Assert.Equal(1, newDeep.PropertyChangedSubscribers); // Subscribed to new | |
| sub.Dispose(); | |
| Assert.Equal(0, vm.PropertyChangedSubscribers); | |
| Assert.Equal(0, nested.PropertyChangedSubscribers); | |
| Assert.Equal(0, newDeep.PropertyChangedSubscribers); | |
| } | |
| [Fact] | |
| public void NullNestedProperty_UnsubscribesFromPreviousValue() | |
| { | |
| var nested = new ViewModelWithSubscriptionCheck(); | |
| var vm = new ViewModelWithSubscriptionCheck { Nested = nested }; | |
| var sub = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Nested) | |
| .Subscribe(_ => { }); | |
| Assert.Equal(1, vm.PropertyChangedSubscribers); | |
| Assert.Equal(1, nested.PropertyChangedSubscribers); | |
| // Set to null - should unsubscribe from nested | |
| vm.Nested = null; | |
| vm.OnPropertyChanged(nameof(ViewModelWithSubscriptionCheck.Nested)); | |
| Assert.Equal(1, vm.PropertyChangedSubscribers); // Still subscribed to root | |
| Assert.Equal(0, nested.PropertyChangedSubscribers); // Unsubscribed from nested | |
| sub.Dispose(); | |
| Assert.Equal(0, vm.PropertyChangedSubscribers); | |
| } | |
| [Fact] | |
| public void MultipleSubscriptions_DoNotInterfereWithEachOther() | |
| { | |
| var nested = new ViewModelWithSubscriptionCheck(); | |
| var vm = new ViewModelWithSubscriptionCheck { Nested = nested }; | |
| var sub1 = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Nested) | |
| .Subscribe(_ => { }); | |
| var sub2 = vm.WhenAnyValue(x => x.Nested) | |
| .WhenAnyValue(x => x!.Nested) | |
| .Subscribe(_ => { }); | |
| Assert.Equal(2, vm.PropertyChangedSubscribers); | |
| Assert.Equal(2, nested.PropertyChangedSubscribers); | |
| sub1.Dispose(); | |
| Assert.Equal(1, vm.PropertyChangedSubscribers); // One subscription remains | |
| Assert.Equal(1, nested.PropertyChangedSubscribers); | |
| sub2.Dispose(); | |
| Assert.Equal(0, vm.PropertyChangedSubscribers); | |
| Assert.Equal(0, nested.PropertyChangedSubscribers); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| var vm = new TestViewModel { Nested = new NestedViewModel { Value = "Initial" } }; | |
| string? receivedValue = null; | |
| using var sub = vm.WhenAnyValue(static x => x.Nested) | |
| .WhenAnyValue(static x => x!.Value) | |
| .Subscribe(value => receivedValue = value); | |
| Assert.Equal("Initial", receivedValue); | |
| vm.Nested.Value = "Updated"; | |
| Assert.Equal("Updated", receivedValue); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment