My last couple of posts have been inspired by await-ing things (see Lucian’s list) which allow you to use Tasks and the new C# async features to write synchronous looking expressive code around things that are not based on threads. There was one bit that was bothering me in the last post about drag and drop (but it applies to lots of other scenarios) which was “What about the extra event subscription that is hanging around?”

Lets say you wrap an event – we’ll use a button click in this example. It normally looks like this

 1: public static Task<RoutedEventArgs> WhenClicked(this Button element)
 2: {
 3:     TaskCompletionSource<RoutedEventArgs> tsc = new TaskCompletionSource<RoutedEventArgs>();
 4:     RoutedEventHandler routedEventHandler = null;
 5:     routedEventHandler = (Object s, RoutedEventArgs e) =>
 6:         {
 7:             element.Click -= routedEventHandler;
 8:             tsc.SetResult(e);
 9:         };
 10:  
 11:     element.Click += routedEventHandler;
 12:     return tsc.Task;
 13: }

This is simple enough but it has one potential issue. If the event is never raised, we never unsubscribe to it. (In my previous post there were 3 possible events that could end a drag operation, only one of which would be raised).

Further, it provides no way for us to force the unsubscribe to happen if we know we don’t need to wait on it any further. Take the following contrived example. I want to initiate a process with a button called “Start”. That process consists of waiting for the “Click Me” button to be clicked 5 times.

 

 1: private async void bttnStart_Click(object sender, RoutedEventArgs e)
 2: {
 3:     int clicks = 0;
 4:     await bttnClickMe.WhenClicked();
 5:     clicks++;
 6:     await bttnClickMe.WhenClicked();
 7:     clicks++;
 8:     await bttnClickMe.WhenClicked();
 9:     clicks++;
 10:     await bttnClickMe.WhenClicked();
 11:     clicks++;
 12:     await bttnClickMe.WhenClicked();
 13:     clicks++;
 14:  
 15:     string tmp = string.Format("Operation completed after {0} clicks", clicks);
 16:     MessageDialog dlg = new MessageDialog(tmp);
 17:     await dlg.ShowAsync();
 18: }

 

but what if I wanted to cancel that process after only 2 clicks? I can’t do anything except wait for the click event to happen. What if it never occurs?

In the Task world we pass a CancellationToken into the Task and catch a TaskCanceledException if something gets canceled. So we want to write our code like this.

 

   1: CancellationTokenSource cts;
   2:  
   3: private async void bttnStart_Click(object sender, RoutedEventArgs e)
   4: {
   5:     string message = null;
   6:  
   7:     cts = new CancellationTokenSource();
   8:  
   9:     int clicks = 0;
  10:     try
  11:     {
  12:         await bttnClickMe.WhenClicked(cts.Token);
  13:         clicks++;
  14:         await bttnClickMe.WhenClicked(cts.Token);
  15:         clicks++;
  16:         await bttnClickMe.WhenClicked(cts.Token);
  17:         clicks++;
  18:         await bttnClickMe.WhenClicked(cts.Token);
  19:         clicks++;
  20:         await bttnClickMe.WhenClicked(cts.Token);
  21:         clicks++;
  22:     }
  23:     catch (OperationCanceledException)
  24:     {
  25:         message = string.Format("Operation has been cancelled after {0} clicks", clicks);
  26:     }
  27:  
  28:     if (message == null)
  29:     {
  30:         message = string.Format("Operation completed after {0} clicks", clicks);
  31:     }
  32:     MessageDialog dlg = new MessageDialog(message);
  33:     await dlg.ShowAsync();
  34: }

Then if someone clicks a button to cancel the operation, we just signal the CancellationToken

 1: private void bttnCancel_Click(object sender, RoutedEventArgs e)
 2: {
 3:     if (cts != null)
 4:     {
 5:         cts.Cancel();
 6:     }
 7: }

Now all we need to do is update the WhenClicked() extension method to accept a cancellation token as a parameter. To do this we will make a few modifications to it

  1. move the unsubscribe code to a continuation of the returned task. This will ensure that the unsubscribing of the event happens any time the task completes (normally or cancelled)
  2. register a callback with the cancellation token to cancel the task (ensuring the task hasn’t already been completed first)

 

 1: public static Task<RoutedEventArgs> WhenClicked(this Button element, CancellationToken token)
 2: {
 3:     TaskCompletionSource<RoutedEventArgs> tsc = new TaskCompletionSource<RoutedEventArgs>();
 4:     RoutedEventHandler routedEventHandler = null;
 5:     var task = tsc.Task;
 6:  
 7:     // set the event handler to complete the task
 8:     routedEventHandler = (Object s, RoutedEventArgs e) => tsc.SetResult(e);
 9:  
 10:     // hook up to the event
 11:     element.Click += routedEventHandler;
 12:  
 13:     // always unhook the event handler after the task is finished
 14:     task.ContinueWith(
 15:         t => element.Click -= routedEventHandler,
 16:         TaskContinuationOptions.ExecuteSynchronously
 17:         );
 18:  
 19:     // if cancellation token is signaled, cancel the task if its not too late
 20:     token.Register(() => { if (!tsc.Task.IsCompleted) tsc.SetCanceled(); });
 21:  
 22:     return task;
 23: }

Now I have a pattern I can follow that allows me to await on my events but also allows me to cancel the awaiting, if necessary.

I’ve attached a sample XAML page from a Windows 8 application which has the complete solution.

Try this:

Click “Start”
Click “Click Me”
Click “Click Me”
Click “Click Me”
Click “Click Me”
Click “Click Me”
<—You get the “You clicked 5 times” dialog –>

Click “Start”
Click “Click Me”
Click “Click Me”
Click “Click Me”
Click “Cancel”
<—You get the “You cancelled after 3 clicks” dialog –>
Click “Click Me”
Click “Click Me”
<—You do not get the “You clicked 5 times” dialog –> 

 

UPDATE: Further refinement of the WhenClicked() method in the next post