Disable WPF buttons during longer running process,

2020-02-27 08:10发布

问题:

I have a WPF/MVVM app, which consists of one window with a few buttons.
Each of the buttons triggers a call to an external device (an USB missile launcher), which takes a few seconds.

While the device is running, the GUI is frozen.
(This is okay, because the only purpose of the app is to call the USB device, and you can't do anything else anyway while the device is moving!)

The only thing that's a bit ugly is that the frozen GUI still accepts additional clicks while the device is moving.
When the device still moves and I click on the same button a second time, the device immediately starts moving again as soon as the first "run" is finished.

So I'd like to disable all the buttons in the GUI as soon as one button is clicked, and enable them again when the button's command has finished running.

I have found a solution for this that looks MVVM-conform.
(at least to me...note that I'm still a WPF/MVVM beginner!)

The problem is that this solution doesn't work (as in: the buttons are not disabled) when I call the external library that communicates with the USB device.
But the actual code to disable the GUI is correct, because it does work when I replace the external library call by MessageBox.Show().

I've constructed a minimal working example that reproduces the problem (complete demo project here):

This is the view:

<Window x:Class="WpfDatabindingQuestion.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <StackPanel>
            <Button Content="MessageBox" Command="{Binding MessageCommand}" Height="50"></Button>
            <Button Content="Simulate external device" Command="{Binding DeviceCommand}" Height="50" Margin="0 10"></Button>
        </StackPanel>
    </Grid>
</Window>

...and this is the ViewModel (using the RelayCommand from Josh Smith's MSDN article):

using System.Threading;
using System.Windows;
using System.Windows.Input;

namespace WpfDatabindingQuestion
{
    public class MainWindowViewModel
    {
        private bool disableGui;

        public ICommand MessageCommand
        {
            get
            {
                return new RelayCommand(this.ShowMessage, this.IsGuiEnabled);
            }
        }

        public ICommand DeviceCommand
        {
            get
            {
                return new RelayCommand(this.CallExternalDevice, this.IsGuiEnabled);
            }
        }

        // here, the buttons are disabled while the MessageBox is open
        private void ShowMessage(object obj)
        {
            this.disableGui = true;
            MessageBox.Show("test");
            this.disableGui = false;
        }

        // here, the buttons are NOT disabled while the app pauses
        private void CallExternalDevice(object obj)
        {
            this.disableGui = true;
            // simulate call to external device (USB missile launcher),
            // which takes a few seconds and pauses the app
            Thread.Sleep(3000);
            this.disableGui = false;
        }

        private bool IsGuiEnabled(object obj)
        {
            return !this.disableGui;
        }
    }
}

I'm suspecting that opening a MessageBox triggers some stuff in the background that does not happen when I just call an external library.
But I'm not able to find a solution.

I have also tried:

  • implementing INotifyPropertyChanged (and making this.disableGui a property, and calling OnPropertyChanged when changing it)
  • calling CommandManager.InvalidateRequerySuggested() all over the place
    (I found that in several answers to similar problems here on SO)

Any suggestions?

回答1:

Try this:

//Declare a new BackgroundWorker
BackgroundWorker worker = new BackgroundWorker();
worker.DoWork += (o, ea) =>
{
    try
    {
        // Call your device

        // If ou need to interact with the main thread
       Application.Current.Dispatcher.Invoke(new Action(() => //your action));
    }
    catch (Exception exp)
    {
    }
};

//This event is raise on DoWork complete
worker.RunWorkerCompleted += (o, ea) =>
{
    //Work to do after the long process
    disableGui = false;
};

disableGui = true;
//Launch you worker
worker.RunWorkerAsync();


回答2:

Ok the CanExecute method will not work because the click will immediately put you into your long-running task.
So here's how I would do it:

  1. Make your view model implement INotifyPropertyChanged

  2. Add a property called something like:

    public bool IsBusy
    {
        get
        {
            return this.isBusy;
        }
        set
        { 
            this.isBusy = value;
            RaisePropertyChanged("IsBusy");
        }
    }
    
  3. Bind your buttons to this property in this manner:

    <Button IsEnabled="{Binding IsBusy}" .. />
    
  4. In your ShowMessage/CallExternal device methods add the line

    IsBusy = true;
    

Should do the trick



回答3:

I think this is a bit more elegant:

XAML:

<Button IsEnabled="{Binding IsGuiEnabled}" Content="Simulate external device" Command="{Binding DeviceCommand}" Height="50" Margin="0 10"></Button>

C# (using async & await):

public class MainWindowViewModel : INotifyPropertyChanged
{
    private bool isGuiEnabled;

    /// <summary>
    /// True to enable buttons, false to disable buttons.
    /// </summary>
    public bool IsGuiEnabled 
    {
        get
        {
            return isGuiEnabled;
        }
        set
        {
            isGuiEnabled = value;
            OnPropertyChanged("IsGuiEnabled");
        }
    }

    public ICommand DeviceCommand
    {
        get
        {
            return new RelayCommand(this.CallExternalDevice, this.IsGuiEnabled);
        }
    }

    private async void CallExternalDevice(object obj)
    {
        IsGuiEnabled = false;
        try
        {
            await Task.Factory.StartNew(() => Thread.Sleep(3000));
        }
        finally
        {
            IsGuiEnabled = true; 
        }
    }
}


回答4:

Because you run CallExternalDevice() on the main thread, the main thread won't have time to update any UI until that job is done, which is why the buttons remain enabled. You could start your long-running operation in a separate thread, and you should see that the buttons are disabled as expected:

private void CallExternalDevice(object obj)
{
    this.disableGui = true;

    ThreadStart work = () =>
    {
        // simulate call to external device (USB missile launcher),
        // which takes a few seconds and pauses the app
        Thread.Sleep(3000);

        this.disableGui = false;
        Application.Current.Dispatcher.BeginInvoke(new Action(() => CommandManager.InvalidateRequerySuggested()));
    };
    new Thread(work).Start();
}