The discussion revolves around implementing a User Registration View in a generic XAML-based UI Framework using the MVVM pattern. It addresses two bugs in the code related to updating the UI when properties change, presenting classical solutions involving manual property change notifications and a more complex solution using ReactiveX, along with a novel approach using Signals, exploring their benefits and drawbacks. Additionally, it extends into a complex example showcasing the usage of Signals to represent dynamic data dependencies in UI.
Understanding the Problem
Let's suppose we are creating a user registration view in a generic XAML based UI Framework using the MVVM pattern. In practice, we need to create a UserRegistrationView
and a UserRegistrationViewModel
.
UserRegistrationViewModel
public class UserRegistrationViewModel
{
public string Name { get; set; }
public string Surname { get; set; }
public string FullName => $"{Name} {Surname}";
}
UserRegistrationView
<StackPanel HorizontalAlignment="Stretch"
VerticalAlignment="Center">
<TextBox Text="{Binding Name, Mode=TwoWay}" />
<TextBox Text="{Binding Surname, Mode=TwoWay}" />
<TextBlock Text="{Binding FullName}"/>
</StackPanel>
The code above contains two bugs:
- Whenever the user writes to the
Name
or Surname
TexBox
es, the Name
and Surname
properties are updated correctly, however there is nothing that tells the UI that the FullName
property has changed, and so the TextBlock
binded to it will not update, and it will always be empty. - Similarly, if we set the
ViewModel
Name
and Surname
properties by code, the UI will not be updated.
Of course, this problem can be solved in several different ways.
First Solution: Manually Raise the PropertyChanged Event in the Setter
public class UserRegistrationViewModel : ViewModelBase
{
string _name;
public string Name
{
get => _name;
set
{
if(Set(ref _name, value))
RaisePropertyChanged(nameof(FullName));
}
}
string _surname;
public string Surname
{
get => _surname;
set
{
if(Set(ref _surname, value))
RaisePropertyChanged(nameof(FullName));
}
}
public string FullName => $"{Name} {Surname}";
}
This is a classical solution. In the Name
property setter, we set the backing field and we raise the PropertyChanged
event for both the Name
and the FullName
properties.
What's Good
- Solves this specific problem
What's Bad
- There is a lot of added boilerplate code that does not represent our business logic.
Name
and Surname
properties now reference the FullName
even if there is no reason at all. - We can do this approach only if we can modify the setter of the properties, and that could be a problem if we depend on properties of other classes.
- We can't do something similar if we depend on elements of an
ObservableCollection
.
Second Solution: ReactiveX & ReactiveUI
public class UserRegistrationViewModel : ViewModelBase
{
public UserRegistrationViewModel()
{
this.WhenAnyValue(@this => @this.Name, @this => @this.Surname)
.Subscribe(_ => RaisePropertyChanged(nameof(FullName)));
}
string _name;
public string Name
{
get => _name;
set => Set(ref _name, value);
}
string _surname;
public string Surname
{
get => _surname;
set => Set(ref _surname, value);
}
public string FullName => $"{Name} {Surname}";
}
What's Good
- Solves the problem.
Name
and Surname
Properties are no more dependent on FullName
. - We can use all ReactiveX operators on our properties!
- Works also for properties we don't own or nested properties (with some edge cases that should be kept in mind).
- A similar approach can be done also for
ObservableCollection
s.
What's Bad
- There is a small setup that has to be done in the constructor that has to be written manually. If, for example, we'd like to make the
FullName
property depend on a third property, we must remember to add it also on the WhenAnyValue
. - In cases more complex than this, the setup requires some not trivial knowledge of reactive operators, and all their edge cases
Note: The solution above is not the approach suggested by ReactiveUI. The FullName
property should indeed be computed using a Select
operator on the WhenAnyValue
and should be converted to an ObservableForPropertyHelper
. This works fine in simple cases but
could be messy if the property depends on elements contained in an ObservableCollection
. In my opinion, the pure functional approach has its drawbacks in some cases.
Signals
public class UserRegistrationViewModel
{
public UserRegistrationViewModel()
{
Fullname = Signal.Computed(() => $"{Name.Value} {Surname.Value}");
}
public Signal<string?> Name { get; } = new();
public Signal<string?> Surname { get; } = new();
public IReadOnlySignal<string?> Fullname { get; }
}
<StackPanel HorizontalAlignment="Stretch"
VerticalAlignment="Center">
<TextBox Text="{Binding Name.Value, Mode=TwoWay}" />
<TextBox Text="{Binding Surname.Value, Mode=TwoWay}" />
<TextBlock Text="{Binding Fullname.Value}"/>
</StackPanel>
What is a Signal?
- A
Signal<T>
is a wrapper for a T
. It contains a Property Value
that returns the actual value of the T
. When the Value
is set, the PropertyChanged
event is raised. This is the property the UI should be binded to. - A
CollectionSignal<TObservableCollection>
is a Signal which Value
is an ObservableCollection
(or more generally, something that implements the INotifyCollectionChanged
). It listens for changes of both the property Value
, and modifications of the ObservableCollection
- A computed
Signal
is a Readonly Signal
that will return the value computed by a function. It will automatically detect changes of all the signal
s accessed inside the body of the function (works also for CollectionSignal
), and when one of them changes, the function is recomputed and the PropertyChanged
is automatically raised for us. The Value
cannot be set manually. - All signals implement
IObservable<T>
, so we can still apply ReactiveX operators if we need them. - Computed Signals subscribe to other signals weakly, to avoid memory leaks.
A Complex Example
Consider this scenario. We have some cities with some houses, each house has some rooms, each room has some people inside, each person has an age. Nothing here is immutable, so people can go in and out rooms, houses can be built and destroyed, cities can appear and disappear, and people can grow old and all collections can even be null
.
Problem: Represent in the UI the youngest people with their city, house, room and age.
Solution with Signals
public class Person
{
public Signal<int> Age { get; } = new();
}
public class Room
{
public CollectionSignal<ObservableCollection<Person>> People { get; } = new();
}
public class House
{
public CollectionSignal<ObservableCollection<Room>> Roooms { get; } = new();
}
public class City
{
public CollectionSignal<ObservableCollection<House>> Houses { get; } = new();
}
public class YoungestPeopleViewModel
{
public YoungestPeopleViewModel()
{
YoungestPerson = Signal.Computed(() =>
{
var people = from city in Cities.Value.EmptyIfNull()
from house in city.Houses.Value.EmptyIfNull()
from room in house.Roooms.Value.EmptyIfNull()
from person in room.People.Value.EmptyIfNull()
select new PersonCoordinates(person, room, house, city);
var youngestPerson = people.DefaultIfEmpty()
.MinBy(x => x?.Person.Age.Value);
return youngestPerson;
});
}
public IReadOnlySignal<PersonCoordinates?> YoungestPerson { get; set; }
public CollectionSignal<ObservableCollection<City>> Cities { get; } = new();
}
public record PersonCoordinates(Person Person, Room Room, House House, City City);
How Does It Work?
Basically, the getter (not the setter!) of the Signals
property Value
raises a static
event that notifies someone just requested that signal. This is used by the Computed signal before executing the computation function. The computed signals register to that event (filtering out notifications of other threads), and in that way they know, when the function returns, what signals have been just accessed.
At this point, it subscribes to the changes of all those signals in order to know when it should recompute again the value. When any signal changes, it repeats the same reasoning and tracks what signals are accessed before recomputing the next value (etc.).
History
- 27th December, 2023: Initial version