C# WPF: Threading, Control Updating, Status Bar and Cancel Operations Example All In One
Bringing them together; this article shows how to do GUI processing and background work in WPF using C# with an eye on progress and the ability to cancel. This article demonstrates all these topics :
I write these articles to enlighten the development community as well as notes for myself as I work in the differing technologies going forward. For this article I saw many websites which would individually piece these topics together, but none of them showed the the whole process. This article covers the whole process.
BackgroundWorker Not Just For Winforms Anymore
The goal of the operation is to do the work, and that work is not done on the GUI thread where a user will notice the slow down, but on a background thread. To accomplish that, one of the best fixtures of .Net is the Background thread which was introduced for Winforms. Even though the BackgroundWorker is a nice drag and droppable component in the Winform arena it does not mean that it can’t moonlight in our WPF sandbox. We simply have to instantiate and initialize it ourselves. Here is the code to do that in our Window class:
public partial class Window1 : Window { BackgroundWorker bcLoad = new BackgroundWorker(); public Window1() { InitializeComponent(); bcLoad.DoWork += new DoWorkEventHandler(bcLoad_DoWork); bcLoad.RunWorkerCompleted += new RunWorkerCompletedEventHandler(bcLoad_RunWorkerCompleted); bcLoad.WorkerReportsProgress = true; bcLoad.ProgressChanged += new ProgressChangedEventHandler(bcLoad_ProgressChanged); bcLoad.WorkerSupportsCancellation = true; }
Explanation
- Line 3: We must declare and subsequently instantiate a background worker which will be used. This object and operations can be reused so this only needs to be done once and to keep temporality we declare it on the window class itself.
- Line 9/10: We are subscribing to the events which will handle our work on the separate thread. The DoWork event is where the heavy lifting of the offloaded operations will occur. The RunWorkerCompleted event is the safe haven where we can pipe the results of DoWork to the GUI controls without having to invoke or dispatch back to the controls because the event code will be done on the GUI’s thread. Note intellisense is our friend one these lines. Type in += and intellisense offers to create the subscribing to the event handler with a Tab. A subsequent Tab key will create the method stub. Give it a try.
- Line 12/13: Here is where we tell the background worker that we are going to do a progress operation back to the GUI for the user experience. Note if we don’t specify WorkReportsProgress we will get this exception:This BackgroundWorker states that it doesn’t report progress. Modify WorkerReportsProgress to state that it does report progress.
- Line 15: Here is where we also inform the BackgroundWorker object that we will be handling cancellation.
Xaml Mammal
With the goal of providing a cancel and progress status bar as such:
Here is the code to add to our Xaml where it lives at the bottom of our screen. Below the code is the status bar and its child elements of a TextBlock, ProgessBar and button for the cancel.
<StatusBar Name="stBarPrimary" Grid.Row="5" Grid.ColumnSpan="4" Background="AntiqueWhite" Margin="0,9.056,0,0"> <StatusBarItem> <TextBlock Name="tbStatus">Status:</TextBlock> </StatusBarItem> <StatusBarItem> <ProgressBar Height="12" Width="400" Margin="20,0,5,1" Name="pBar1" Visibility="Hidden" VerticalAlignment="Bottom" /> </StatusBarItem> <StatusBarItem> <Button Height="24" Width="80" Content="Cancel" Name="btnCancel" Visibility="Hidden" Click="btnCancel_Click" /> </StatusBarItem> </StatusBar>
The only thing to note that initially the Cancel button and progress bar visibility is set to hidden.
Launching the Operation from a Button Click
Here is the code where we launch the DoWork event operation, but more importantly we pass in data which the DoWork can use to perform its operations. The data here is special because it all resides on gui controls. We don’t want to access that data directly from our worker thread otherwise we will get this exception as reported in my previous article (C# WPF: Linq Fails in BackgroundWorker DoWork Event ) :
The calling thread cannot access this object because a different thread owns it.
private void btnAcquire_Click(object sender, RoutedEventArgs e) { btnAcquire.IsEnabled = false; tbStatus.Text = "Status :"; // Reset status if changed. btnCancel.Visibility = pBar1.Visibility = Visibility.Visible; bcLoad.RunWorkerAsync(new Dictionary<string, string>() { { "AccountID", tbAccount.Text }, { "CategoryID", tbCategory.Text }, { "SequenceNumber", tbSequenceNumber.Text } }); }
The first thing we do is darken the button which launched the process so the user doesn’t click it twice. Then we make the cancel button and the progress bar visible to the user as the process is about to begin. Finally we extract data held in Gui controls exposed to the user. We cannot access that data once the thread is running and must get the data to to the DoWork process.
Is It Done Yet? Move the Progress Bar.
Here is our event which handles the moving of the progress bar. This is done on the GUI thread so no need to do checking of the dispatch. We get a numeric percentage which moves the bar along and incremental movements.
void bcLoad_ProgressChanged(object sender, ProgressChangedEventArgs e) { pBar1.Value = e.ProgressPercentage; }
Cancelling the Operation from a Button Click
The cancel is similar because we do the opposite of the initiation code above by hiding the controls and informing the user of the stop action. This cancel is initiated by the cancel button on click event shown in the Xaml above. We do all this on the cancel because a cancel event does not fire the RunWorkerCompleted event.;
private void btnCancel_Click(object sender, RoutedEventArgs e) { bcLoad.CancelAsync(); // Turn off from here the progress bar and cancel button and report that. tbStatus.Text = "Status : Canceled"; btnCancel.Visibility = pBar1.Visibility = Visibility.Hidden; btnAcquire.IsEnabled = true; }
Time to DoWork
Ok, here is what we have been waiting to do, the actual work which will occur in the background on the separate thread. We add to the code for the DoWork Event.
void bcLoad_DoWork(object sender, DoWorkEventArgs e) { try { List<string> Results = new List<string>(); Dictionary<string, string> UserInputs = e.Argument as Dictionary<string, string>; if (UserInputs != null) { bcLoad.ReportProgress(33); DatabaseContext dbc = new DatabaseContext(); var data = dbc.SystemData .Where(ac => ac.Account_ID == UserInputs["AccountID"]) .Where(ac => ac.TimeStamp == 0) .Where(ac => ac.Category_id == UserInputs["CategoryID"]) .Where(ac => (int)ac.Category_seq_nbr == int.Parse(UserInputs["SequenceNumber"])) .Select(ac => acc.UnitCode); Results.Add("Unit Code: " + data.First()); if (bcLoad.CancellationPending) { e.Cancel = true; return; } bcLoad.ReportProgress(60); Results.Add("Income Processing Types: " + string.Join(" ", Financial.GetIncomeProcessingTypes().ToArray())); if (bcLoad.CancellationPending) { e.Cancel = true; return; } bcLoad.ReportProgress(80); Results.Add("Stock Movement: " + string.Join(" ", Financial.GetStockMovementTypes().ToArray())); bcLoad.ReportProgress(100); e.Result = Results; // Pass the results to the completed events to process them accordingly. } } catch (Exception ex) { lbxData.Dispatcher.BeginInvoke(new Action(() => lbxData.Items.Add("Exception Caught: " + ex.Message))); } }
Explanation
- Line 5: We will return Results to the RunWorkerCompleted event and this line initializes it.
- Line 7: This method was passed data. Here is where we unbox our present which is a Dictionary which contains our user input we are interested in processing.
- Line 11: Here is where we pass our percentage. It will move the bar up 33 percent. You can dictate what percentage of the process is done. I have chosen 33 in this case, because the code does three separate work items and it will allow for a smother transitions.
- Line 16 : Here is where we access the passed in dictionary which holds our user inputs.
- Line 22: The first work unit will be executed on this line and the database call will be made. After this line we can bump up the progress bar.
- Line 24: Our first important check to see if the user wants to cancel. If a cancel event has been fired, we simply exit this method. Cancellation means that the follow up function RunWorkerCompleted will not be fired.
- Line 27: If we are here, bump up the progress bar.
- Line 28-37: Repeat of the work, check cancel and move progress bar as shown above. Though once done, we bump the result to 100%! We are done with the work.
- Line 39: Now we place the results on the DoWorkEvents Result property to be used in the RunWorkerCompleted.
- Line 44: The exception actually reports the error to in this case a List box named lbxData. This is accomplished by using the controls Dispatcher invoke code. That invoke ensures that the action against the control is done safely on the GUI thread. We could have used this in our code instead of putting things onto a result. But I have chosen not to because if the user cancels, we don’t have half loaded data to unload from controls. By passing the result to the RunWorkerCompleted event we ensure that all data is properly populated and no complex back out of data during error situations is needed. But since this is a exceptional and unplanned situation, I have chosen to write to the control directly. You have the tools to do either, its your design. :-)
Time to Display the Result to the User
Finally our work is done and all is complete. The below code safely writes the result to the GUI thread’s controls.
void bcLoad_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { if ((e.Result != null) && (e.Cancel == false)) { List<string> results = e.Result as List<string>; if (results != null) foreach (string item in results) lbxData.Items.Add(item); } else // User has canceled or there is no data. { lbxData.Items.Clear(); } btnAcquire.IsEnabled = true; btnCancel.Visibility = pBar1.Visibility = Visibility.Hidden; }
Explanation
- Line 3: We are expecting results, do a check for good measure.
- Line 5: Unbox our present from the DoWork event.
- Line 7: Load our ListBox with our found data!
- Line 10: Turn on the button which started it all.
- Line 12-13: Hide the progress bar and Cancel button.
This is now done and I hope it helped.
Miscellaneous
I am including the Xaml which I used which shows the user input and input button for those who may be curious about the code.
<Window x:Class="CSS_Research.Window1" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Window1" Height="360" Width="582"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="34*" /> <RowDefinition Height="34*" /> <RowDefinition Height="34*" /> <RowDefinition Height="34*" /> <RowDefinition Height="134*" /> <RowDefinition Height="37*" /> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="170*" /> <ColumnDefinition Width="216*" /> <ColumnDefinition Width="16*" /> <ColumnDefinition Width="155*" /> </Grid.ColumnDefinitions> <Label Name="lbAccount" Content="Account" HorizontalAlignment="Center" VerticalAlignment="Center" /> <TextBox Name="tbAccount" Grid.Column="1" VerticalContentAlignment="Center" VerticalAlignment="Center" Height="Auto" Margin="0">392702150</TextBox> <Label Margin="0" Name="label1" Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center">Category</Label> <TextBox Margin="0" Name="tbCategory" VerticalContentAlignment="Center" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Stretch" VerticalAlignment="Center">ret</TextBox> <TextBox Grid.ColumnSpan="1" Margin="0" Name="tbSequenceNumber" VerticalContentAlignment="Center" Grid.Column="1" Grid.Row="2" HorizontalAlignment="Stretch" VerticalAlignment="Center" Grid.RowSpan="1">1</TextBox> <Label Grid.RowSpan="1" Margin="0" Name="label2" Grid.Row="2" HorizontalAlignment="Center" VerticalAlignment="Center">Category Sequence Number</Label> <Button Grid.Column="3" Margin="54.252,6.696,37,6.696" Name="btnAcquire" Click="btnAcquire_Click">Acquire</Button> <TabControl Name="tabControl1" Grid.ColumnSpan="4" Grid.Row="4" Margin="12,0.367,0,32.597" Grid.RowSpan="2"> <TabItem Header="General" Name="tabItem1"> <Grid> <ListBox Name="lbxData" Grid.IsSharedSizeScope="True" /> </Grid> </TabItem> <TabItem Header="Data" Name="tbData"> <Grid> </Grid> </TabItem> </TabControl> <StatusBar Name="stBarPrimary" Grid.Row="5" Grid.ColumnSpan="4" Background="AntiqueWhite" Margin="0,9.056,0,0"> <StatusBarItem> <TextBlock Name="tbStatus">Status:</TextBlock> </StatusBarItem> <StatusBarItem> <ProgressBar Height="12" Width="400" Margin="20,0,5,1" Name="pBar1" Visibility="Hidden" VerticalAlignment="Bottom" /> </StatusBarItem> <StatusBarItem> <Button Height="24" Width="80" Content="Cancel" Name="btnCancel" Visibility="Hidden" Click="btnCancel_Click" /> </StatusBarItem> </StatusBar> </Grid> </Window>