diff --git a/PowerKit.Tests/Extensions/NotifyPropertyChangedExtensionsTests.cs b/PowerKit.Tests/Extensions/NotifyPropertyChangedExtensionsTests.cs new file mode 100644 index 0000000..62953ef --- /dev/null +++ b/PowerKit.Tests/Extensions/NotifyPropertyChangedExtensionsTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using FluentAssertions; +using PowerKit.Extensions; +using Xunit; + +namespace PowerKit.Tests.Extensions; + +file class FakeNotifyPropertyChanged : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + public string? StringProperty + { + get; + set + { + field = value; + OnPropertyChanged(); + } + } + + public int IntProperty + { + get; + set + { + field = value; + OnPropertyChanged(); + } + } + + public void RaiseAllPropertiesChanged() => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty)); + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} + +public class NotifyPropertyChangedExtensionsTests +{ + [Fact] + public void WatchProperty_Test() + { + // Arrange + var obj = new FakeNotifyPropertyChanged { StringProperty = "initial" }; + var values = new List(); + + // Act + var sub = obj.WatchProperty(x => x.StringProperty, v => values.Add(v), true); + + obj.StringProperty = "hello"; + obj.IntProperty = 42; + obj.RaiseAllPropertiesChanged(); + sub.Dispose(); + obj.StringProperty = "world"; + + // Assert + values.Should().Equal("initial", "hello", "hello"); + } + + [Fact] + public void WatchProperties_Test() + { + // Arrange + var obj = new FakeNotifyPropertyChanged(); + var callCount = 0; + + // Act + var sub = obj.WatchProperties( + [x => x.StringProperty, x => x.IntProperty], + () => callCount++, + true + ); + + obj.StringProperty = "hello"; + obj.IntProperty = 42; + obj.RaiseAllPropertiesChanged(); + sub.Dispose(); + obj.StringProperty = "world"; + + // Assert + callCount.Should().Be(4); + } + + [Fact] + public void WatchAllProperties_Test() + { + // Arrange + var obj = new FakeNotifyPropertyChanged(); + var callCount = 0; + + // Act + var sub = obj.WatchAllProperties(() => callCount++, true); + obj.StringProperty = "hello"; + obj.IntProperty = 42; + sub.Dispose(); + obj.StringProperty = "world"; + + // Assert + callCount.Should().Be(3); + } +} diff --git a/PowerKit/Extensions/NotifyPropertyChangedExtensions.cs b/PowerKit/Extensions/NotifyPropertyChangedExtensions.cs new file mode 100644 index 0000000..a3800c9 --- /dev/null +++ b/PowerKit/Extensions/NotifyPropertyChangedExtensions.cs @@ -0,0 +1,144 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace PowerKit.Extensions; + +#if !POWERKIT_INCLUDE_COVERAGE +[ExcludeFromCodeCoverage] +#endif +internal static class NotifyPropertyChangedExtensions +{ + extension(TOwner owner) + where TOwner : INotifyPropertyChanged + { + /// + /// Subscribes to changes of the specified property on the owner object. + /// The returned can be disposed to unsubscribe. + /// + public IDisposable WatchProperty( + Expression> propertyExpression, + Action callback, + bool watchInitialValue = false + ) + { + var memberExpression = + propertyExpression.Body as MemberExpression + // The compiler implicitly wraps value types in a conversion expression when + // the expression is typed to return a reference type. + ?? (propertyExpression.Body as UnaryExpression)?.Operand as MemberExpression; + + if ( + memberExpression?.Member is not PropertyInfo property + || !property.DeclaringType!.IsAssignableFrom(typeof(TOwner)) + ) + { + throw new ArgumentException( + "Provided expression must reference a property of the owner type.", + nameof(propertyExpression) + ); + } + + var getValue = propertyExpression.Compile(); + + void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if ( + string.IsNullOrWhiteSpace(args.PropertyName) + || string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal) + ) + { + callback(getValue(owner)); + } + } + + owner.PropertyChanged += OnPropertyChanged; + + if (watchInitialValue) + { + callback(getValue(owner)); + } + + return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); + } + + /// + /// Subscribes to changes of the specified properties on the owner object. + /// The returned can be disposed to unsubscribe. + /// + public IDisposable WatchProperties( + IEnumerable>> propertyExpressions, + Action callback, + bool watchInitialValue = false + ) + { + var properties = propertyExpressions + .Select(expression => + { + var memberExpression = + expression.Body as MemberExpression + // Because the expression is typed to return an object, the compiler will + // implicitly wrap it in a conversion unary expression if it's of any other type. + ?? (expression.Body as UnaryExpression)?.Operand as MemberExpression; + + if ( + memberExpression?.Member is not PropertyInfo property + || !property.DeclaringType!.IsAssignableFrom(typeof(TOwner)) + ) + { + throw new ArgumentException( + "Provided expression must reference a property of the owner type.", + nameof(propertyExpressions) + ); + } + + return property; + }) + .ToArray(); + + void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if ( + string.IsNullOrWhiteSpace(args.PropertyName) + || properties.Any(p => + string.Equals(args.PropertyName, p.Name, StringComparison.Ordinal) + ) + ) + { + callback(); + } + } + + owner.PropertyChanged += OnPropertyChanged; + + if (watchInitialValue) + { + callback(); + } + + return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); + } + + /// + /// Subscribes to changes of all properties on the owner object. + /// The returned can be disposed to unsubscribe. + /// + public IDisposable WatchAllProperties(Action callback, bool watchInitialValue = false) + { + void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback(); + owner.PropertyChanged += OnPropertyChanged; + + if (watchInitialValue) + { + callback(); + } + + return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); + } + } +}