diff --git a/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableObject.cs b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableObject.cs index f1c9b8989b5..cd7fec0634c 100644 --- a/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableObject.cs +++ b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableObject.cs @@ -27,25 +27,39 @@ public abstract class ObservableObject : INotifyPropertyChanged, INotifyProperty public event PropertyChangingEventHandler? PropertyChanging; /// - /// Performs the required configuration when a property has changed, and then - /// raises the event to notify listeners of the update. + /// Raises the event. + /// + /// The input instance. + protected virtual void OnPropertyChanged(PropertyChangedEventArgs e) + { + PropertyChanged?.Invoke(this, e); + } + + /// + /// Raises the event. + /// + /// The input instance. + protected virtual void OnPropertyChanging(PropertyChangingEventArgs e) + { + PropertyChanging?.Invoke(this, e); + } + + /// + /// Raises the event. /// /// (optional) The name of the property that changed. - /// The base implementation only raises the event. - protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + OnPropertyChanged(new PropertyChangedEventArgs(propertyName)); } /// - /// Performs the required configuration when a property is changing, and then - /// raises the event to notify listeners of the update. + /// Raises the event. /// /// (optional) The name of the property that changed. - /// The base implementation only raises the event. - protected virtual void OnPropertyChanging([CallerMemberName] string? propertyName = null) + protected void OnPropertyChanging([CallerMemberName] string? propertyName = null) { - PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName)); + OnPropertyChanging(new PropertyChangingEventArgs(propertyName)); } /// diff --git a/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableValidator.cs b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableValidator.cs index 2a96ab7b837..e990fcc8b1d 100644 --- a/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableValidator.cs +++ b/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableValidator.cs @@ -19,33 +19,28 @@ namespace Microsoft.Toolkit.Mvvm.ComponentModel /// public abstract class ObservableValidator : ObservableObject, INotifyDataErrorInfo { + /// + /// The cached for . + /// + private static readonly PropertyChangedEventArgs HasErrorsChangedEventArgs = new PropertyChangedEventArgs(nameof(HasErrors)); + /// /// The instance used to store previous validation results. /// private readonly Dictionary> errors = new Dictionary>(); + /// + /// Indicates the total number of properties with errors (not total errors). + /// This is used to allow to operate in O(1) time, as it can just + /// check whether this value is not 0 instead of having to traverse . + /// + private int totalErrors; + /// public event EventHandler? ErrorsChanged; /// - public bool HasErrors - { - get - { - // This uses the value enumerator for Dictionary.ValueCollection, so it doesn't - // allocate. Accessing this property is O(n), but we can stop as soon as we find at least one - // error in the whole entity, and doing this saves 8 bytes in the object size (no fields needed). - foreach (var value in this.errors.Values) - { - if (value.Count > 0) - { - return true; - } - } - - return false; - } - } + public bool HasErrors => this.totalErrors > 0; /// /// Compares the current and new values for a given property. If the value has changed, @@ -67,12 +62,14 @@ public bool HasErrors /// protected bool SetProperty(ref T field, T newValue, bool validate, [CallerMemberName] string? propertyName = null) { - if (validate) + bool propertyChanged = SetProperty(ref field, newValue, propertyName); + + if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } - return SetProperty(ref field, newValue, propertyName); + return propertyChanged; } /// @@ -90,12 +87,14 @@ protected bool SetProperty(ref T field, T newValue, bool validate, [CallerMem /// if the property was changed, otherwise. protected bool SetProperty(ref T field, T newValue, IEqualityComparer comparer, bool validate, [CallerMemberName] string? propertyName = null) { - if (validate) + bool propertyChanged = SetProperty(ref field, newValue, comparer, propertyName); + + if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } - return SetProperty(ref field, newValue, comparer, propertyName); + return propertyChanged; } /// @@ -120,12 +119,14 @@ protected bool SetProperty(ref T field, T newValue, IEqualityComparer comp /// protected bool SetProperty(T oldValue, T newValue, Action callback, bool validate, [CallerMemberName] string? propertyName = null) { - if (validate) + bool propertyChanged = SetProperty(oldValue, newValue, callback, propertyName); + + if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } - return SetProperty(oldValue, newValue, callback, propertyName); + return propertyChanged; } /// @@ -144,12 +145,14 @@ protected bool SetProperty(T oldValue, T newValue, Action callback, bool v /// if the property was changed, otherwise. protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, Action callback, bool validate, [CallerMemberName] string? propertyName = null) { - if (validate) + bool propertyChanged = SetProperty(oldValue, newValue, comparer, callback, propertyName); + + if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } - return SetProperty(oldValue, newValue, comparer, callback, propertyName); + return propertyChanged; } /// @@ -172,12 +175,14 @@ protected bool SetProperty(T oldValue, T newValue, IEqualityComparer compa protected bool SetProperty(T oldValue, T newValue, TModel model, Action callback, bool validate, [CallerMemberName] string? propertyName = null) where TModel : class { - if (validate) + bool propertyChanged = SetProperty(oldValue, newValue, model, callback, propertyName); + + if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } - return SetProperty(oldValue, newValue, model, callback, propertyName); + return propertyChanged; } /// @@ -202,12 +207,123 @@ protected bool SetProperty(T oldValue, T newValue, TModel model, Acti protected bool SetProperty(T oldValue, T newValue, IEqualityComparer comparer, TModel model, Action callback, bool validate, [CallerMemberName] string? propertyName = null) where TModel : class { - if (validate) + bool propertyChanged = SetProperty(oldValue, newValue, comparer, model, callback, propertyName); + + if (propertyChanged && validate) { ValidateProperty(newValue, propertyName); } - return SetProperty(oldValue, newValue, comparer, model, callback, propertyName); + return propertyChanged; + } + + /// + /// Tries to validate a new value for a specified property. If the validation is successful, + /// is called, otherwise no state change is performed. + /// + /// The type of the property that changed. + /// The field storing the property's value. + /// The property's value after the change occurred. + /// The resulting validation errors, if any. + /// (optional) The name of the property that changed. + /// Whether the validation was successful and the property value changed as well. + protected bool TrySetProperty(ref T field, T newValue, out IReadOnlyCollection errors, [CallerMemberName] string? propertyName = null) + { + return TryValidateProperty(newValue, propertyName, out errors) && + SetProperty(ref field, newValue, propertyName); + } + + /// + /// Tries to validate a new value for a specified property. If the validation is successful, + /// is called, otherwise no state change is performed. + /// + /// The type of the property that changed. + /// The field storing the property's value. + /// The property's value after the change occurred. + /// The instance to use to compare the input values. + /// The resulting validation errors, if any. + /// (optional) The name of the property that changed. + /// Whether the validation was successful and the property value changed as well. + protected bool TrySetProperty(ref T field, T newValue, IEqualityComparer comparer, out IReadOnlyCollection errors, [CallerMemberName] string? propertyName = null) + { + return TryValidateProperty(newValue, propertyName, out errors) && + SetProperty(ref field, newValue, comparer, propertyName); + } + + /// + /// Tries to validate a new value for a specified property. If the validation is successful, + /// is called, otherwise no state change is performed. + /// + /// The type of the property that changed. + /// The current property value. + /// The property's value after the change occurred. + /// A callback to invoke to update the property value. + /// The resulting validation errors, if any. + /// (optional) The name of the property that changed. + /// Whether the validation was successful and the property value changed as well. + protected bool TrySetProperty(T oldValue, T newValue, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string? propertyName = null) + { + return TryValidateProperty(newValue, propertyName, out errors) && + SetProperty(oldValue, newValue, callback, propertyName); + } + + /// + /// Tries to validate a new value for a specified property. If the validation is successful, + /// is called, otherwise no state change is performed. + /// + /// The type of the property that changed. + /// The current property value. + /// The property's value after the change occurred. + /// The instance to use to compare the input values. + /// A callback to invoke to update the property value. + /// The resulting validation errors, if any. + /// (optional) The name of the property that changed. + /// Whether the validation was successful and the property value changed as well. + protected bool TrySetProperty(T oldValue, T newValue, IEqualityComparer comparer, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string? propertyName = null) + { + return TryValidateProperty(newValue, propertyName, out errors) && + SetProperty(oldValue, newValue, comparer, callback, propertyName); + } + + /// + /// Tries to validate a new value for a specified property. If the validation is successful, + /// is called, otherwise no state change is performed. + /// + /// The type of model whose property (or field) to set. + /// The type of property (or field) to set. + /// The current property value. + /// The property's value after the change occurred. + /// The model + /// The callback to invoke to set the target property value, if a change has occurred. + /// The resulting validation errors, if any. + /// (optional) The name of the property that changed. + /// Whether the validation was successful and the property value changed as well. + protected bool TrySetProperty(T oldValue, T newValue, TModel model, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string? propertyName = null) + where TModel : class + { + return TryValidateProperty(newValue, propertyName, out errors) && + SetProperty(oldValue, newValue, model, callback, propertyName); + } + + /// + /// Tries to validate a new value for a specified property. If the validation is successful, + /// is called, otherwise no state change is performed. + /// + /// The type of model whose property (or field) to set. + /// The type of property (or field) to set. + /// The current property value. + /// The property's value after the change occurred. + /// The instance to use to compare the input values. + /// The model + /// The callback to invoke to set the target property value, if a change has occurred. + /// The resulting validation errors, if any. + /// (optional) The name of the property that changed. + /// Whether the validation was successful and the property value changed as well. + protected bool TrySetProperty(T oldValue, T newValue, IEqualityComparer comparer, TModel model, Action callback, out IReadOnlyCollection errors, [CallerMemberName] string? propertyName = null) + where TModel : class + { + return TryValidateProperty(newValue, propertyName, out errors) && + SetProperty(oldValue, newValue, comparer, model, callback, propertyName); } /// @@ -285,6 +401,34 @@ private void ValidateProperty(object? value, string? propertyName) new ValidationContext(this, null, null) { MemberName = propertyName }, propertyErrors); + // Update the shared counter for the number of errors, and raise the + // property changed event if necessary. We decrement the number of total + // errors if the current property is valid but it wasn't so before this + // validation, and we increment it if the validation failed after being + // correct before. The property changed event is raised whenever the + // number of total errors is either decremented to 0, or incremented to 1. + if (isValid) + { + if (errorsChanged) + { + this.totalErrors--; + + if (this.totalErrors == 0) + { + OnPropertyChanged(HasErrorsChangedEventArgs); + } + } + } + else if (!errorsChanged) + { + this.totalErrors++; + + if (this.totalErrors == 1) + { + OnPropertyChanged(HasErrorsChangedEventArgs); + } + } + // Only raise the event once if needed. This happens either when the target property // had existing errors and is now valid, or if the validation has failed and there are // new errors to broadcast, regardless of the previous validation state for the property. @@ -294,6 +438,61 @@ private void ValidateProperty(object? value, string? propertyName) } } + /// + /// Tries to validate a property with a specified name and a given input value, and returns + /// the computed errors, if any. If the property is valid, it is assumed that its value is + /// about to be set in the current object. Otherwise, no observable local state is modified. + /// + /// The value to test for the specified property. + /// The name of the property to validate. + /// The resulting validation errors, if any. + /// Thrown when is . + private bool TryValidateProperty(object? value, string? propertyName, out IReadOnlyCollection errors) + { + if (propertyName is null) + { + ThrowArgumentNullExceptionForNullPropertyName(); + } + + // Add the cached errors list for later use. + if (!this.errors.TryGetValue(propertyName!, out List? propertyErrors)) + { + propertyErrors = new List(); + + this.errors.Add(propertyName!, propertyErrors); + } + + bool hasErrors = propertyErrors.Count > 0; + + List localErrors = new List(); + + // Validate the property, by adding new errors to the local list + bool isValid = Validator.TryValidateProperty( + value, + new ValidationContext(this, null, null) { MemberName = propertyName }, + localErrors); + + // We only modify the state if the property is valid and it wasn't so before. In this case, we + // clear the cached list of errors (which is visible to consumers) and raise the necessary events. + if (isValid && hasErrors) + { + propertyErrors.Clear(); + + this.totalErrors--; + + if (this.totalErrors == 0) + { + OnPropertyChanged(HasErrorsChangedEventArgs); + } + + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName)); + } + + errors = localErrors; + + return isValid; + } + #pragma warning disable SA1204 /// /// Throws an when a property name given as input is . diff --git a/Microsoft.Toolkit.Mvvm/DependencyInjection/Ioc.cs b/Microsoft.Toolkit.Mvvm/DependencyInjection/Ioc.cs new file mode 100644 index 00000000000..b44819888ff --- /dev/null +++ b/Microsoft.Toolkit.Mvvm/DependencyInjection/Ioc.cs @@ -0,0 +1,167 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; + +#nullable enable + +namespace Microsoft.Toolkit.Mvvm.DependencyInjection +{ + /// + /// A type that facilitates the use of the type. + /// The provides the ability to configure services in a singleton, thread-safe + /// service provider instance, which can then be used to resolve service instances. + /// The first step to use this feature is to declare some services, for instance: + /// + /// public interface ILogger + /// { + /// void Log(string text); + /// } + /// + /// + /// public class ConsoleLogger : ILogger + /// { + /// void Log(string text) => Console.WriteLine(text); + /// } + /// + /// Then the services configuration should then be done at startup, by calling the + /// method and passing an instance with the services to use. That instance can + /// be from any library offering dependency injection functionality, such as Microsoft.Extensions.DependencyInjection. + /// For instance, using that library, can be used as follows in this example: + /// + /// Ioc.Default.ConfigureServices( + /// new ServiceCollection() + /// .AddSingleton<ILogger, Logger>() + /// .BuildServiceProvider()); + /// + /// Finally, you can use the instance (which implements ) + /// to retrieve the service instances from anywhere in your application, by doing as follows: + /// + /// Ioc.Default.GetService<ILogger>().Log("Hello world!"); + /// + /// + public sealed class Ioc : IServiceProvider + { + /// + /// Gets the default instance. + /// + public static Ioc Default { get; } = new Ioc(); + + /// + /// The instance to use, if initialized. + /// + private volatile IServiceProvider? serviceProvider; + + /// + public object? GetService(Type serviceType) + { + // As per section I.12.6.6 of the official CLI ECMA-335 spec: + // "[...] read and write access to properly aligned memory locations no larger than the native + // word size is atomic when all the write accesses to a location are the same size. Atomic writes + // shall alter no bits other than those written. Unless explicit layout control is used [...], + // data elements no larger than the natural word size [...] shall be properly aligned. + // Object references shall be treated as though they are stored in the native word size." + // The field being accessed here is of native int size (reference type), and is only ever accessed + // directly and atomically by a compare exchange instruction (see below), or here. We can therefore + // assume this read is thread safe with respect to accesses to this property or to invocations to one + // of the available configuration methods. So we can just read the field directly and make the necessary + // check with our local copy, without the need of paying the locking overhead from this get accessor. + IServiceProvider? provider = this.serviceProvider; + + if (provider is null) + { + ThrowInvalidOperationExceptionForMissingInitialization(); + } + + return provider!.GetService(serviceType); + } + + /// + /// Tries to resolve an instance of a specified service type. + /// + /// The type of service to resolve. + /// An instance of the specified service, or . + /// Throw if the current instance has not been initialized. + public T? GetService() + where T : class + { + IServiceProvider? provider = this.serviceProvider; + + if (provider is null) + { + ThrowInvalidOperationExceptionForMissingInitialization(); + } + + return (T?)provider!.GetService(typeof(T)); + } + + /// + /// Resolves an instance of a specified service type. + /// + /// The type of service to resolve. + /// An instance of the specified service, or . + /// + /// Throw if the current instance has not been initialized, or if the + /// requested service type was not registered in the service provider currently in use. + /// + public T GetRequiredService() + where T : class + { + IServiceProvider? provider = this.serviceProvider; + + if (provider is null) + { + ThrowInvalidOperationExceptionForMissingInitialization(); + } + + T? service = (T?)provider!.GetService(typeof(T)); + + if (service is null) + { + ThrowInvalidOperationExceptionForUnregisteredType(); + } + + return service!; + } + + /// + /// Initializes the shared instance. + /// + /// The input instance to use. + public void ConfigureServices(IServiceProvider serviceProvider) + { + IServiceProvider? oldServices = Interlocked.CompareExchange(ref this.serviceProvider, serviceProvider, null); + + if (!(oldServices is null)) + { + ThrowInvalidOperationExceptionForRepeatedConfiguration(); + } + } + + /// + /// Throws an when the property is used before initialization. + /// + private static void ThrowInvalidOperationExceptionForMissingInitialization() + { + throw new InvalidOperationException("The service provider has not been configured yet"); + } + + /// + /// Throws an when the property is missing a type registration. + /// + private static void ThrowInvalidOperationExceptionForUnregisteredType() + { + throw new InvalidOperationException("The requested service type was not registered"); + } + + /// + /// Throws an when a configuration is attempted more than once. + /// + private static void ThrowInvalidOperationExceptionForRepeatedConfiguration() + { + throw new InvalidOperationException("The default service provider has already been configured"); + } + } +} \ No newline at end of file diff --git a/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand.cs b/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand.cs index d7edbca8e05..e9678946137 100644 --- a/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand.cs +++ b/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.ComponentModel; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -18,6 +19,21 @@ namespace Microsoft.Toolkit.Mvvm.Input /// public sealed class AsyncRelayCommand : ObservableObject, IAsyncRelayCommand { + /// + /// The cached for . + /// + internal static readonly PropertyChangedEventArgs CanBeCanceledChangedEventArgs = new PropertyChangedEventArgs(nameof(CanBeCanceled)); + + /// + /// The cached for . + /// + internal static readonly PropertyChangedEventArgs IsCancellationRequestedChangedEventArgs = new PropertyChangedEventArgs(nameof(IsCancellationRequested)); + + /// + /// The cached for . + /// + internal static readonly PropertyChangedEventArgs IsRunningChangedEventArgs = new PropertyChangedEventArgs(nameof(IsRunning)); + /// /// The to invoke when is used. /// @@ -91,15 +107,22 @@ public Task? ExecutionTask get => this.executionTask; private set { - if (SetPropertyAndNotifyOnCompletion(ref this.executionTask, value, _ => OnPropertyChanged(nameof(IsRunning)))) + if (SetPropertyAndNotifyOnCompletion(ref this.executionTask, value, _ => + { + // When the task completes + OnPropertyChanged(IsRunningChangedEventArgs); + OnPropertyChanged(CanBeCanceledChangedEventArgs); + })) { - OnPropertyChanged(nameof(IsRunning)); + // When setting the task + OnPropertyChanged(IsRunningChangedEventArgs); + OnPropertyChanged(CanBeCanceledChangedEventArgs); } } } /// - public bool CanBeCanceled => !(this.cancelableExecute is null); + public bool CanBeCanceled => !(this.cancelableExecute is null) && IsRunning; /// public bool IsCancellationRequested => this.cancellationTokenSource?.IsCancellationRequested == true; @@ -142,7 +165,7 @@ public Task ExecuteAsync(object? parameter) var cancellationTokenSource = this.cancellationTokenSource = new CancellationTokenSource(); - OnPropertyChanged(nameof(IsCancellationRequested)); + OnPropertyChanged(IsCancellationRequestedChangedEventArgs); // Invoke the cancelable command delegate with a new linked token return ExecutionTask = this.cancelableExecute!(cancellationTokenSource.Token); @@ -156,7 +179,8 @@ public void Cancel() { this.cancellationTokenSource?.Cancel(); - OnPropertyChanged(nameof(IsCancellationRequested)); + OnPropertyChanged(IsCancellationRequestedChangedEventArgs); + OnPropertyChanged(CanBeCanceledChangedEventArgs); } } } \ No newline at end of file diff --git a/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand{T}.cs b/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand{T}.cs index 1acf8e72a23..61f1962b1d7 100644 --- a/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand{T}.cs +++ b/Microsoft.Toolkit.Mvvm/Input/AsyncRelayCommand{T}.cs @@ -91,15 +91,22 @@ public Task? ExecutionTask get => this.executionTask; private set { - if (SetPropertyAndNotifyOnCompletion(ref this.executionTask, value, _ => OnPropertyChanged(nameof(IsRunning)))) + if (SetPropertyAndNotifyOnCompletion(ref this.executionTask, value, _ => { - OnPropertyChanged(nameof(IsRunning)); + // When the task completes + OnPropertyChanged(AsyncRelayCommand.IsRunningChangedEventArgs); + OnPropertyChanged(AsyncRelayCommand.CanBeCanceledChangedEventArgs); + })) + { + // When setting the task + OnPropertyChanged(AsyncRelayCommand.IsRunningChangedEventArgs); + OnPropertyChanged(AsyncRelayCommand.CanBeCanceledChangedEventArgs); } } } /// - public bool CanBeCanceled => !(this.cancelableExecute is null); + public bool CanBeCanceled => !(this.cancelableExecute is null) && IsRunning; /// public bool IsCancellationRequested => this.cancellationTokenSource?.IsCancellationRequested == true; @@ -163,7 +170,7 @@ public Task ExecuteAsync(T parameter) var cancellationTokenSource = this.cancellationTokenSource = new CancellationTokenSource(); - OnPropertyChanged(nameof(IsCancellationRequested)); + OnPropertyChanged(AsyncRelayCommand.IsCancellationRequestedChangedEventArgs); // Invoke the cancelable command delegate with a new linked token return ExecutionTask = this.cancelableExecute!(parameter, cancellationTokenSource.Token); @@ -183,7 +190,8 @@ public void Cancel() { this.cancellationTokenSource?.Cancel(); - OnPropertyChanged(nameof(IsCancellationRequested)); + OnPropertyChanged(AsyncRelayCommand.IsCancellationRequestedChangedEventArgs); + OnPropertyChanged(AsyncRelayCommand.CanBeCanceledChangedEventArgs); } } } \ No newline at end of file diff --git a/Microsoft.Toolkit.Mvvm/Messaging/IMessengerExtensions.cs b/Microsoft.Toolkit.Mvvm/Messaging/IMessengerExtensions.cs index 72caa9fb7dd..ef8c7612701 100644 --- a/Microsoft.Toolkit.Mvvm/Messaging/IMessengerExtensions.cs +++ b/Microsoft.Toolkit.Mvvm/Messaging/IMessengerExtensions.cs @@ -27,27 +27,10 @@ public static class IMessengerExtensions /// private static class MethodInfos { - /// - /// Initializes static members of the class. - /// - static MethodInfos() - { - RegisterIRecipient = ( - from methodInfo in typeof(IMessengerExtensions).GetMethods() - where methodInfo.Name == nameof(Register) && - methodInfo.IsGenericMethod && - methodInfo.GetGenericArguments().Length == 2 - let parameters = methodInfo.GetParameters() - where parameters.Length == 3 && - parameters[1].ParameterType.IsGenericType && - parameters[1].ParameterType.GetGenericTypeDefinition() == typeof(IRecipient<>) - select methodInfo).First(); - } - /// /// The instance associated with . /// - public static readonly MethodInfo RegisterIRecipient; + public static readonly MethodInfo RegisterIRecipient = new Action, Unit>(Register).Method.GetGenericMethodDefinition(); } /// diff --git a/Microsoft.Toolkit.Mvvm/Messaging/Internals/Type2.cs b/Microsoft.Toolkit.Mvvm/Messaging/Internals/Type2.cs index 411b8809ba6..f6f203a37d6 100644 --- a/Microsoft.Toolkit.Mvvm/Messaging/Internals/Type2.cs +++ b/Microsoft.Toolkit.Mvvm/Messaging/Internals/Type2.cs @@ -65,19 +65,19 @@ public override bool Equals(object? obj) [MethodImpl(MethodImplOptions.AggressiveInlining)] public override int GetHashCode() { - unchecked - { - // To combine the two hashes, we can simply use the fast djb2 hash algorithm. - // This is not a problem in this case since we already know that the base - // RuntimeHelpers.GetHashCode method is providing hashes with a good enough distribution. - int hash = RuntimeHelpers.GetHashCode(TMessage); + // To combine the two hashes, we can simply use the fast djb2 hash algorithm. Unfortunately we + // can't really skip the callvirt here (eg. by using RuntimeHelpers.GetHashCode like in other + // cases), as there are some niche cases mentioned above that might break when doing so. + // However since this method is not generally used in a hot path (eg. the message broadcasting + // only invokes this a handful of times when initially retrieving the target mapping), this + // doesn't actually make a noticeable difference despite the minor overhead of the virtual call. + int hash = TMessage.GetHashCode(); - hash = (hash << 5) + hash; + hash = (hash << 5) + hash; - hash += RuntimeHelpers.GetHashCode(TToken); + hash += TToken.GetHashCode(); - return hash; - } + return hash; } } } diff --git a/Microsoft.Toolkit.Mvvm/Messaging/StrongReferenceMessenger.cs b/Microsoft.Toolkit.Mvvm/Messaging/StrongReferenceMessenger.cs index a8e45b15c2e..dc414ffaeb0 100644 --- a/Microsoft.Toolkit.Mvvm/Messaging/StrongReferenceMessenger.cs +++ b/Microsoft.Toolkit.Mvvm/Messaging/StrongReferenceMessenger.cs @@ -371,8 +371,6 @@ public TMessage Send(TMessage message, TToken token) // that doesn't expose the single standard Current property. while (mappingEnumerator.MoveNext()) { - object recipient = mappingEnumerator.Key.Target; - // Pick the target handler, if the token is a match for the recipient if (mappingEnumerator.Value.TryGetValue(token, out object? handler)) { @@ -382,7 +380,7 @@ public TMessage Send(TMessage message, TToken token) // We're still using a checked span accesses here though to make sure an out of // bounds write can never happen even if an error was present in the logic above. pairs[2 * i] = handler!; - pairs[(2 * i) + 1] = recipient; + pairs[(2 * i) + 1] = mappingEnumerator.Key.Target; i++; } } diff --git a/UnitTests/UnitTests.Shared/Mvvm/Test_AsyncRelayCommand.cs b/UnitTests/UnitTests.Shared/Mvvm/Test_AsyncRelayCommand.cs index 09ff1d35a90..85d40de0505 100644 --- a/UnitTests/UnitTests.Shared/Mvvm/Test_AsyncRelayCommand.cs +++ b/UnitTests/UnitTests.Shared/Mvvm/Test_AsyncRelayCommand.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Threading.Tasks; using Microsoft.Toolkit.Mvvm.Input; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -124,26 +126,57 @@ public async Task Test_AsyncRelayCommand_WithCancellation() { TaskCompletionSource tcs = new TaskCompletionSource(); + // We need to test the cancellation support here, so we use the overload with an input + // parameter, which is a cancellation token. The token is the one that is internally managed + // by the AsyncRelayCommand instance, and canceled when using IAsyncRelayCommand.Cancel(). var command = new AsyncRelayCommand(token => tcs.Task); + List args = new List(); + + command.PropertyChanged += (s, e) => args.Add(e); + + // We have no canExecute parameter, so the command can always be invoked Assert.IsTrue(command.CanExecute(null)); Assert.IsTrue(command.CanExecute(new object())); - Assert.IsTrue(command.CanBeCanceled); + // The command isn't running, so it can't be canceled yet + Assert.IsFalse(command.CanBeCanceled); Assert.IsFalse(command.IsCancellationRequested); + // Start the command, which will return the token from our task completion source. + // We can use that to easily keep the command running while we do our tests, and then + // stop the processing by completing the source when we need (see below). command.Execute(null); + // The command is running, so it can be canceled, as we used the token overload + Assert.IsTrue(command.CanBeCanceled); Assert.IsFalse(command.IsCancellationRequested); + // Validate the various event args for all the properties that were updated when executing the command + Assert.AreEqual(args.Count, 4); + Assert.AreEqual(args[0].PropertyName, nameof(IAsyncRelayCommand.IsCancellationRequested)); + Assert.AreEqual(args[1].PropertyName, nameof(IAsyncRelayCommand.ExecutionTask)); + Assert.AreEqual(args[2].PropertyName, nameof(IAsyncRelayCommand.IsRunning)); + Assert.AreEqual(args[3].PropertyName, nameof(IAsyncRelayCommand.CanBeCanceled)); + command.Cancel(); + // Verify that these two properties raised notifications correctly when canceling the command too. + // We need to ensure all command properties support notifications so that users can bind to them. + Assert.AreEqual(args.Count, 6); + Assert.AreEqual(args[4].PropertyName, nameof(IAsyncRelayCommand.IsCancellationRequested)); + Assert.AreEqual(args[5].PropertyName, nameof(IAsyncRelayCommand.CanBeCanceled)); + Assert.IsTrue(command.IsCancellationRequested); + // Complete the source, which will mark the command as completed too (as it returned the same task) tcs.SetResult(null); await command.ExecutionTask!; + // Verify that the command can no longer be canceled, and that the cancellation is + // instead still true, as that's reset when executing a command and not on completion. + Assert.IsFalse(command.CanBeCanceled); Assert.IsTrue(command.IsCancellationRequested); } } diff --git a/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableValidator.cs b/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableValidator.cs index d5f6e243f40..41c94e6310a 100644 --- a/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableValidator.cs +++ b/UnitTests/UnitTests.Shared/Mvvm/Test_ObservableValidator.cs @@ -19,16 +19,28 @@ public class Test_ObservableValidator public void Test_ObservableValidator_HasErrors() { var model = new Person(); + var args = new List(); + + model.PropertyChanged += (s, e) => args.Add(e); Assert.IsFalse(model.HasErrors); model.Name = "No"; + // Verify that errors were correctly reported as changed, and that all the relevant + // properties were broadcast as well (both the changed property and HasErrors). We need + // this last one to raise notifications too so that users can bind to that in the UI. Assert.IsTrue(model.HasErrors); + Assert.AreEqual(args.Count, 2); + Assert.AreEqual(args[0].PropertyName, nameof(Person.Name)); + Assert.AreEqual(args[1].PropertyName, nameof(INotifyDataErrorInfo.HasErrors)); model.Name = "Valid"; Assert.IsFalse(model.HasErrors); + Assert.AreEqual(args.Count, 4); + Assert.AreEqual(args[2].PropertyName, nameof(Person.Name)); + Assert.AreEqual(args[3].PropertyName, nameof(INotifyDataErrorInfo.HasErrors)); } [TestCategory("Mvvm")] @@ -119,7 +131,6 @@ public void Test_ObservableValidator_GetErrors() [TestCategory("Mvvm")] [TestMethod] - [DataRow(null, false)] [DataRow("", false)] [DataRow("No", false)] [DataRow("This text is really, really too long for the target property", false)] @@ -142,6 +153,60 @@ public void Test_ObservableValidator_ValidateReturn(string value, bool isValid) } } + [TestCategory("Mvvm")] + [TestMethod] + public void Test_ObservableValidator_TrySetProperty() + { + var model = new Person(); + var events = new List(); + + model.ErrorsChanged += (s, e) => events.Add(e); + + // Set a correct value, this should update the property + Assert.IsTrue(model.TrySetName("Hello", out var errors)); + Assert.IsTrue(errors.Count == 0); + Assert.IsTrue(events.Count == 0); + Assert.AreEqual(model.Name, "Hello"); + Assert.IsFalse(model.HasErrors); + + // Invalid value #1, this should be ignored + Assert.IsFalse(model.TrySetName(null, out errors)); + Assert.IsTrue(errors.Count > 0); + Assert.IsTrue(events.Count == 0); + Assert.AreEqual(model.Name, "Hello"); + Assert.IsFalse(model.HasErrors); + + // Invalid value #2, same as above + Assert.IsFalse(model.TrySetName("This string is too long for the target property in this model and should fail", out errors)); + Assert.IsTrue(errors.Count > 0); + Assert.IsTrue(events.Count == 0); + Assert.AreEqual(model.Name, "Hello"); + Assert.IsFalse(model.HasErrors); + + // Correct value, this should update the property + Assert.IsTrue(model.TrySetName("Hello world", out errors)); + Assert.IsTrue(errors.Count == 0); + Assert.IsTrue(events.Count == 0); + Assert.AreEqual(model.Name, "Hello world"); + Assert.IsFalse(model.HasErrors); + + // Actually set an invalid value to show some errors + model.Name = "No"; + + // Errors should now be present + Assert.IsTrue(model.HasErrors); + Assert.IsTrue(events.Count == 1); + Assert.IsTrue(model.GetErrors(nameof(Person.Name)).Cast().Any()); + Assert.IsTrue(model.HasErrors); + + // Trying to set a correct property should clear the errors + Assert.IsTrue(model.TrySetName("This is fine", out errors)); + Assert.IsTrue(errors.Count == 0); + Assert.IsTrue(events.Count == 2); + Assert.IsFalse(model.HasErrors); + Assert.AreEqual(model.Name, "This is fine"); + } + public class Person : ObservableValidator { private string name; @@ -155,6 +220,11 @@ public string Name set => SetProperty(ref this.name, value, true); } + public bool TrySetName(string value, out IReadOnlyCollection errors) + { + return TrySetProperty(ref name, value, out errors, nameof(Name)); + } + private int age; [Range(0, 100)]