Files
dotnet/Microsoft.Toolkit.Mvvm/ComponentModel/ObservableObject.cs
T

399 lines
20 KiB
C#

// 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.
#pragma warning disable SA1512
// This file is inspired from the MvvmLight libray (lbugnion/mvvmlight),
// more info in ThirdPartyNotices.txt in the root of the project.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace Microsoft.Toolkit.Mvvm.ComponentModel
{
/// <summary>
/// A base class for objects of which the properties must be observable.
/// </summary>
public abstract class ObservableObject : INotifyPropertyChanged, INotifyPropertyChanging
{
/// <inheritdoc cref="INotifyPropertyChanged.PropertyChanged"/>
public event PropertyChangedEventHandler? PropertyChanged;
/// <inheritdoc cref="INotifyPropertyChanging.PropertyChanging"/>
public event PropertyChangingEventHandler? PropertyChanging;
/// <summary>
/// Performs the required configuration when a property has changed, and then
/// raises the <see cref="PropertyChanged"/> event to notify listeners of the update.
/// </summary>
/// <param name="propertyName">(optional) The name of the property that changed.</param>
/// <remarks>The base implementation only raises the <see cref="PropertyChanged"/> event.</remarks>
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null!)
{
RaisePropertyChanged(propertyName);
}
/// <summary>
/// Performs the required configuration when a property is changing, and then
/// raises the <see cref="PropertyChanged"/> event to notify listeners of the update.
/// </summary>
/// <param name="propertyName">(optional) The name of the property that changed.</param>
/// <remarks>The base implementation only raises the <see cref="PropertyChanging"/> event.</remarks>
protected virtual void OnPropertyChanging([CallerMemberName] string propertyName = null!)
{
RaisePropertyChanging(propertyName);
}
/// <summary>
/// Raises the <see cref="PropertyChanging"/> event if needed.
/// </summary>
/// <param name="propertyName">The name of the property that changed.</param>
private void RaisePropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
/// <summary>
/// Raises the <see cref="PropertyChanged"/> event if needed.
/// </summary>
/// <param name="propertyName">The name of the property that changed.</param>
private void RaisePropertyChanging(string propertyName)
{
PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName));
}
/// <summary>
/// Compares the current and new values for a given property. If the value has changed,
/// raises the <see cref="PropertyChanging"/> event, updates the property with the new
/// value, then raises the <see cref="PropertyChanged"/> event.
/// </summary>
/// <typeparam name="T">The type of the property that changed.</typeparam>
/// <param name="field">The field storing the property's value.</param>
/// <param name="newValue">The property's value after the change occurred.</param>
/// <param name="propertyName">(optional) The name of the property that changed.</param>
/// <returns><see langword="true"/> if the property was changed, <see langword="false"/> otherwise.</returns>
/// <remarks>
/// The <see cref="PropertyChanging"/> and <see cref="PropertyChanged"/> events are not raised
/// if the current and new value for the target property are the same.
/// </remarks>
protected bool Set<T>(ref T field, T newValue, [CallerMemberName] string propertyName = null!)
{
if (EqualityComparer<T>.Default.Equals(field, newValue))
{
return false;
}
OnPropertyChanging(propertyName);
field = newValue;
OnPropertyChanged(propertyName);
return true;
}
/// <summary>
/// Compares the current and new values for a given property. If the value has changed,
/// raises the <see cref="PropertyChanging"/> event, updates the property with the new
/// value, then raises the <see cref="PropertyChanged"/> event.
/// This overload is much less efficient than <see cref="Set{T}(ref T,T,string)"/> and it
/// should only be used when the former is not viable (eg. when the target property being
/// updated does not directly expose a backing field that can be passed by reference).
/// </summary>
/// <typeparam name="T">The type of the property that changed.</typeparam>
/// <param name="oldValue">The current property value.</param>
/// <param name="newValue">The property's value after the change occurred.</param>
/// <param name="callback">A callback to invoke to update the property value.</param>
/// <param name="propertyName">(optional) The name of the property that changed.</param>
/// <returns><see langword="true"/> if the property was changed, <see langword="false"/> otherwise.</returns>
/// <remarks>
/// The <see cref="PropertyChanging"/> and <see cref="PropertyChanged"/> events are not raised
/// if the current and new value for the target property are the same.
/// </remarks>
protected bool Set<T>(T oldValue, T newValue, Action<T> callback, [CallerMemberName] string propertyName = null!)
{
if (EqualityComparer<T>.Default.Equals(oldValue, newValue))
{
return false;
}
OnPropertyChanging(propertyName);
callback(newValue);
OnPropertyChanged(propertyName);
return true;
}
/// <summary>
/// Compares the current and new values for a given nested property. If the value has changed,
/// raises the <see cref="PropertyChanging"/> event, updates the property and then raises the
/// <see cref="PropertyChanged"/> event. The behavior mirrors that of <see cref="Set{T}(ref T,T,string)"/>,
/// with the difference being that this method is used to relay properties from a wrapped model in the
/// current instance. This type is useful when creating wrapping, bindable objects that operate over
/// models that lack support for notification (eg. for CRUD operations).
/// Suppose we have this model (eg. for a database row in a table):
/// <code>
/// public class Person
/// {
/// public string Name { get; set; }
/// }
/// </code>
/// We can then use a property to wrap instances of this type into our observable model (which supports
/// notifications), injecting the notification to theproperties of that model, like so:
/// <code>
/// public class BindablePerson : ObservableObject
/// {
/// public Model { get; }
///
/// public BindablePerson(Person model)
/// {
/// Model = model;
/// }
///
/// public string Name
/// {
/// get => Model.Name;
/// set => Set(() => Model.Name, value);
/// }
/// }
/// </code>
/// This way we can then use the wrapping object in our application, and all those "proxy" properties will
/// also raise notifications when changed. Note that this method is not meant to be a replacement for
/// <see cref="Set{T}(ref T,T,string)"/>, which offers better performance and less memory usage. Only use this
/// overload when relaying properties to a model that doesn't support notifications, and only if you can't
/// implement notifications to that model directly (eg. by having it inherit from <see cref="ObservableObject"/>).
/// </summary>
/// <typeparam name="T">The type of property to set.</typeparam>
/// <param name="propertyExpression">An <see cref="Expression{TDelegate}"/> returning the property to update.</param>
/// <param name="newValue">The property's value after the change occurred.</param>
/// <param name="propertyName">(optional) The name of the property that changed.</param>
/// <returns><see langword="true"/> if the property was changed, <see langword="false"/> otherwise.</returns>
/// <remarks>
/// The <see cref="PropertyChanging"/> and <see cref="PropertyChanged"/> events are not raised
/// if the current and new value for the target property are the same. Additionally, <paramref name="propertyExpression"/>
/// must return a property from a model that is stored as another property in the current instance.
/// This method only supports one level of indirection: <paramref name="propertyExpression"/> can only
/// be used to access properties of a model that is directly stored as a property of the current instance.
/// Additionally, this method can only be used if the wrapped item is a reference type.
/// </remarks>
protected bool Set<T>(Expression<Func<T>> propertyExpression, T newValue, [CallerMemberName] string propertyName = null!)
{
PropertyInfo? parentPropertyInfo;
FieldInfo? parentFieldInfo = null;
// Get the target property info
if (!(propertyExpression.Body is MemberExpression targetExpression &&
targetExpression.Member is PropertyInfo targetPropertyInfo &&
targetExpression.Expression is MemberExpression parentExpression &&
(!((parentPropertyInfo = parentExpression.Member as PropertyInfo) is null) ||
!((parentFieldInfo = parentExpression.Member as FieldInfo) is null)) &&
parentExpression.Expression is ConstantExpression instanceExpression &&
instanceExpression.Value is object instance))
{
ThrowArgumentExceptionForInvalidPropertyExpression();
// This is never executed, as the method above always throws
return false;
}
object parent = parentPropertyInfo is null
? parentFieldInfo!.GetValue(instance)
: parentPropertyInfo.GetValue(instance);
T oldValue = (T)targetPropertyInfo.GetValue(parent);
if (EqualityComparer<T>.Default.Equals(oldValue, newValue))
{
return false;
}
OnPropertyChanging(propertyName);
targetPropertyInfo.SetValue(parent, newValue);
OnPropertyChanged(propertyName);
return true;
}
/// <summary>
/// Provides the internal backing implementation for <see cref="SetAndNotifyOnCompletion{TTask}(ref TTask,Expression{Func{TTask}},TTask,string)"/>.
/// </summary>
/// <typeparam name="TTask">The type of <see cref="Task"/> to set and monitor.</typeparam>
/// <param name="field">The field storing the property's value.</param>
/// <param name="fieldExpression">An <see cref="Expression{TDelegate}"/> returning the field to update.</param>
/// <param name="newValue">The property's value after the change occurred.</param>
/// <param name="monitorTask">A <see cref="Task"/> that completes when </param>
/// <param name="propertyName">(optional) The name of the property that changed.</param>
/// <returns><see langword="true"/> if the property was changed, <see langword="false"/> otherwise.</returns>
/// <remarks>
/// The <see cref="PropertyChanging"/> and <see cref="PropertyChanged"/> events are not raised
/// if the current and new value for the target property are the same.
/// </remarks>
private protected bool SetAndNotifyOnCompletion<TTask>(ref TTask? field, Expression<Func<TTask?>> fieldExpression, TTask? newValue, out Task monitorTask, [CallerMemberName] string propertyName = null!)
where TTask : Task
{
if (ReferenceEquals(field, newValue))
{
monitorTask = Task.CompletedTask;
return false;
}
// Check the status of the new task before assigning it to the
// target field. This is so that in case the task is either
// null or already completed, we can avoid the overhead of
// scheduling the method to monitor its completion.
bool isAlreadyCompletedOrNull = newValue?.IsCompleted ?? true;
OnPropertyChanging(propertyName);
field = newValue;
OnPropertyChanged(propertyName);
// If the input task is either null or already completed, we don't need to
// execute the additional logic to monitor its completion, so we can just bypass
// the rest of the method and return that the field changed here. The return value
// does not indicate that the task itself has completed, but just that the property
// value itself has changed (ie. the referenced task instance has changed).
// This mirrors the return value of all the other synchronous Set methods as well.
if (isAlreadyCompletedOrNull)
{
monitorTask = Task.CompletedTask;
return true;
}
// Get the target field to set. This is needed because we can't
// capture the ref field in a closure (for the async method).
if (!((fieldExpression.Body as MemberExpression)?.Member is FieldInfo fieldInfo))
{
ThrowArgumentExceptionForInvalidFieldExpression();
// This is never executed, as the method above always throws
monitorTask = Task.CompletedTask;
return false;
}
// We use a local async function here so that the main method can
// remain synchronous and return a value that can be immediately
// used by the caller. This mirrors Set<T>(ref T, T, string).
async Task MonitorTask()
{
try
{
// Await the task and ignore any exceptions
await newValue!;
}
catch
{
}
TTask? currentTask = (TTask?)fieldInfo.GetValue(this);
// Only notify if the property hasn't changed
if (ReferenceEquals(newValue, currentTask))
{
OnPropertyChanged(propertyName);
}
}
monitorTask = MonitorTask();
return true;
}
/// <summary>
/// Compares the current and new values for a given field (which should be the backing
/// field for a property). If the value has changed, raises the <see cref="PropertyChanging"/>
/// event, updates the field and then raises the <see cref="PropertyChanged"/> event.
/// The behavior mirrors that of <see cref="Set{T}(ref T,T,string)"/>, with the difference being that this method
/// will also monitor the new value of the property (a generic <see cref="Task"/>) and will also
/// raise the <see cref="PropertyChanged"/> again for the target property when it completes.
/// This can be used to update bindings observing that <see cref="Task"/> or any of its properties.
/// Here is a sample property declaration using this method:
/// <code>
/// private Task myTask;
///
/// public Task MyTask
/// {
/// get => myTask;
/// private set => SetAndNotifyOnCompletion(ref myTask, () => myTask, value);
/// }
/// </code>
/// </summary>
/// <typeparam name="TTask">The type of <see cref="Task"/> to set and monitor.</typeparam>
/// <param name="field">The field storing the property's value.</param>
/// <param name="fieldExpression">
/// An <see cref="Expression{TDelegate}"/> returning the field to update. This is needed to be
/// able to raise the <see cref="PropertyChanged"/> to notify the completion of the input task.
/// </param>
/// <param name="newValue">The property's value after the change occurred.</param>
/// <param name="propertyName">(optional) The name of the property that changed.</param>
/// <returns><see langword="true"/> if the property was changed, <see langword="false"/> otherwise.</returns>
/// <remarks>
/// The <see cref="PropertyChanging"/> and <see cref="PropertyChanged"/> events are not raised
/// if the current and new value for the target property are the same.
/// </remarks>
protected bool SetAndNotifyOnCompletion<TTask>(ref TTask? field, Expression<Func<TTask?>> fieldExpression, TTask? newValue, [CallerMemberName] string propertyName = null!)
where TTask : Task
{
return SetAndNotifyOnCompletion(ref field, fieldExpression, newValue, out _);
}
/// <summary>
/// Compares the current and new values for a given field (which should be the backing
/// field for a property). If the value has changed, raises the <see cref="PropertyChanging"/>
/// event, updates the field and then raises the <see cref="PropertyChanged"/> event.
/// This method is just like <see cref="SetAndNotifyOnCompletion{TTask}(ref TTask,Expression{Func{TTask}},TTask,string)"/>,
/// with the difference being an extra <see cref="Action{T}"/> parameter with a callback being invoked
/// either immediately, if the new task has already completed or is <see langword="null"/>, or upon completion.
/// </summary>
/// <typeparam name="TTask">The type of <see cref="Task"/> to set and monitor.</typeparam>
/// <param name="field">The field storing the property's value.</param>
/// <param name="fieldExpression">
/// An <see cref="Expression{TDelegate}"/> returning the field to update.</param>
/// <param name="newValue">The property's value after the change occurred.</param>
/// <param name="callback">A callback to invoke to update the property value.</param>
/// <param name="propertyName">(optional) The name of the property that changed.</param>
/// <returns><see langword="true"/> if the property was changed, <see langword="false"/> otherwise.</returns>
/// <remarks>
/// The <see cref="PropertyChanging"/> and <see cref="PropertyChanged"/> events are not raised
/// if the current and new value for the target property are the same.
/// </remarks>
protected bool SetAndNotifyOnCompletion<TTask>(ref TTask? field, Expression<Func<TTask?>> fieldExpression, TTask? newValue, Action<TTask?> callback, [CallerMemberName] string propertyName = null!)
where TTask : Task
{
bool result = SetAndNotifyOnCompletion(ref field, fieldExpression, newValue, out Task task);
task.ContinueWith(t => callback((TTask?)t));
return result;
}
/// <summary>
/// Throws an <see cref="ArgumentException"/> when a given <see cref="Expression{TDelegate}"/> is invalid for a property.
/// </summary>
private static void ThrowArgumentExceptionForInvalidPropertyExpression()
{
throw new ArgumentException("The given expression must be in the form () => MyModel.MyProperty");
}
/// <summary>
/// Throws an <see cref="ArgumentException"/> when a given <see cref="Expression{TDelegate}"/> is invalid for a property field.
/// </summary>
private static void ThrowArgumentExceptionForInvalidFieldExpression()
{
throw new ArgumentException("The given expression must be in the form () => field");
}
}
}