Search code examples
c#xamlwindows-store-appsuwpinotifypropertychanged

Change property from setter and notify


Within a property's setter, sometimes I must change the property's value to something other than the one currently being set. This doesn't work by simply raising the PropertyChanged event, for a Windows Store app anyway. I can change the value, but the UI doesn't update itself based on that change.

<TextBox Text="{Binding Text, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>

The view-model:

class MyViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private string text;
    public string Text
    {
        get { return text; }
        set
        {
            if ( text != value ) {
                text = value == "0" ? "" : value;
                OnPropertyChanged();
            }
        }
    }

    protected void OnPropertyChanged( [CallerMemberName] string propertyName = null )
    {
        PropertyChanged?.Invoke( this, new PropertyChangedEventArgs( propertyName ) );
    }
}

I wrote the following method which fixes this problem fairly well:

protected void OnPropertyChanged_Delayed( [CallerMemberName] string propertyName = null )
{
    Task.Delay( 1 ).ContinueWith(
        t => OnPropertyChanged( propertyName ),
        TaskScheduler.FromCurrentSynchronizationContext()
    );
}

I call this instead of OnPropertyChanged. But I recently discovered an intermittent bug in my Windows Store app caused by the OnPropertyChanged_Delayed call occurring after the user has navigated away from the page. This leads me to seek an alternative solution.

Is there a better way to change a property's value from within its setter and notify the UI of the change? I am currently developing a Windows Store app, so I'd like an answer in that context. But I will eventually port it to UWP. Is this also a problem on UWP? If so, I'd like a solution there as well (which may be the same).


Solution

  • My question was: from within a property's setter, how to change the value being set and have the UI recognize that change?

    The two-way XAML binding assumes that the target value which is set to the source, is also the value that is actually stored by the source. It ignores the PropertyChanged event of the property it just set, and so it doesn't check the source's actual value after updating it.

    TextBox

    The simplest solution is: make the binding one-way, and instead update the source in a TextChanged handler.

    <TextBox Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}"
             TextChanged="TextBox_TextChanged"/>
    

    The event handler:

    void TextBox_TextChanged( object sender, TextChangedEventArgs e )
    {
        var tb = (TextBox)sender;
        var pm = (MyViewModel)DataContext;
        pm.Text = tb.Text; //update one-way binding's source
    }
    

    When the UI's text changes, the event is handled by setting the view-model's text. The view text's one-way binding means that it then receives the value that was actually set. Doing things this way avoids the limitation of XAML bindings mentioned above.

    It's possible to leave the binding as two-way and the above solution still works. But if you want to let the two-way binding update its source, then the code is slightly longer:

    void TextBox_TextChanged( object sender, TextChangedEventArgs e )
    {
        var tb = (TextBox)sender;
        var pm = (MyViewModel)DataContext;
        tb.GetBindingExpression( TextBox.TextProperty ).UpdateSource();
        if ( tb.Text != pm.Text )
            tb.Text = pm.Text; //correct two-way binding's target
    }
    

    If you don't need UpdateSourceTrigger=PropertyChanged then another alternative is:

    <TextBox Text="{Binding Text, Mode=TwoWay}" LostFocus="TextBox_LostFocus"/>
    

    with the event handler:

    void TextBox_LostFocus( object sender, RoutedEventArgs e )
    {
        var tb = (TextBox)sender;
        var pm = (MyViewModel)DataContext;
        tb.GetBindingExpression( TextBox.TextProperty ).UpdateSource();
        pm.OnPropertyChanged( "Text" );
    }
    

    ComboBox

    @RogerN's answer was helpful for showing that the problem can be overcome using SelectionChanged, but he doesn't actually show how to change the value within the setter. I will show that here.

    <ComboBox ItemsSource="{Binding Items}"
              SelectedItem="{Binding Text, Mode=TwoWay}"
              SelectionChanged="ComboBox_SelectionChanged"/>
    

    Add this property to the view-model:

    public IList<string> Items
    {
        get { return items; }
    }
    readonly IList<string> items = new ObservableCollection<string> { "first", "second", "third", "forth" };
    

    Within the Text property's setter, change the value like this:

    text = value == "third" ? "forth" : value;
    

    The first approach for TextBox shown above doesn't work for ComboBox, but the second one does.

    void ComboBox_SelectionChanged( object sender, SelectionChangedEventArgs e )
    {
        var cb = (ComboBox)sender;
        var pm = (MyViewModel)DataContext;
        cb.GetBindingExpression( ComboBox.SelectedItemProperty ).UpdateSource();
        if ( (string)cb.SelectedItem != pm.Text )
            cb.SelectedItem = pm.Text; //correct two-way binding's target
    }