Search code examples
c#wpflinqmvvmbinding

WPF MVVM C# - How do I bind a button's text to a LINQ expression and have it change dynamically?


I would like to bind button text to a LINQ expression, like this:

public string CloseButtonText => Roles.Any(r => r.IsChanged) ? "Save and close" : "Close";

...but it's not working. The text is properly set at startup, but it doesn't change when any of the RoleViewModel objects change.

This is how I've set it up.

public class RoleSelectionViewModel : ViewModelBase
{
    public RoleSelectionViewModel()
    {
        LoadRoles();
    }
    
    private readonly ObservableCollection<RoleViewModel> _roles = [];
    public IList<RoleViewModel> Roles => _roles;

    public string CloseButtonText => Roles.Any(r => r.IsChanged) ? "Save and close" : "Close";

    private void LoadRoles()
    {
        // Prevents crash when trying to read XML roles during design-time.
        if (DesignerProperties.GetIsInDesignMode(new System.Windows.DependencyObject()))
        {
            return;
        }

        var roles = [ .. RolesManagement.ReadRolesXml().OrderBy(r => r.RoleName), ];
        roles.ForEach(r => Roles.Add(new RoleViewModel(r)));
    }
    // rest of the class...
public class RoleViewModel : ViewModelBase
{
    public RoleViewModel() { }

    public RoleViewModel(Role role) => Role = role;

    public Role Role { get; protected set; }

    public int RoleId => Role.RoleId;

    public string RoleName => Role.RoleName;

    public string Name
    {
        get { return Role.IssuedBy; }
        set
        {
            Role.IssuedBy = value;
            OnPropertyChanged();
        }
    }

    public string Department
    {
        get { return Role.Department; }
        set
        {
            Role.Department = value;
            OnPropertyChanged();
        }
    }

    public string Email
    {
        get { return Role.Email; }
        set
        {
            Role.Email = value;
            OnPropertyChanged();
        }
    }
    // etc...
public class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private bool _isChanged = false;
    public bool IsChanged
    {
        get { return _isChanged; }
        set
        {
            _isChanged = value;
            OnPropertyChanged();
        }
    }

    protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        if (propertyName != nameof(IsChanged))
        {
            IsChanged = true;
        }
    }
}
<Button
    x:Name="Close_Button"
    Width="96"
    Height="30"
    Padding="10,0,10,0"
    Background="#FF838383"
    Foreground="White"
    Command="{Binding CloseCommand}"
    >
    <TextBlock
        x:Name="Close_Button_Text"
        Text="{Binding CloseButtonText}"
        />
</Button>

I also tried manually subscribing to the RoleViewModel.PropertyChanged event, but it didn't work either. Weirdly enough Role_PropertyChanged triggers when I click the close button, but not when any of the role properties change.

foreach (var role in Roles)
{
    role.PropertyChanged += Role_PropertyChanged;
}

private void Role_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName == nameof(RoleViewModel.IsChanged))
    {
        this.OnPropertyChanged(nameof(CloseButtonText));
    }
}

EDIT: I tried subscribing to the CollectionChanged event of the ObservableCollection, but no dice. The Role_PropertyChanged method only triggers when I actually click the Close button; I've no idea why.

public string CloseButtonText { get; set; } = "Close";

private void LoadRoles()
{
    _roles.CollectionChanged += Roles_CollectionChanged;
    // rest of the method unchanged
}

private void Roles_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
    if (e.OldItems != null)
    {
        foreach (RoleViewModel item in e.OldItems)
        {
            item.PropertyChanged -= Role_PropertyChanged;
        }
    }
    if (e.NewItems != null)
    {
        foreach (RoleViewModel item in e.NewItems)
        {
            item.PropertyChanged += Role_PropertyChanged;
        }
    }
}

private void Role_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    if (sender is RoleViewModel role 
        && role.IsChanged 
        && e.PropertyName == nameof(RoleViewModel.IsChanged))
    {
        CloseButtonText = "Save and close";
        this.OnPropertyChanged(nameof(CloseButtonText));
    }
}

EDIT 2: So as it turns out, you cannot bind to a LINQ expression like I originally intended. However, after adding UpdateSourceTrigger=PropertyChanged on each TextBox's binding, it worked. The default seems to be to raise the event after the TextBox loses focus. So the complete binding should look like this: Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}". I still had to subscribe to the CollectionChanged event on the ObservableCollection and also subscribe to each RoleViewModel's PropertyChanged event as described in the first edit though.


Solution

  • So as it turns out, you cannot bind to a LINQ expression like I originally intended. I changed the property to this: public string CloseButtonText { get; set; } = "Close".

    The problem I had was that the event was not being raised when I expected it to (when the text box content changed). However, after adding UpdateSourceTrigger=PropertyChanged on each TextBox's binding, it worked. The default seems to be to raise the event after the TextBox loses focus. So the complete binding should look like this: Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}". I still had to subscribe to the CollectionChanged event on the ObservableCollection and also subscribe to each RoleViewModel's PropertyChanged event as described in the first edit though.