Jim
Jim

Reputation: 21

ObservableProperty in ViewModel Does Not Trigger PropertyChanged for Model Object in WPF

Problem:

I'm working on a WPF application using the MVVM pattern and the CommunityToolkit.Mvvm package to handle ObservableProperty and INotifyPropertyChanged. However, I've run into an issue when binding a complex model object (e.g., Settings) to the ViewModel.
Since I'm still learning WPF and MVVM concepts, I don’t have a deep understanding of how everything works yet, and I might be missing something important here.

Here’s the setup:

  1. I have a Settings model class that inherits from ObservableObject (from the CommunityToolkit) and contains several properties:
public partial class Settings : ObservableObject
{
    [ObservableProperty]
    private string _serverPath;

    [ObservableProperty]
    private string _updatePath;

    // Other properties...
}
  1. In my ViewModel, I initially used the ObservableProperty attribute to declare the Settings object:
public partial class MyViewModel : ObservableObject
{
    [ObservableProperty]
    private Settings _settings; // Loading via another method 

    partial void OnSettingsChanged(Settings value)
    {
       //Not fire when change value of Settings     
    }
}
  1. In my View, I’ve set up two-way bindings to the Settings properties, like this:
<TextBox Text="{Binding Settings.ServerPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

The Issue:

The data binding works in that the changes to the TextBox are reflected in the ServerPath property of the Settings object. However, the PropertyChanged event in the ViewModel is not triggered when properties inside Settings change, so the UI doesn't update correctly in some cases.

I expected the [ObservableProperty] attribute to handle this automatically, but it seems that when I modify a property inside Settings, the ViewModel does not get notified about these internal changes.

Question:

Why doesn’t the ObservableProperty attribute in the ViewModel handle changes inside a complex model object like Settings? Am I missing something in the way ObservableProperty works with nested objects that implement INotifyPropertyChanged? Is there a way to make this work without manually subscribing to PropertyChanged?

Is there a way to make the ObservableProperty attribute automatically trigger PropertyChanged when properties within a complex object like Settings are modified? Or is this a limitation of ObservableProperty in the context of model objects that implement INotifyPropertyChanged?

I would prefer not to manually handle the PropertyChanged event as shown in my workaround if possible. Any insights into why this behavior occurs and how to fix it would be greatly appreciated!

Manual Workaround:

I’ve found a manual workaround by explicitly handling the PropertyChanged event for the Settings object in the ViewModel. Here’s the working solution, but I’m wondering if there’s a way to avoid this:

public partial class MyViewModel : ObservableObject
{
    private Settings _settings;

    public Settings Settings
    {
        get => _settings;
        set
        {
            if (_settings != null)
            {
                // Unsubscribe from the previous Settings object's PropertyChanged event
                _settings.PropertyChanged -= Settings_PropertyChanged;
            }
    
            SetProperty(ref _settings, value);  // Set the new value for _settings
    
            if (_settings != null)
            {
                // Subscribe to the new Settings object's PropertyChanged event
                _settings.PropertyChanged += Settings_PropertyChanged;
            }
        }
    }
    
    private void Settings_PropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        // Notify the UI about the Settings object change
        OnPropertyChanged(nameof(Settings));
    }
    
    public MyViewModel()
    {
        Settings = new Settings();
    }

}

Upvotes: 2

Views: 299

Answers (2)

IV.
IV.

Reputation: 9438

When you say this, it's a true statement:

ViewModel does not get notified about these [changes in Settings]

When you say this, it's a partially true statement:

I expected the [ObservableProperty] attribute to handle this automatically.

True in the sense that if there are bindings like {Binding Server.ServerPath} in the xaml, and you swap to a new instance of Settings, the bindings in the UI will now respond to changes in the new instance. So it is automatic in that respect.


That said, it sounds as though you want internal logic in the view model to respond directly to PropertyChanged of the Settings, as in the example below where we Ping() in response to a new value for ServerPath. Nothing about that is going to be automatic. Your code is showing exactly the right approach, unhooking the old instance's PropertyChanged event and setting a hook to the new instance, calling an internal handler so that the business logic in the VM will respond.

Often, though, the wiring for "complex interactions" can be done in xaml using IValueConverter as is the case for the generated update path and text color in this UI.


I've reread your post about a hundred times now to see why the UI doesn't update correctly in some cases. A slightly modified approach to the VM is shown here, but there doesn't seem to be anything fundamentally wrong with how you're going about this.

MainPageViewModel
partial class MainPageViewModel : ObservableObject
{
    [ObservableProperty]
    private SettingsClass _settings;

    [ObservableProperty]
    Brush _pingServerIndicatorColor = Brushes.Gray;

    [ObservableProperty]
    string _windowTitle = "Main Window";

    public MainPageViewModel()
    {
        Settings = new SettingsClass();
        NewSettingsTestCommand = new RelayCommand(() =>
        {
            Settings = new SettingsClass();
        });
    }

    // Respond to a new instance of Settings e.g. user profile changed.
    protected override void OnPropertyChanged(PropertyChangedEventArgs e)
    {
        base.OnPropertyChanged(e);
        switch (e.PropertyName)
        {
            case nameof(Settings):
                WindowTitle = $"Main Window: Settings = {Settings.Id}";
                Settings.PropertyChanged -= OnSettingsPropertyChanged;
                Settings.PropertyChanged += OnSettingsPropertyChanged;
                break;
        }
    }
    private void OnSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        switch (e.PropertyName)
        {
            case nameof(Settings.ServerPath):
                _ = PingServer();
                break;
        }
    }
    private async Task PingServer()
    {
        var url = 
            Settings
            .ServerPath
            .Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase)
            .Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase);
        if( new[] { ".com", ".net", "org" }.Contains(
            Path.GetExtension(url.Replace("www.", string.Empty))))
        {
            PingServerIndicatorColor = Brushes.Yellow;
            using (Ping ping = new Ping())
            {
                try
                {
                    PingServerIndicatorColor = (
                        await ping.SendPingAsync(url))
                        .Status == IPStatus.Success ?
                            Brushes.LightGreen : Brushes.Red;
                }
                catch
                {  
                    PingServerIndicatorColor = Brushes.Red;
                }
            }
        }
        else PingServerIndicatorColor = Brushes.Salmon;
    }
    public ICommand NewSettingsTestCommand { get; }
}

Settings Class

In this sample, we'll react to changes of ServerPath to trigger UI updates of UpdatePath and text color. What this demonstrates is that the property change notifications of the nested class are definitely available and functional as binding targets in the xaml scheme.

partial class SettingsClass : ObservableObject
{
    static int _id = 1;
    public int Id { get; } = _id++;

    [ObservableProperty]
    private string _serverPath = String.Empty;
}

Converted property values

There is a textbox on the UI that generates an UpdatePath based on changes to ServerPath.

UpdatePath

This IValueConverter sample class derives UpdatePath from changes to ServerPath.

class UpdatePathFromServerPath : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is string serverPath && !string.IsNullOrWhiteSpace(serverPath))
        {
            return $"{serverPath}/Updates/OneClick/package.htm";
        }
        else
        {
            return "Waiting for server path...";
        }
    }
    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => 
        throw new NotImplementedException();
}
Text Color

This IValueConverter sample class derives Forecolor from changes to ServerPath.

class EmptyToColor : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is string @string)
        {
            return string.IsNullOrEmpty(@string) ? Brushes.Green: Brushes.Red;
        }
        else return Colors.Black;
    }
    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
        throw new NotImplementedException();
}

ui update example

Xaml
<Window x:Class="wpf_nested_notify.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:wpf_nested_notify"
        mc:Ignorable="d"
        Title="{Binding WindowTitle}" Width="500" Height="350"
        FontSize="18">
    <Window.DataContext>
        <local:MainPageViewModel/>
    </Window.DataContext>
    <Window.Resources>
        <local:UpdatePathFromServerPath x:Key="UpdatePathFromServerPath"/>
        <local:EmptyToColor x:Key="EmptyToColor"/>
    </Window.Resources>
    <Grid 
        VerticalAlignment="Stretch">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="4*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <StackPanel
            Grid.Column="1"
            Orientation="Vertical"
            VerticalAlignment="Center">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="25" />
                </Grid.ColumnDefinitions>
                <TextBox 
                    Text="{Binding Settings.ServerPath, UpdateSourceTrigger=PropertyChanged}"
                    Margin="0,20"
                    MinHeight="30"
                    VerticalContentAlignment="Center" />
                <Label
                    x:Name="UpdateCurrent"
                    Margin="0,20"
                    Grid.Column="1"
                    Background="{Binding PingServerIndicatorColor}" />
            </Grid>
            <Grid Height="175" Margin="0,0,0,0">
                <Border
                    BorderBrush="Gray" 
                    BorderThickness="1" 
                    CornerRadius="5" 
                    Margin="0,20"
                    Background="White">
                    <StackPanel 
                        Margin="10,10" >
                        <Label 
                            Content="{Binding Settings.ServerPath, UpdateSourceTrigger=PropertyChanged}"
                            Margin="0,10" 
                            Background="Azure"
                            MinHeight="25"/>
                        <TextBox 
                            Text="{Binding Settings.ServerPath, Converter={StaticResource UpdatePathFromServerPath} }" 
                            Margin="0,10" MinHeight="25" 
                            IsReadOnly="True"
                            Background="Azure"
                            FontSize="12"
                            Foreground="{Binding Settings.ServerPath, Converter={StaticResource EmptyToColor}  }"
                            VerticalContentAlignment="Center">
                        </TextBox>
                    </StackPanel>
                </Border>
                <Label
                    Margin="5,2,0,0"
                    Background="White"
                    Padding="5"
                    Content="Loopback"
                    VerticalAlignment="Top"
                    HorizontalAlignment="Left"/>
            </Grid>
            <Button 
                Height="30"
                Padding="5"
                Width="100"
                FontSize="11"
                Content="New Settings"
                Command="{Binding NewSettingsTestCommand}"
                Foreground="White"
                Background="Purple">
                <Button.Template>
                    <ControlTemplate TargetType="Button">
                        <Border 
                            x:Name="border"
                            Background="{TemplateBinding Background}" 
                            BorderBrush="{TemplateBinding BorderBrush}" 
                            BorderThickness="1" 
                            CornerRadius="10">
                        <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="border" Property="Background" Value="DarkMagenta"/>
                            </Trigger>
                            <Trigger Property="IsPressed" Value="True">
                                <Setter TargetName="border" Property="Background" Value="MediumPurple"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Button.Template>
            </Button>
        </StackPanel>
    </Grid>
</Window>

Upvotes: 1

EldHasp
EldHasp

Reputation: 7943

Your explanations are somewhat self-contradictory. Therefore, my answer will be based in part on my guesses.

<TextBox Text="{Binding Settings.serverPath,

Pay attention to the case of the characters in the name. serverPath is a private field. The Code Generator uses it to create the ServerPath property. This is what the binding should be created for.
I think this is your main mistake.

Why is this my guess...? In your implementation with which everything works Manual Workaround, you have explicitly specified a property starting with Upper Litter. But you did not write that you changed the binding path in XAML. And the serverPath path should not work with your implementation either. This contradiction puzzles me a little.

Another unclear point is how you change the value of the Settings property, which ensures the work with the _settings field. The notification works correctly only in the case of working with the property, but not with the field.

    public void SomeLoadMethod()
    {
        // Correct
        Settings = new Settings();

        // incorrect
        _settings = new Settings();
    }

Upvotes: 0

Related Questions