다음을 통해 공유


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.

Here is our visual.

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

Screenshot in progress.

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

Screenshot

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

Screenshot

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

Source code

https://github.com/karenpayneoregon/CallbacksVisualBasic