VB.NET: Windows Forms Custom delegates and events
Introduction
Developers tend to visualize a project as a series of procedures and functions that execute in a given sequence, in reality these programs are event driven meaning the flow of execution is determined by occurrences called events.
An event is triggered by something that was expected to occur such as a user clicking a button which raises a Click event which has code to perform one or more actions or calls a method to perform one or more actions. Think of events similar to the radio or TV broadcaster, an event would be a signal to tell you that a show is starting or a signal to tell your DVR that it is time to start recording. Your DVR would listen for a “show is about to begin” event and choose whether or not to do anything in response to it. Put this together with a delegate and an event notifies subscribers that something happened by calling on the delegate to tell them about it.
Throughout this article an exploration will be to show how to create custom delegates and events for use in common operations ranging from monitoring reading files to form communications. Using this information will allow a developer to take code which is constrained to a Windows form to reside in a class which communicates back to a form or to allow a form to communicate back to a class.
Standard events
These are events which are built into controls where a common event is the Click event for a button. The following is a Click event generated by placing a button on a form followed by double clicking the button or by selecting the button, selecting the property windows, selecting the event tab then double clicking the Click event.
Private Sub ExitApplicationButton_Click(sender As Object, e As EventArgs) _
Handles ExitApplicationButton.Click
Close()
End Sub
Breaking down arguments in the Click event.
The sender and e arguments are the standard signature of event handlers. Sender is the object that raised the event and e contains the data of the event. All events in .NET contain such arguments. While in an event like the Click event place a breakpoint on the Close() method and when hit hover over sender and this will reveal sender is of type Button. In this case e does nothing for us generally speaking. In the case of a form's closing event shown below, FormClosingEventArgs provide the reason for the form closing. Besides determining the reason for form closing there is CancelEventArgs.Cancel property which gets or sets a value indicating whether the event should be canceled although depending on the reason setting e.Cancel = False but not be respected if the system is in a unstable state.
Private Sub Form1_FormClosing(sender As Object, e As FormClosingEventArgs) Handles Me.FormClosing
If e.CloseReason = CloseReason.WindowsShutDown Then
' perform actions such as save data or window state,
End If
End Sub
There may be a requirement for a class to allow cancelation, in this case the pattern shown below allow this. Later on there will be examples which will use a similar pattern of delegate and eventargs in real world examples so don't get hung up on this code, come back after finished reading through the article.
Public Event Announcing As EventHandler(Of AnnounceNavigateEventArgs)
Protected Sub OnAnnounce()
Dim e As New AnnounceNavigateEventArgs
RaiseEvent Announcing(Me, e)
If Not e.Cancel Then
' Announce
End If
End Sub
Public Class AnnounceNavigateEventArgs
Inherits ComponentModel.CancelEventArgs
End Class
Important As code presented herein becomes more complicated consider taking time to first run code samples provided in the GitHub repository which were selected to be in line with what a developer may use as alternatives to non object oriented approach. Visual Studio debugger may be used to step through code also to get a better understanding. |
Custom events
Custom events are very useful with common operations but only when a developer understands delegates and events, otherwise developers gravitate to actions that are half-baked, violate OOP concepts.
Example 1 (form communications)
Simple example, the task is to open a new window with a countdown timer where the calling (main) form can start and stop the countdown. The common path is to access the timer component and invoke the Start and Stop method e.g. childForm.Timer1.Start. This is possible since the modifier property of the Timer is Friend. Implementing an event using a delegate the calling form can invoke an event to start/stop the timer without referencing the Timer in the main form.
Delegate and event in main form (Form1)
Public Class Form1
' Our delegate (which "points" at any method which takes an object and EventArgs)
' Look familiar? This is the signature of most control events on a form
Public Delegate Sub SendMessage(obj As Boolean, e As EventArgs)
' Here is the event we trigger to send messages out to listeners
' in this case a Boolean which is used to start/stop a Timer.
Public Event OnSendMessage As SendMessage
In the child form create an event of type SendMessage defined in the main form. This has one line to start/stop the Timer based on the Checked state of a CheckBox in the main (Form1) form.
Public Class Form2
Public Sub MessageReceived(sender As Boolean, e As EventArgs)
Timer1.Enabled = sender
End Sub
In the main form there is a button with the following click event. Which subscribes to MessageReceived in the child form.
Private Sub ShowChildFormButton_Click(sender As Object, e As EventArgs) _
Handles ShowChildFormButton.Click
Dim child As New Form2()
AddHandler OnSendMessage, AddressOf child.MessageReceived
child.Show()
child.Location = New Point(Left + (Width + 10), Top + 5)
End Sub
Note That the first argument to SendMessage delegate is not a Object as in common events found in events like a Button Click event, the developer can make this what is needed. |
Screenshots are always helpful to visualize, the following shows the above in action.
Example 2 (forms communication)
In the first example the delegate and event were placed in the main form, a better idea is to place delegates in a code module, public if the module is in a different project such as a class project.
In the example the task is to open a child form and perform mocked data entry on two properties of a class, pressing ENTER will raise an event passing an instance of a concrete class (Person) to the calling form were the calling form will Validate the two properties are not empty and that this instance of a person is not present in a DataGridView with a BindingSource as the DataSource.
The following are part of this example
- Delegates are declared in a code module.
- Custom EventArgs are used and stored in a class.
- An anonymous method is used as a predicate to a Lambda statement to keep an line of assertion easy to read
- Child form overrides ProcessCmdKey to assist with moving from TextBox to TextBox.
The following delegate in a code module is used to pass information from child form to calling form.
Namespace Modules
Public Module DelegatesModule
Public Delegate Sub FormMessageHandler(
sender As Object, args As FormMessageArgs)
End Module
End Namespace
The above delegate FormMessageHandler has a custom event args.
Namespace Classes
Public Class FormMessageArgs
Inherits EventArgs
Protected _person As Person
''' <summary>
''' Expects a valid Person object with
''' First and Last name populated
''' </summary>
''' <param name="person"></param>
Public Sub New(person As Person)
_person = person
End Sub
''' <summary>
''' Returns a Person object
''' </summary>
''' <returns></returns>
Public ReadOnly Property Person() As Person
Get
Return _person
End Get
End Property
''' <summary>
''' Determines if the person object is populated
''' </summary>
''' <returns></returns>
Public ReadOnly Property IsValidPerson() As Boolean
Get
Return _
Not String.IsNullOrWhiteSpace(_person.FirstName) AndAlso
Not String.IsNullOrWhiteSpace(_person.LastName)
End Get
End Property
End Class
End NameSpace
Definition of the Person class used in the FormMessageArgs above.
Namespace Classes
Public Class Person
Public Property FirstName() As String
Public Property LastName() As String
Public Overrides Function ToString() As String
Return $"{FirstName} {LastName}"
End Function
End Class
End Namespace
In the child form the following method determines if both TextBox controls have data and if so raises OnMessageInformationChanged event using the custom event arguments of type FormMesageArgs.
Private Sub ConditionallyAddPerson()
Dim hasFirstName = Not String.IsNullOrWhiteSpace(FirstNameTextBox.Text)
Dim hasLastName = Not String.IsNullOrWhiteSpace(LastNameTextBox.Text)
If hasFirstName AndAlso hasLastName Then
'
' Both TextBox controls have data, push to the main form
' which has subscribed to this event.
'
RaiseEvent OnMessageInformationChanged(Me, New FormMessageArgs(
New Person() With
{
.FirstName = FirstNameTextBox.Text,
.LastName = LastNameTextBox.Text
}))
End If
End Sub
The method above is triggered in ProcessCmdKey if the ENTER key was depressed while in the last name TextBox.
Protected Overrides Function ProcessCmdKey(
ByRef msg As Message, keyData As Keys) As Boolean
If msg.WParam.ToInt32() = Keys.Enter AndAlso ActiveControl.Name = "LastNameTextBox" Then
ConditionallyAddPerson()
SendKeys.Send("{Tab}")
Return True
ElseIf msg.WParam.ToInt32() = Keys.Enter AndAlso ActiveControl.Name = "FirstNameTextBox" Then
SendKeys.Send("{Tab}")
Return True
End If
Return MyBase.ProcessCmdKey(msg, keyData)
End Function
In the calling form when creating an instance of the child form the main form subscribes to OnMessageInformationChanged which is triggered in the child form CondiionallyAddPerson invoked in processCmdKey. A standard event is subscribed too also, form closing event of the child form as when the child form is shown the button which shows the child form is disabled so to reenable FormClosed event is used to re-enable the form show button.
Private Sub DisplayChildForm()
If Not My.Application.IsFormOpen("ChildForm") Then
_childForm = New ChildForm With {
.Owner = Me
}
AddHandler _childForm.OnMessageInformationChanged, AddressOf GetNewPerson
AddHandler _childForm.FormClosing, AddressOf ChildFormClosed
_firstTime = True
End If
_childForm.Show()
ShowChildFormButton.Enabled = False
If _firstTime Then
_childForm.Location = New Point(Left + (Width + 10), Top + 5)
_firstTime = False
End If
End Sub
Note When reviewing code note Person FirstName and person LastName properties are checked for empty strings in several places, one is sufficient, each developer will want this as they see fit so this allows the reader to use one of the several assertions. |
Back in the main form, directly after creating the child form a AddHandler was done
AddHandler _childForm.OnMessageInformationChanged, AddressOf GetNewPerson
Where GetNewPerson event receives a person sent from the child form.
Private Sub GetNewPerson(sender As Object, args As FormMessageArgs)
Dim people = CType(_bindingSource.DataSource, List(Of Person))
If people.FirstOrDefault(Function(p) PersonValid(p, args.Person)) Is Nothing Then
_bindingSource.Add(args.Person)
Else
MessageBox.Show($"{args.Person} exists.")
End If
End Sub
Note the predicate in the above FirstOrDefault method which is shown below in the main form which shows how to create a predicate for one of two or both reasons.
- Reusable
- Takes a large assertion out of business code, now the code is easier to read
' Anonymous method
Public PersonValid As ValidPerson =
Function(person1 As Person, person2 As Person)
Return Not String.IsNullOrWhiteSpace(person1.FirstName) AndAlso
Not String.IsNullOrWhiteSpace(person1.LastName) AndAlso
person1.FirstName = person2.FirstName AndAlso
person1.LastName = person2.LastName
End Function
Person1 is an instance of a iteration againsts the List of Person object in a BindingSource component which is the DataSource of a DataGridView while Person2 contains data from the child form.
Here is the entire code for the main form.
Imports FollowParent.Classes
Imports FollowParent.Modules
Public Class Form1
Private ReadOnly _bindingSource As New BindingSource
Private _childForm As ChildForm
Private _firstTime As Boolean = True
' Anonymous method
Public PersonValid As ValidPerson =
Function(person1 As Person, person2 As Person)
Return Not String.IsNullOrWhiteSpace(person1.FirstName) AndAlso
Not String.IsNullOrWhiteSpace(person1.LastName) AndAlso
person1.FirstName = person2.FirstName AndAlso
person1.LastName = person2.LastName
End Function
Private Sub ShowChildFormButton_Click(sender As Object, e As EventArgs) _
Handles ShowChildFormButton.Click
DisplayChildForm()
End Sub
Private Sub Form1_FormClosing(sender As Object, e As FormClosingEventArgs) Handles Me.FormClosing
If _childForm IsNot Nothing Then
_childForm.Dispose()
End If
End Sub
Private Sub DisplayChildForm()
If Not My.Application.IsFormOpen("ChildForm") Then
_childForm = New ChildForm With {
.Owner = Me
}
AddHandler _childForm.OnMessageInformationChanged, AddressOf GetNewPerson
AddHandler _childForm.FormClosing, AddressOf ChildFormClosed
_firstTime = True
End If
_childForm.Show()
ShowChildFormButton.Enabled = False
If _firstTime Then
_childForm.Location = New Point(Left + (Width + 10), Top + 5)
_firstTime = False
End If
End Sub
Private Sub ChildFormClosed(sender As Object, e As FormClosingEventArgs)
ShowChildFormButton.Enabled = True
End Sub
''' <summary>
''' Person passed in from child form
''' </summary>
''' <param name="sender"></param>
''' <param name="args"></param>
''' <remarks>
'''
''' </remarks>
Private Sub GetNewPerson(sender As Object, args As FormMessageArgs)
Dim people = CType(_bindingSource.DataSource, List(Of Person))
If people.FirstOrDefault(Function(p) PersonValid(p, args.Person)) Is Nothing Then
_bindingSource.Add(args.Person)
Else
MessageBox.Show($"{args.Person} exists.")
End If
End Sub
Private Sub Form1_LocationChanged(sender As Object, e As EventArgs) Handles Me.LocationChanged
If Not _FirstTime Then
_ChildForm.Location = New Point(Left + (Width + 10), Top + 5)
End If
End Sub
Private Sub ExitApplicationButton_Click(sender As Object, e As EventArgs) _
Handles ExitApplicationButton.Click
Close()
End Sub
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
DataGridView1.AutoGenerateColumns = False
_bindingSource.DataSource = New List(Of Person)
DataGridView1.DataSource = _bindingSource
End Sub
End Class
Entire code for child form.
Imports FollowParent.Classes
Imports FollowParent.Modules
Public Class ChildForm
Public Event OnMessageInformationChanged As FormMessageHandler
Private Sub cmdClose_Click(sender As Object, e As EventArgs) Handles cmdClose.Click
Close()
End Sub
''' <summary>
''' Event triggered in ProcessCmdKey event when ENTER is pressed
''' in the LastNameTextBox and both TextBox controls have values
''' </summary>
Private Sub ConditionallyAddPerson()
Dim hasFirstName = Not String.IsNullOrWhiteSpace(FirstNameTextBox.Text)
Dim hasLastName = Not String.IsNullOrWhiteSpace(LastNameTextBox.Text)
If hasFirstName AndAlso hasLastName Then
'
' Both TextBox controls have data, push to the main form
' which has subscribed to this event.
'
RaiseEvent OnMessageInformationChanged(Me, New FormMessageArgs(
New Person() With
{
.FirstName = FirstNameTextBox.Text,
.LastName = LastNameTextBox.Text
}))
End If
End Sub
''' <summary>
''' Ensures no beep on enter key pressed
''' Not practical for a typical data entry form.
''' </summary>
''' <param name="msg"></param>
''' <param name="keyData"></param>
''' <returns></returns>
Protected Overrides Function ProcessCmdKey(ByRef msg As Message, keyData As Keys) As Boolean
If msg.WParam.ToInt32() = Keys.Enter AndAlso ActiveControl.Name = "LastNameTextBox" Then
ConditionallyAddPerson()
SendKeys.Send("{Tab}")
Return True
ElseIf msg.WParam.ToInt32() = Keys.Enter AndAlso ActiveControl.Name = "FirstNameTextBox" Then
SendKeys.Send("{Tab}")
Return True
End If
Return MyBase.ProcessCmdKey(msg, keyData)
End Function
End Class
Note There is a good deal to consume in the above code sample, it's highly recommended to take time to study the code rather than simply attempting to implement in an existing project. |
Example 3 (read delimited file part 1)
A common operation is reading delimited files by tab, comma or positional into another data source which is usually a database table direct or into a relational database. It's also common for developers to write code within a form either because they don't know how to write code outside a form or they are the type to just code because they don't know better or lazy.
Consider a delimited text file with eight comma delimited columns where in this case the data is perfect in that all fields are of type string so there is no problem with need to convert to other types and once converted determine if they are within specifications.
The file name is hard coded, in the wild a user might pick the file with a FileOpenDialog.
Imports System.IO
Public Class Form1
Private FileName As String = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "Data.txt")
Private Sub ReadFileButton_Click(sender As Object, e As EventArgs) _
Handles ReadFileButton.Click
ReadFileAndPopulateDataGridView()
End Sub
Private Async Sub ReadFileAndPopulateDataGridView()
Dim lineIndex = 1
Dim currentLine As String
Using reader As StreamReader = File.OpenText(FileName)
While Not reader.EndOfStream
Await Task.Delay(10)
currentLine = Await reader.ReadLineAsync()
StatusLabel.Text = $"Reading line {lineIndex}"
Dim parts = currentLine.Split(","c)
Dim person = New Person With
{
.FirstName = parts(0),
.MiddleName = parts(1),
.LastName = parts(2),
.Street = parts(3),
.City = parts(4),
.State = parts(5),
.PostalCode = parts(6),
.EmailAddress = parts(7)
}
DataGridView1.Rows.Add(person.FieldArray())
lineIndex += 1
End While
End Using
End Sub
End Class
Public Class Person
Public Property FirstName As String
Public Property MiddleName As String
Public Property LastName As String
Public Property Street() As String
Public Property City As String
Public Property State As String
Public Property PostalCode As String
Public Property EmailAddress As String
Public Function FieldArray() As String()
Return New String() {FirstName, MiddleName, LastName, Street, City, State, PostalCode, EmailAddress}
End Function
End Class
Problems
- If the file contains thousands of rows the form will not respond until processing has completed.
- The Await Task.Delay(10) is for this sample in that there are only several hundred records so this permits seeing records being added. Without this the form becomes unresponsive.
- There are no provisions for say reporting blank rows (we will cover this in the next example).
- If the file was not perfect there are no way to report issues back to the form.
- The Person class can only be used in this form.
Example 3 (read delimited file part 2)
In this version the form is basically the same with two labels for reporting progress which is done through events.
Here is the project layout which is more involved that the latter code sample.
Changed mindset Many developers want to code with the the fewest lines of code possible and when seeing a solution such as this one will indicate it's over their head. A true developer will pick the proper path, in some cases less code is better while in other cases more is better. |
Running through code logic
There are two delegates, one for reporting progress by the current line number plus the Person instance read from a line. The second delegate will be used to report empty lines and count them.
Imports ReadingDelimitedFile2.Classes
Namespace Modules
Public Module DelegatesModule
Public Delegate Sub FileHandler(sender As Object, args As LineArgs)
Public Delegate Sub EmptyLineHandler(sender As Object, args As EmptyLineArgs)
End Module
End Namespace
A class is used to read the text file where events are setup to match the delegates above.
Imports System.IO
Imports ReadingDelimitedFile2.Modules
Namespace Classes
Public Class FileOperation
''' <summary>
''' Event for reporting progress and passing a just read Person
''' to the calling form for adding to a DataGridView
''' </summary>
Public Event OnLineRead As FileHandler
''' <summary>
''' Event for monitoring empty lines
''' </summary>
Public Event OnEmptyLineRead As EmptyLineHandler
The following property sets/gets a Boolean to continue or cancel processing lines.
Public Property CancelRead() As Boolean
The following method is asynchronous by using ReadLineAsync while the last version for reading lines was synchronous which used ReadLine. Asynchronous operations should be used when needed and not when it looks cool as there are more considerations from interacting with form controls when done wrong to proper cancellation of an operation.
Note the two RaiseEvent, the first passes the current line index and the person data read back to the form which has subscribed to the OnLineRead event and the other for reporting back empty line indexes back in the form here.
Public Async Function ReadFileAndPopulateDataGridView() As Task
Dim currentLineIndex = 1
Dim currentLine As String
Using reader As StreamReader = File.OpenText(FileName)
While Not reader.EndOfStream
Await Task.Delay(2)
currentLine = Await reader.ReadLineAsync()
' avoid empty lines
If Not String.IsNullOrWhiteSpace(currentLine) Then
Dim currentLineParts = currentLine.Split(","c)
' only add if there are 8 elements in the array
If currentLineParts.Length = 8 Then
Dim person = New Person With
{
.FirstName = currentLineParts(0),
.MiddleName = currentLineParts(1),
.LastName = currentLineParts(2),
.Street = currentLineParts(3),
.City = currentLineParts(4),
.State = currentLineParts(5),
.PostalCode = currentLineParts(6),
.EmailAddress = currentLineParts(7)
}
RaiseEvent OnLineRead(Me, New LineArgs(currentLineIndex, person))
End If
Else
RaiseEvent OnEmptyLineRead(Me, New EmptyLineArgs(currentLineIndex))
End If
' increment line number used in callback
currentLineIndex += 1
' watch for user cancellation
If CancelRead Then
Exit Function
End If
End While
End Using
End Function
Form code
In the form there is zero code that interacts with the text file, all work is done by clicking a button then listening for information returning in subscribed events. Special note to the button which uses the key word Async as the method invoked from FileOperations is asynchronous. Also note there is no need for InvokeRequired as the two events are not in another thread which would be needed if done from the Button Click event which is another benefit of using events which is similar to using Iterator with Yield statement.
Imports System.IO
Imports ReadingDelimitedFile2.Classes
Public Class Form1
''' <summary>
''' Exiting file to read
''' </summary>
Private ReadOnly _fileName As String = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "Data.txt")
Private _operations As New FileOperation
''' <summary>
''' Used to display count of empty rows while reading a file
''' </summary>
Private _emptyLineCount As Integer = 0
''' <summary>
''' Used to store line indices for empty lines when reading a file
''' </summary>
Private _emptyLineList As New List(Of Integer)
''' <summary>
''' Provides functionality to read a file asynchronously with
''' callbacks for populating a DataGridView, report progress
''' by line number and also using a callback to report empty lines
''' </summary>
''' <param name="sender"></param>
''' <param name="e"></param>
Private Async Sub ReadFileButton_Click(sender As Object, e As EventArgs) _
Handles ReadFileButton.Click
_emptyLineCount = 0
_emptyLineList = New List(Of Integer)()
_operations = New FileOperation(_fileName)
AddHandler _operations.OnLineRead, AddressOf LineRead
AddHandler _operations.OnEmptyLineRead, AddressOf EmptyLineRead
Await _operations.ReadFileAndPopulateDataGridView()
EmailColumn.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
If _emptyLineList.Count > 0 Then
Dim lineIndices = String.Join(",", _emptyLineList.ToArray())
MessageBox.Show($"The following lines where empty {lineIndices}")
End If
End Sub
''' <summary>
''' Report back an empty line with it's index.
''' </summary>
''' <param name="sender"></param>
''' <param name="args"></param>
Private Sub EmptyLineRead(sender As Object, args As EmptyLineArgs)
_emptyLineCount += 1
EmptyLinesLabel.Text = _emptyLineCount.ToString()
_emptyLineList.Add(args.RowIndex)
End Sub
''' <summary>
''' Update status label
''' Add new row to DataGridView
''' </summary>
''' <param name="sender"></param>
''' <param name="args"></param>
Private Sub LineRead(sender As Object, args As LineArgs)
StatusLabel.Text = $"Read line {args.RowIndex}"
If Not _operations.CancelRead Then
DataGridView1.Rows.Add(args.Person.FieldArray())
End If
End Sub
Private Sub ExitApplicationButton_Click(sender As Object, e As EventArgs) _
Handles ExitApplicationButton.Click
Close()
End Sub
Private Sub Form1_FormClosing(sender As Object, e As FormClosingEventArgs) _
Handles Me.FormClosing
_operations.CancelRead = True
End Sub
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
StatusLabel.Text = ""
EmptyLinesLabel.Text = ""
End Sub
End Class
Progress(Of T) Asynchronous
Progress(Of T) provides IProgress(Of T) Interface a way to invoke callbacks for each reported progress which in the wild allows an operation such as downloading files from the web or reading a delimited file from disk in a form or a class called from a form to report current progress. For example, when reading a file report the current line of total lines in a label rather than a progressbar.
Example 1
Starting off simple by executing a Do While statement 10,000 times, within the Do While construct a callback of type TaskProgressReport, pass the current index, total (10,000) and a string message.
TaskProgressReport class
Namespace Classes
Public Class TaskProgressReport
'current progress
Public Property CurrentProgressAmount() As Integer
'total progress
Public Property TotalProgressAmount() As Integer
'some message to pass to the UI of current progress
Public Property CurrentProgressMessage() As String
End Class
End Namespace
Worker method
Private Async Function WorkerAsync(delay As Integer, progress As IProgress(Of TaskProgressReport)) As Task
Dim totalAmount As Integer = 10000
Dim currentIterator As Integer = 0
Do While currentIterator <= totalAmount
Await Task.Delay(delay)
progress.Report(New TaskProgressReport With
{
.CurrentProgressAmount = currentIterator,
.TotalProgressAmount = totalAmount,
.CurrentProgressMessage = $"Current message: {currentIterator}"
})
currentIterator += delay
Loop
End Function
The worker class is called from a Button Click event. Note AddressOf ReportProgress, this is our callback.
Private Async Sub StartButton_Click(sender As Object, e As EventArgs) Handles StartButton.Click
Dim progress = New Progress(Of TaskProgressReport)(AddressOf ReportProgress)
Await WorkerAsync(100, progress)
End Sub
Callback
Private Sub ReportProgress(progress As TaskProgressReport)
StatusLabel.Text = progress.CurrentProgressMessage
ProgressTextBox.Text = $"{progress.CurrentProgressAmount} of {progress.TotalProgressAmount}"
End Sub
Although the provides feedback for an operation there are two issues, the first is the operation resides in the form rather than in a class and secondly no canceling of the operation if the user decided to cancel. The next example will provide a method to not only cancel but start the process over again while many code samples on the web simply show how to cancel but missing an important element, restart the process, not resume which can be done also yet this is to be considered a foundation for more complex operations.
Example 2
Going with cancellation keeping the operation in the form, the third example will move away from the form to a class. Rather than using a class for the callback this example uses a Integer. The key to cancel option is CancellationTokenSource. A CancellationTokenSource object is setup a form level scope as a private variable. The Token of the CancellationTokenSource is passed to a callback which is the worker of the operation, in this case a do-nothing method.
In the worker method an assertion is done to see if the CancellationTokenSource.IsCancellationRequested is true (meaning a cancel to request has been issued) then invoke ThrowIfCancellationRequested of the CancellationTokenSource object which will throw an exception which means the call to the worker method must be in a Try-Catch statement. In this example the only exception watched is OperationCanceledException but for an application in production there should also be a second catch for general exceptions as the last catch. To cancel the operation Cancel method is invoked on the CancellationTokenSource object.
Important: To reuse the CancellationTokenSource object to restart the object must first be disposed then recreated.
Imports System.Threading
Public Class Form1
Private _cts As New CancellationTokenSource()
Private Async Function AsyncMethod(progress As IProgress(Of Integer), ct As CancellationToken) As Task
For index As Integer = 0 To 20
'Simulate an async call that takes some time to complete
Await Task.Delay(500)
If ct.IsCancellationRequested Then
ct.ThrowIfCancellationRequested()
End If
'Report progress
If progress IsNot Nothing Then
progress.Report(index)
End If
Next
End Function
Private Async Sub StartButton_Click(sender As Object, e As EventArgs) Handles StartButton.Click
'
' Reset if needed, if was ran and cancelled before
'
If _cts.IsCancellationRequested = True Then
_cts.Dispose()
_cts = New CancellationTokenSource()
End If
'Instantiate progress indicator.
'In this example this reports an Integer as progress, but
'we could actually report a complex object providing more
'information such as current operation
Dim progressIndicator = New Progress(Of Integer)(AddressOf ReportProgress)
Try
'Call async method, pass progress indicator and cancellation token as parameters
Await AsyncMethod(progressIndicator, _cts.Token)
Catch ex As OperationCanceledException
lblStatus.Text = "Cancelled"
End Try
End Sub
Private Sub CancelButton_Click(sender As Object, e As EventArgs) Handles CancelButton.Click
_cts.Cancel()
End Sub
Private Sub ReportProgress(value As Integer)
lblStatus.Text = value.ToString()
End Sub
End Class
Example 3
In this example a class is used for reporting, similar to example 1. The same cancellation method is used as in example 2. The main difference this time is that the worker method resides outside a form in a class.
The work simply reads lines from a file and displays the line index and the string contents of each line,
Imports System.IO
Imports System.Threading
Namespace Classes
Public Class Operations
Private ReadOnly _fileName As String = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "Data.txt")
Public Async Function AsyncMethod(progress As IProgress(Of Line), ct As CancellationToken) As Task
Dim currentLine As New Line
Dim lines = File.ReadAllLines(_fileName)
For index As Integer = 0 To lines.Length - 1
Await Task.Delay(300)
currentLine = New Line() With {.Index = index, .Data = lines(index)}
If ct.IsCancellationRequested Then
ct.ThrowIfCancellationRequested()
End If
'Report progress
If progress IsNot Nothing Then
progress.Report(currentLine)
End If
Next
End Function
End Class
End Namespace
Form code
Imports System.Threading
Imports Progress3.Classes
Public Class Form1
Private _cts As New CancellationTokenSource()
Private operations As New Operations
Private Async Sub StartButton_Click(sender As Object, e As EventArgs) Handles StartButton.Click
'
' Reset if needed, if was ran and cancelled before
'
If _cts.IsCancellationRequested = True Then
_cts.Dispose()
_cts = New CancellationTokenSource()
End If
'Instantiate progress indicator, unlike the last two examples
'In this example this reports a class as progress, but
Dim progressIndicator = New Progress(Of Line)(AddressOf ReportProgress)
Try
Await operations.AsyncMethod(progressIndicator, _cts.Token)
Catch ex As OperationCanceledException
lblStatus.Text = "Cancelled"
End Try
End Sub
Private Sub CancelButton_Click(sender As Object, e As EventArgs) Handles CancelButton.Click
_cts.Cancel()
End Sub
Private Sub ReportProgress(value As Line)
lblStatus.Text = value.ToString()
End Sub
End Class
There is no processing of lines as to not lose focus of working from a form to a class.
Summary
Two common tasks have been presented for consideration to understand delegates and events without digging deep into the mechanics of delegates/events which is intentional as many developers learn by example and then will read Microsoft documentation to then get a firm understanding of delegates and events. Reading Microsoft documentation will provide the why’s and also provide more such as memory conservation with delegates and events. The main thing here was to show specifically how to use custom event arguments and to do this with common task such as form communications and reading files. Also presented, how to work with progress asynchronously with options for callbacks from simple type to complex types.
See also
VB.NET: Invoke Method to update UI from secondary threads
Delegates
C# Event handling in an MVVM WPF application