Search code examples
c#maui

Switching binding context programmatically sometimes does not trigger CanExecute


Consider the following screenshot to explain my simplified scenario. I have

  • a custom control Numpad with two buttons Up and Down and a bindable property Text. Pressing Up and Down will increase and decrease the value of Text, respectively. The Down button is disabled whenever Text reaches 0.
  • A page consists of 2 radio buttons Quantity and UnitPrice and a Numpad.
  • The page uses a view model ItemViewModel that has two properties Quantity and UnitPrice.
  • Numpad's Text is bound to either Quantity or UnitPrice depending which radio button is being selected.

enter image description here

As you can see, the Down button should be disabled because Numpad's Text is 0. But it is not the case. What is the culprit? I attempted to debug but the process is really confusing with a lot of branching and jumping to internal methods.

Minimal Code

public partial class Numpad : ContentView
{
    private int? _value;

    public static readonly BindableProperty TextProperty =
           BindableProperty.Create(
               propertyName: nameof(Text),
               returnType: typeof(string),
               defaultValue: "0",
               defaultBindingMode: BindingMode.TwoWay,
               declaringType: typeof(Numpad),
               propertyChanged: (bindable, oldValue, newValue) =>
               {

                   var @this = (Numpad)bindable;
                   ((Command)@this.UpCommand).ChangeCanExecute();
                   ((Command)@this.DownCommand).ChangeCanExecute();
                   Trace.WriteLine("property Text has been changed!");
               });

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set => SetValue(TextProperty, value);
    }

    public ICommand UpCommand { get; }
    public ICommand DownCommand { get; }

    public Numpad()
    {
        UpCommand = new Command(
            execute: () =>
            {
                Text = $"{_value + 1}";
            },
            canExecute: () =>
            {
                Trace.WriteLine("UpCommand's CanExecute()");
                if (int.TryParse(Text, out int x))
                {
                    _value = x;
                    return true;
                }

                _value = null;
                return false;
            }
        );

        DownCommand = new Command(
            execute: () =>
            {
                Text = $"{_value - 1}";
            },
            canExecute: () =>
            {
                Trace.WriteLine("DownCommand's CanExecute()");
                if (int.TryParse(Text, out int x))
                {
                    _value = x;
                    return _value > 0;
                }

                _value = null;
                return false;
            }
        );
}
<ContentView
    ...
    xmlns:loc="clr-namespace:MyProject.Views"
    x:Class="MyProject.Views.Numpad"
    x:Name="This">
    <VerticalStackLayout BindingContext="{Reference This}">
        <Button
            Text="Up"
            Command="{Binding UpCommand}" />
        <Button
            Text="Down"
            Command="{Binding DownCommand}" />
    </VerticalStackLayout>
</ContentView>
public class ItemViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private int _quantity;
    private int _unitPrice;
    public int Quantity
    {
        get { return _quantity; }
        set
        {
            _quantity = value;
            OnPropertyChanged();
        }
    }
    public int UnitPrice
    {
        get { return _unitPrice; }
        set
        {
            _unitPrice = value;
            OnPropertyChanged();
        }
    }
}
public partial class ChangerPage : ContentPage
{
    public ChangerPage()
    {
        BindingContext = new ItemViewModel { Quantity = 3, UnitPrice = 5 };
        InitializeComponent();
        RadioButtonQuantity.IsChecked = true;
    }
    private void RadioButton_CheckedChanged(object sender, CheckedChangedEventArgs e)
    {
        if (e.Value)
        {
            var rb = sender as RadioButton;
            if (rb == RadioButtonQuantity)
            {
                cv.SetBinding(Numpad.TextProperty, nameof(ItemViewModel.Quantity));                
            }
            if (rb == RadioButtonUnitPrice)
            {
                cv.SetBinding(Numpad.TextProperty, nameof(ItemViewModel.UnitPrice));
            }
        }
    }
}
<ContentPage
    ...
    xmlns:loc="clr-namespace:MyProject.Views"
    xmlns:vm="clr-namespace:MyProject.ViewModels"
    x:Class="MyProject.ChangerPage"
    x:DataType="vm:ItemViewModel"
    Title="Changer Page">
    <VerticalStackLayout RadioButtonGroup.GroupName="Numpad">
        <RadioButton
            x:Name="RadioButtonQuantity"
            Content="{Binding Quantity,Mode=TwoWay}"
            CheckedChanged="RadioButton_CheckedChanged"
            />
        <RadioButton
            x:Name="RadioButtonUnitPrice"
            Content="{Binding UnitPrice,Mode=TwoWay}"
            CheckedChanged="RadioButton_CheckedChanged" />
        <loc:Numpad
            x:Name="cv"
            Text="{Binding Quantity,Mode=TwoWay}"
            ReturnClicked="OnReturnClicked" />
    </VerticalStackLayout>
</ContentPage>

Solution

  • I can reproduce this issue. The Binding does not work well for some reason after switching BindingContext at runtime.

    I managed to work it out by using the following code in code behind,

        private void RadioButton_CheckedChanged(object sender, CheckedChangedEventArgs e)
        {
            if (e.Value)
            {
                var rb = sender as RadioButton;
    
                if (rb == RadioButtonQuantity)
                {
                    cv.SetBinding(Numpad.TextProperty, nameof(ItemViewModel.Quantity),BindingMode.TwoWay);
                    
                    this.cv.Text = itemViewModel.Quantity.ToString();   
                }
                if (rb == RadioButtonUnitPrice)
                {
                    cv.SetBinding(Numpad.TextProperty, nameof(ItemViewModel.UnitPrice), BindingMode.TwoWay);
                    
                    this.cv.Text = itemViewModel.UnitPrice.ToString();
                }
                
            }
        } 
    

    I set the BindingMode as TwoWay for the Numpad.TextProperty and explicitly set the value to it while switching the BindingContext.

    Hope it helps!