Scheduled Notifications in Windows 10 VB.Net - vb.net

I am working on a project where the user sets a reminder with information, date and time for when a notification should pop up and the notification should be clickable and open another form with the reminder information on it. So far I can set the reminder but it pops up as soon as the remind button in clicked, my notification looks a standard windows 10 notification, I just want the notification to be scheduled for a certain date and time. The information including date and time is saved into an Access Database. I am using VB.Net
Kind Regards
Form:
This is my reminder form as of now
This is how my notification looks like
Imports System.Data.OleDb
Public Class frmReminder
Private CurrentReminderID As Integer = -1
Private Sub frmReminder_Load(sender As Object, e As EventArgs) Handles MyBase.Load
BtnClear.PerformClick()
Timer1.Enabled = True
End Sub
Private Sub BtnClear_Click(sender As Object, e As EventArgs) Handles BtnClear.Click
Label6.Text = ""
TxtCustName.Text = ""
TxtDeviceInfo.Text = ""
TxtPrice.Text = ""
TxtDateDue.ResetText()
End Sub
Private Sub BtnSetReminder_Click(sender As Object, e As EventArgs) Handles BtnSetReminder.Click
If DbConnect() Then
Dim SQLCmd As New OleDbCommand
If CurrentReminderID = -1 Then
With SQLCmd
.Connection = cn
.CommandText = "Insert into TblReminder (CustomerName, DeviceInfo, RepairPrice, ReminderDate)"
.CommandText &= "Values (#CustomerName, #DeviceInfo, #RepairPrice, #ReminderDate)"
.Parameters.AddWithValue("#CustomerName", TxtCustName.Text)
.Parameters.AddWithValue("#DeviceInfo", TxtDeviceInfo.Text)
.Parameters.AddWithValue("#RepairPrice", TxtPrice.Text)
.Parameters.AddWithValue(" #ReminderDate", TxtDateDue.Text)
.ExecuteNonQuery()
.CommandText = "Select ##Identity"
CurrentReminderID = .ExecuteScalar
Label6.Text = CurrentReminderID
End With
End If
End If
Notification.ShowBalloonTip(1000, "Reminder", "Customer Order Due!", ToolTipIcon.None)
End Sub
End Class

You need to use a Timer and to avoid the constant reading of the database you may use something very simple as a DateTimePicker or a TextBox. In this example I'll use a Texbox.
So, insert a TextBox1 in your form and had the value of the first date/time (read from your database just once). The Textbox1 text should be something like "2021-10-21 15:00" (don't put the seconds and don't forget to have a space between date and time)
TextBox1.Text="2021-10-21 15:00" ' read this data from your database
Then you need to insert a Timer in your form and add this values:
Timer1.Interval = 60000 ' 1 minute
Timer1.Enabled = True
For the last you need to double click the Timer1 and write this code:
Private Sub Timer1_Tick(sender As Object, e As EventArgs) Handles Timer1.Tick
If TextBox1.Text = Now.ToString("yyyy-MM-dd HH:mm") Then
' At this moment you should read the next date/time and put that in TextBox1
MsgBox("Show the popup notification")
End If
End Sub

Since you are using DB connections, ill assume you are familiar with multi threading.
RULE 1. The easiest way to do this is for your application to keep track of (in RAM) the next notification. Any time you add a new one through your app it will both insert it into the DB as well as check to see if it is going to elapse before the current one in RAM. When the one in RAM expires it grabs the next one from the DB. All that to say, you find a way to ensure that the next one to elapse is kept in RAM at any given time.
RULE 2. Then, when a notification is 'saved' in RAM, compare the time when the notification should be displayed - current time, this will give you how long until the notification should de shown and set a timer for that amount of time.
RULE 3. When the timer has elapsed display the notification for the one that is in RAM (Per rule 1 this is the one that just elapsed). And iterate the DB to find the next one to expire. Rinse and repeat. (Note, youll need to handle edge cases for when the first one is intered as there will not be any to compare to, and for when the last one is notified as there will not be any notifications left to store in RAM)
Tada, sorry i dont have code samples. But that is how i have solved this very issue numerous times. It works quite nicely.
EDIT 1: CODE
'main for testing
Dim notificationScheduler As NotificationScheduler = NotificationScheduler.getInstance
notificationScheduler.insertNotification(New MyNotificationEntity("Yay, working", DateTime.Now.AddSeconds(15)))
' expected that the one below will be displayed since insertNotification will only keep track of the newest one in the queue
' As mentioned in your post, you have a DB to persist them long term and as such will only need one in the APP's RAM at a time
notificationScheduler.insertNotification(New MyNotificationEntity("Yay, working-2", DateTime.Now.AddSeconds(3)))
' myNotificationEntity, will be replaced by whatever class you are using to package your reminder's in a single class (dont see on in your code sample)
Public Class MyNotificationEntity
Public data As String
Public timeToShow As DateTime
' this is a dumb bucket class, you will not end up using this one but rather whatever class you use to contain your notifications
' you will need to add _timeToShow as a variable to your class or change the code in the NotificationScheduler to be compatible with your Notification class
Public Sub New(data As String, timeToShow As DateTime)
Me.data = data
Me.timeToShow = timeToShow
End Sub
End Class
'NotificationScheduler class
Public Class NotificationScheduler
Private Shared _instance As NotificationScheduler
Private Shared ReadOnly _lock As Object = New Object()
Dim queuedNotification As MyNotificationEntity
Dim WithEvents timer As System.Windows.Forms.Timer = New Timer
Dim timerElapsedTime As DateTime = DateTime.Now
Private Sub New()
timer.Enabled = False
End Sub
Public Shared Function getInstance()
' enforce singleton design pattern (i.e. there should only be one of these in existence)
' If this does not make sense, that is fine, at some point lookup the 'singleton design pattern'
' Also, SyncLock is a VB (.net really) element that means in a multi-threaded application only one of them at any given time is inside the SyncLock block,
' the rest are queued to alleviate race conditions. Again, if this does not make sense, lookup 'race conditions'
SyncLock _lock
If (_instance Is Nothing) Then
_instance = New NotificationScheduler()
End If
End SyncLock
Return _instance
End Function
' called after you have placed the new notification in your DB, replaces the currently queued notification only if the new one will be shown first
Public Sub insertNotification(notificationEntity As MyNotificationEntity)
If timerElapsedTime.Ticks < notificationEntity.timeToShow.Ticks Then
queuedNotification = notificationEntity
timer.Stop()
Dim span As TimeSpan = notificationEntity.timeToShow - timerElapsedTime
timer.Interval = CInt(span.TotalMilliseconds)
timer.Start()
End If
End Sub
Private Sub timer_elapsed() Handles timer.Tick
' the notification time has arrived
showNotification()
readNextFromDb()
End Sub
Private Sub readNextFromDb()
' read the next (if any) from the and handle edge cases for when there is not next one in the DB
'IMPORTANT: if the one you read from the DB has already happened (i.e. the notification dateTime is after now, notify and do this method again)
'' your code here
End Sub
Private Sub showNotification()
MsgBox(queuedNotification.data) ' do whatever notification you want
End Sub
End Class
You will need to (it appears) create a class NotificationDAO (Data Access Object) or NotificationDTO (Data Transfer Object) or a generic NotificationEntity. That class will have a constructor and public instance variables for the items you need to store about a notification (i.e. every column you have in the 'TblReminder ' table) and everything should work fairly easily.
Note, the above code does not work nor check for pulling notifications out of a DB that have already passed their notification time, read the comments in the code for further information.

Related

How to handle long running tasks in VB.NET forms?

I am currently working on a VB.NET form that automatically create Word documents according to an Excel file and a few extra data asked by the form (Project Name, Customer Name, Use SQL, ...).
This procedure works fine and takes approximatelly 1 or 2 minutes to complete.
The issue is that all my script is in ButtonGenerate.Click Handler. So when the Generate button is pressed the form window is bricked and it's impossible to Cancel...
It shouldn't be in a Click handler. Opening a new thread for that long task seems better. But Multithreading isn't very familiar to me.
I tryed launching the script with
ThreadPool.QueueUserWorkItem(...
but my Generate Sub sets labels and update a Progress Bar in the main form, so I doesn't work unless I use
Me.Invoke(New MethodInvoker(Sub()
label.Text = "..."
ProgressBar.Value = 10
' ...
End Sub)
each time I need to update something on the form and I can't even retrieve any new push of a button with that (A cancel button would be nice).
This is basically my code :
Public Class TestFichesAutomation
Private Sub BtnGenerate_Click(sender As Object, e As EventArgs) Handles BtnGenerate.Click
System.Threading.ThreadPool.QueueUserWorkItem(Sub() Generate())
End Sub
Public Sub Generate()
' Check user options, retrieve Excel Data, SQL, Fill in custom classes, create Word docs (~ 1 minute)
End Sub
So How would you handle that script ? Is Threading even a good solution ?
Thanks a lot for your help ^^ and for the useful doc.
My app now open a new thread and uses 2 custom classes to act like buffers :
Private Async Sub Btn_Click(sender As Object, e As EventArgs) Handles Btn.Click
myProgress = New Progress
' a custom class just for the UI with the current task, current SQL connection status and progress value in %
_Options.ProjectName = TextBoxProjectName.Text
_Options.CustomerName = TextBoxCustomerName.Text
...
' Fill in a custom "_Options" private class to act as a buffer between the 2 thread (the user choices)
Loading = New Loading()
Me.Visible = False
Loading.Show() ' Show the Loading window (a ProgressBar and a label : inputLine)
Task.Run(Function() Generate(Progress, _Options))
Me.Visible = True
End Sub
Public Async Function Generate(ByVal myProgress As Progress, ByVal Options As Options) As Task(Of Boolean)
' DO THE LONG JOB and sometimes update the UI :
myProgress.LoadingValue = 50 ' %
myProgress.CurrentTask= "SQL query : " & ...
Me.Invoke(New MethodInvoker(Sub() UpdateLoading()))
' Check if the task has been cancelled ("Cancelled" is changed by a passvalue from the Loading window):
If myProgress.Cancelled = True Then ...
' Continue ...
End Function
Public Shared Sub UpdateLoading()
MyForm.Loading.ProgressBar.Value = myProgress.LoadingValue
MyForm.Loading.inputLine.Text = myProgress.CurrentTask
' ...
End Sub
You should look into using the Async/Await structure
if the work you need to do is CPU bound, i like using Task.Run() doc here
By making your event handler Async and having it Await the work, you prevent the UI from locking up and avoid the use of Invoke in most cases.
ex:
Private Async Sub Btn_Click(sender As Object, e As EventArgs) Handles Btn.Click
Dim Result As Object = Await Task.Run(Function() SomeFunction())
'after the task returned by Task.Run is completed, the sub will continue, thus allowing you to update the UI etc..
End Sub
For progress reporting with Async/Await you might be interested in this

How to pass a form, object or data to a second form

I have created 2 forms.
The first one is the button that you want to back up.
In the second there are paths that can be modified.
How to make a reference that after pressing the "backup" button will get a path of 2 forms.
The path is saved when I closed form2
I know how to do it in one form but unfortunately I can not refer to another form.
Source of Form 2:
Private Sub Browser_from1_Click(sender As Object, e As EventArgs) Handles Browser_from1.Click
Dim FolderBrowserDialog1 As New FolderBrowserDialog
FolderBrowserDialog1.ShowDialog()
TextBox1from.Text = FolderBrowserDialog1.SelectedPath
If Browser_from1.Text <> "" And TextBox1from.Text <> "" Then
Backup.StartCopy.Enabled = True
End If
End Sub
Private Sub Browser_to1_Click(sender As Object, e As EventArgs) Handles Browser_to1.Click
Dim FolderBrowserDialog1 As New FolderBrowserDialog
FolderBrowserDialog1.ShowDialog()
TextBox2to.Text = FolderBrowserDialog1.SelectedPath
If Browser_to1.Text <> "" And TextBox2to.Text <> "" Then
Backup.StartCopy.Enabled = True
End If
End Sub
Private Sub TextBox1from_TextChanged(sender As Object, e As EventArgs) Handles TextBox1from.TextChanged
End Sub
Private Sub save_settings_Click(sender As Object, e As EventArgs) Handles save_settings.Click
My.Settings.pathmem = TextBox2to.Text
My.Settings.pathmem1 = TextBox1from.Text
My.Settings.Save()
End Sub
Private Sub setting_Load(sender As Object, e As EventArgs) Handles MyBase.Load
TextBox1from.Text = My.Settings.pathmem1
TextBox2to.Text = My.Settings.pathmem
End Sub
End Class
You dont want to create a reference to a form - that would (or could) create a whole new form. You want to hold onto the form reference.
This is done by passing a reference to the forms, but the talk of one form fiddling with the controls on another form is a bad idea because it breaks encapsulation. But forms are classes (it says so at the top of each one), so you can add Properties and Methods (Sub and/or Functions) to facilitate passing information back and forth.
Method One - Passing a Form Reference
The simplest way is to pass whatever the other form needs in the constructor:
' form 1 / "main" form / form to return to
Dim frm As New Form6(Me)
frm.Show()
Me.Hide()
In order for this to work, you need to modify the constructor (Sub New) on the destination form:
Private frmReturnTo As Form
Public Sub New(f As Form)
' This call is required by the designer.
InitializeComponent()
frmReturnTo = f
End Sub
It is best not to create your own constructor until you are familiar with them. Use the drop downs at the top of the code window: from the left pick the form name; from the right, select New. The designer adds required code to them which must not be changed.
Do not add any code before the InitializeComponent() call at least until you are familiar with the life cycle of a form. The form and its controls do not exist until that runs.
To return to the "main" form:
If frmReturnTo IsNot Nothing Then
frmReturnTo.Show()
End If
You may want to remove some of the title bar buttons or add code to the form Closing event to handle when the user closes via the system menu or buttons.
Using the constructor is ideal for cases where there is some bit of data which the form must have in order to do its job.
Method Two - Passing Data
Thats all well and good, but what about passing data to another form? You can use the constructor for that too. In order to pass say, a string, integer and a Point:
' destination / second form:
Public Sub New(a As String, b As Int32, c As Point)
' This call is required by the designer.
InitializeComponent()
' Add any initialization after the InitializeComponent() call.
Label1.Text = a
Label2.Text = b.ToString
Label3.Text = c.ToString
End Sub
Call it like this:
' method two: pass data you want to share in the ctor
Dim frm As New frmData("hello", 6, New Point(150, 550))
frm.Show()
Result:
Method Three: Properties
Thats fine, but if there is a lots of data that way can get cumbersome. Plus, you may want to update some of the data from the calling/main form. For this you can create Properties on the form to handle the data:
Public Property Label1Text As String
Get
Return Me.Label1.Text
End Get
Set(value As String)
Me.Label1.Text = value
End Set
End Property
Rather than a private variable to act as the backing field, one of the controls is used. The name leaves a bit to be desired as it exposes implementation details. So, use names which describe what the data represents rather than where it displays.
Public Property SpecialValue As Integer
Get
Return Integer.Parse(Me.Label2.Text)
End Get
Set(value As Integer)
Me.Label2.Text = value.ToString
End Set
End Property
Public Property SomePoint As Point
Get
Dim data = Me.Label3.Text.Split(","c)
Return New Point(Convert.ToInt32(data(0)),
Convert.ToInt32(data(1))
)
End Get
Set(value As Point)
Me.Label3.Text = value.X.ToString & "," & value.Y.ToString
End Set
End Property
A point was used just to show that other data types can be used. Setting those values from the calling/original/source form:
Using frm As New Form6
frm.Label1Text = "Ziggy"
frm.SpecialValue = 42
frm.SomePoint = New Point(111, 222)
frm.ShowDialog()
' do stuff here with any changes
Dim theint = frm.SpecialValue
End Using ' dispose of dialog
The destination controls would well have been TextBoxes for the user to edit. The Property "wrappers" allow you to fetch those values back, so in this case, a Dialog was used.
Method Four: Methods
You can also use methods as a way to pass data to the second/helper form. Here a List(of T) collection will be passed. In the child/display form a method is added to receive the data which it then displays. The task represented is proofing or viewing a filtered list:
Public Sub UpdateDisplay(lst As List(Of SimpleItem), filter As String)
DataGridView1.DataSource = lst
Label1.Text = String.Format("{0} Total {1} Items", lst.Count, filter)
End Sub
In the main/calling form:
' form level variable
Private frmDV As frmDataView
elsewhere...perhaps in a Click event:
' myList is a simple list of items
' Users pick which color to filter on via a combo box
Dim filter As String
If cboListFilter.SelectedItem IsNot Nothing Then
'Dim frmDV As New frmDataView
If frmDV Is Nothing OrElse frmDV.IsDisposed Then
frmDV = New frmDataView
End If
filter = cboListFilter.SelectedItem.ToString()
' apply the filter
Dim tmpList = myList.Where(Function(w) w.Color = filter).ToList()
frmDV.UpdateDisplay(tmpList, filter)
frmDV.Show()
Else
Return
End If
Result:
With DataBased apps a modified version of this can allow for the case where you display DataGridView data in detail form on another form. You need not have the second form rung SQL to add or update the record, and then the main form running another query to "refresh" the display. If the DataSource is a DataTable backed up by a fully configured DataAdapter, pass the DataTable and have the child form add, change or delete using that. The data will automagically be in the DataTable and DataGridView`.
There are other ways to do this, but they generally all boil down to passing something from A to B. Which way is "best" depends on what the app does, the use-case and the nature of the data. There is no one right way or best way.
For instance, Properties and in many cases Functions allow the B Form to close the feedback loop. With DB items, a DataChanged property might tell the calling form that data was added or changed so that form knows to use the DataAdapter to update the db.
'SECOND FORM
Public class secondForm (blah blah)
Public overloads property owner as myMainForm
'Must be only the form you prepared for that
Private sub secondForm_load(blah blah) handles blah blah
Texbox1.text=Owner.customcontrol.text
End sub
End class
'MAIN FORM
public class myMainForm(blah blah)
Private sub button1_click(blah blah) handles blah blah
Dim NewSecondForm as secondForm = New secondForm
NewSecondForm.owner(me)
NewSecondForm.show(me)
NewSecondForm.dispose()
' so you can have bidirectional communication between the two forms and access all the controls and properties from each other
End sub
End Class

Raise Event when a Function return True

Part of a program I am modifying involves communicating through a serial port using a proprietary library. Unfortunately, this library does not have the same SerialPort.DataReceived event that the System.IO.Ports namespace contains. In fact, it has no events whatsoever, however it does have two functions that can probably be used similarly:
Port.WaitForData(int time)
This function waits the given amount of time to recieve some previously specified strings over the port. It returns 1 for yes, received string, or 0 for no, did not recieve string, timed out.
Port.IsReceiveBufferEmpty()
This function returns a boolean of yes, the receive buffer is empty or no, the receive buffer contains data.
It seems to me I will have to create some thread to be continuously looping whenever the port is opened and do one of these two things:
For every loop, call WaitForData(some big number) with the specified strings it is looking for set to "", or vbCrLf, or something else that I can confirm it will recieve everytime data is sent. If it finds smoething, read it and write to a textbox. If WaitForData doesn't find anything, loop again.
For every loop, call IsReceiveBufferEmpty(), and if it isn't, read it and write to a textbox.
What the best way to go about implementing this? The first options seems potentially more efficient to me, although I know next to nothing about how these method work under the hood. Obviously I want to keep my form responsive when doing this, so how should I go about continuously looping without freezing the form but being able to read any incoming data?
Thanks for your help.
Perhaps not the most elegant solution, but you could use a BackgroundWorker to do the IO. e.g. something like this pseudo-code:
Public Class MyForm
Private _port As ProprietaryIOLibrary
Private WithEvents Worker As System.ComponentModel.BackgroundWorker
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles Me.Load
_port = New ProprietaryIOLibrary()
Worker = New System.ComponentModel.BackgroundWorker()
Worker.WorkerReportsProgress = True
Worker.WorkerSupportsCancellation = True
Worker.RunWorkerAsync()
End Sub
Private Sub ButtonCancel_Click(sender As Object, e As EventArgs) Handles ButtonCancel.Click
Worker.CancelAsync()
End Sub
Private Sub Worker_DoWork(sender As Object, e As DoWorkEventArgs) Handles Worker.DoWork
Do
If _port.WaitForData(1000) Then ' Adjust timeout depending on cancel responsiveness?
Dim data As String = _port.ReadDataAsString() ' ?
' Trigger the ProgressChanged event, passing the data
Worker.ReportProgress(0, data)
End If
If Worker.CancellationPending Then
Exit Do
End If
Loop
End Sub
Private Sub Worker_ProgressChanged(sender As Object, e As ProgressChangedEventArgs) Handles Worker.ProgressChanged
' Update the UI with the data received
' ProgressChanged is called on the UI thread, so no need to Invoke
Dim data As String = DirectCast(e.UserState, String)
TextBox1.Text &= data & vbCrLf
End Sub
Private Sub Worker_RunWorkerCompleted(sender As Object, e As RunWorkerCompletedEventArgs) Handles Worker.RunWorkerCompleted
TextBox1.Text &= "Complete!"
End Sub
End Class

Making a button.click event do two different things

I'm working on a simple VB.NET program (just using winforms) and am really terrible at UI management. I'd like to have a single button that starts a process, and then have that same button stop the process.
I'm thinking about having the main form initiate a counter, and the Click event iterate the counter. Then it does a simple check, and if the counter is even it will do thing A and odd does thing B.
Is there a better way, aside from using two buttons or stop/start radio buttons?
I've done that exact thing one of two ways. You can use a static variable or toggle the text of the button.
Since your button has two functions, Good design requires you to indicate that to the user. The following code assumes the Button's text is set in Design Mode to "Start", and the code to start and stop your process is in the Subs StartProcess and EndProcess.
Public Sub Button1_Click(ByVal Sender as Object, ByVal e as System.EventArgs)
If Button1.Text ="Start" Then
StartProcess()
Button1.Text="End"
Else
EndProcess()
Button1.Text="Start"
End IF
End Sub
EDIT
The above solution is fine for a single-language application developed by a small number of developers.
To support multiple languages, developers typically assign all text literals from supporting files or databases. In larger development shops, with multiple programmers, using a display feature of the control for flow-control may cause confusion and regression errors. In those cass, the above technique wouldn't work.
Instead, you could use the Tag property of the button, which holds an object. I would typically use a Boolean, but I used a string just to make more clear as to what's going on.
Public Sub New()
'Initialize the Tag
Button1.Tag="Start"
End Sub
Public Sub Button1_Click(ByVal Sender as Object, ByVal e as System.EventArgs)
If Button1.Tag.ToString="Start" Then
StartProcess()
Button1.Tag="End"
Else
EndProcess()
Button1.Tag="Start"
End IF
End Sub
This is example in pseudo-code. I don't guarantee that names of methods and event are exactly match real names. But this should provide you a design that you could use for responsive form.
Lets say, your process is running on separate tread, using BackgroundWorker.
You setup your worker and start process
Class MyForm
private _isRunning as boolean
private _bgWorker as BackgroundWorker
sub buton_click()
If Not _isRunning Then
_isRunning = true;
StartProcess()
Else
StopProcess()
End if
end sub
sub StartProcess()
' Setup your worker
' Wire DoWork
' Wire on Progress
' wire on End
_bgWorker.RunWorkerAsync()
End sub
sub StopProcess()
if _isRunning andAlso _bgWorker.IsBusy then
' Send signal to worker to end processed
_bgWorker.CancelAsync()
end if
end sub
sub DoWork()
worker.ReportProgress(data) ' report progress with status like <Started>
' periodically check if process canceled
if worker.canceled then
worker.ReportProgress(data) ' report progress with status like <Cancelling>
return
end if
' Do your process and report more progress here with status like <In Progress>
' and again periodically check if process canceled
if worker.canceled then
worker.ReportProgress(data) ' report progress with status like <Cancelling>
return
end if
worker.ReportProgress(data) ' report progress with status like <Ending>
end sub
sub ReportProgress(data)
if data = <some process state, like "Started"> then
btnProcess.Text = "End Process"
end if
End sub
sub ReportEndOfProcess
btnProcess.Text = "Start Process"
_isRunning = false
end sub
End Class
Here you can pinpoint the names of methods and events
You have to substitute identifiers with real names and create you state or data object, which will carry information from background thread to UI thread, and also an Enum Status that can be part of your custom state object. This should work once translated into real code
Just want to show another approach for this task
Use .Tag property for your own purpose
If .Tag Is Nothing (by default in designer) then start process
If not Nothing -> stop process
Public Sub Button1_Click(ByVal Sender as Object, ByVal e as System.EventArgs)
If Me.Button1.Tag Is Nothing Then
StartProcess()
Me.Button1.Tag = New Object()
Me.Button1.Text = "End"
Else
EndProcess()
Me.Button1.Tag = Nothing
Me.Button1.Text = "Start"
End
End Sub

Trying to use Threading.Tasks.Task to kick off a background search while user types, but after they pause

I've searched but I can't find the solution I'm looking for.
I specifically want to use Threading.Tasks.Task for this project, just to understand it better.
I have a lengthy XML file that I want to search based on text that a user types in. Because it's lengthy, I want to wait for the user to stop typing for 250ms or so before I actually start searching in the background. I tried to kick off my Task, having it sleep for 250ms, then check if my CancelTokenSource had canceled because another character was typed. I'm not seeing a cancellation, though, so I end up seeing my results flash several times as the queued up search tasks complete, one after the other, after I finish typing.
My code has turned into a terrible mess and I need to start over, but was hoping someone could point me in the right direction.
Start with a thread-safe property that determines when the search should begin. Initialise it to Date.MaxValue to prevent it running before it's asked to.
Private Property SearchTriggerTime As Date
Get
SyncLock SearchTriggerTimeLock
Return _SearchTriggerTime
End SyncLock
End Get
Set(value As Date)
SyncLock SearchTriggerTimeLock
_SearchTriggerTime = value
End SyncLock
End Set
End Property
Private _SearchTriggerTime As Date = Date.MaxValue
Private ReadOnly SearchTriggerTimeLock As New Object
When the search text box text changes, set the timer to when you want to start searching. As the user types quickly, the search timer will be reset before it triggers. In the code below, if the user clears the text box, the timer is set to never trigger, ie. do not search.
Private Const SearchDelay As Integer = 250
Private Sub TextBox1_TextChanged(sender As Object, e As EventArgs) Handles TextBox1.TextChanged
If TextBox1.Text <> "" Then
SearchTriggerTime = Date.Now.AddMilliseconds(SearchDelay)
Else
SearchTriggerTime = Date.MaxValue
End If
End Sub
Start a thread to perform the searching when the form loads.
Public Sub Form_Load(sender As Object, e As EventArgs) Handles Me.Load
With New Thread(AddressOf SearchThread)
.Start()
End With
End Sub
This thread passes through three states. The first state is waiting for the trigger time to start the search. It checks the trigger time every 50ms. The second state is performing the search. During the search, it checks if the form closes or if the user types more, and abandons the search in those cases. In the third state, if the search completes normally, the form's original thread is asked to display the results. If you need to change a control, always do so on the form's thread by using Form.Invoke(Delegate).
Private Sub SearchThread()
Do Until IsDisposed
' Wait for the user to stop typing.
Do Until IsDisposed OrElse SearchTriggerTime <= Date.Now
Thread.Sleep(50)
Loop
' Search until the form is disposed, the user types more, or the search is complete.
' TODO: Initialise the search variables.
Dim SearchComplete As Boolean = False
Do Until IsDisposed OrElse SearchTriggerTime > Date.Now OrElse SearchComplete
' TODO: Insert search code here.
If condition Then SearchComplete = True
Loop
' Reset the search timer.
SearchTriggerTime = Date.MaxValue
' Only display results if the search was completed.
If SearchComplete Then Invoke(New Action(AddressOf DisplaySearchResults))
Loop
End Sub
Lastly, create a method to display your search results. This will be run in the form's thread to prevent invalid cross-thread operations.
Private Sub DisplaySearchResults()
' TODO: Display search results.
End Sub
I managed to get it to work and it actually looks fairly clean (I think).
I'm using 'Static' variables so that I can keep all of the code within this method but still have my CancellationTokenSource available on subsequent calls.
I'm pretty pleased with the result, but still welcome comments and notes for improvement.
The actual search is actually happening on the UI thread (I think) but I'm doing that because it's easy to populate a treeview while I find valid nodes in my XML. My next refactor will be to do the search on a background thread (rather than just the 250ms wait) and use a TaskScheduler to post UI updates.
Private Sub txtQuickSearch_TextChanged(ByVal sender As Object, ByVal e As System.EventArgs) Handles txtQuickSearch.TextChanged
Static scCancelTokenSource As Threading.CancellationTokenSource
Static scCancelToken As Threading.CancellationToken
If txtQuickSearch.Text.Length >= 3 Then
If scCancelTokenSource IsNot Nothing Then
scCancelTokenSource.Cancel()
scCancelTokenSource = Nothing
End If
scCancelTokenSource = New Threading.CancellationTokenSource
scCancelToken = scCancelTokenSource.Token
Dim lfSearch = Sub()
Dim ltToken As Threading.CancellationToken = scCancelToken
Threading.Thread.Sleep(250)
If Not ltToken.IsCancellationRequested Then
Me.Invoke(Sub() DoQuickSearch(txtQuickSearch.Text))
End If
End Sub
Threading.Tasks.Task.Factory.StartNew(lfSearch, scCancelToken)
End If
End Sub