[Note: Updated RunAfter
to support the OnLostFocus
event]
A year without a post? Hrm. There is a post dated 2019 but it's in the Draft
state; never got around to finishing it... oops.
So here's a little post on running code on a timer; I don't know if C# can do this more elegantly, but I don't think VB can. Hence: this.
There have been far too many times I've wanted to run code that only displays something for a couple of seconds and then hides it. While simple enough, I really don't want to have to create a Timer component, instantiate it, run the code, and then tear it all down again. Just display a thing and then go away - that's all I want!
I couldn't even have a half-solution by instantiating and executing the Timer without having an explicit method for the Timer to call into when the time elapses, such as [note that all code formatting in this post is wonky; a lot of it isn't really fixable]:
Dim timer As New Timer
timer.Start(2000, Sub()
Console.WriteLine("Runs after 2 seconds!")
End Sub)
I may make a specific extension method for this sort of thing but that's only tangibly why we're here and so will have to wait for another time.
So, today, I decided to try and get this sorted out. While it would probably have to be a Task, I wasn't entirely sure the best way of going about achomlishing accomplishing this and so I began; after various experiments, here's the result.
#Region " Public: RunAfter "
''' <summary>Runs the specified <paramref name="action"/> on the UI thread after the specified number of <paramref name="runAfterDuration"/> milliseconds has expired.</summary>
''' <param name="ctrl">The <see cref="Control"/> thread to run on. If using additional actions (such as <paramref name="additionalEventAction"/>), then any hooked events will be hooked into <c>ctrl</c>.</param>
''' <param name="runAfterDuration">The number of milliseconds (1000 = 1 second) to wait before running the <paramref name="action"/>.</param>
''' <param name="action">The code to run.</param>
''' <param name="additionalHookEvent">Specifies that an event should be hooked for the duration of the task; specify the action associated with the hooked event via <paramref name="additionalEventAction"/>.</param>
''' <param name="additionalEventAction">The action to perform once the <paramref name="additionalHookEvent"/> has been fired.</param>
''' <example>
''' Me.RunAfter(1000, Sub()
''' Me.Text = "Runs on the UI thread"
''' End Sub)
''' </example>
<Extension>
Public Sub RunAfter(ctrl As Control, runAfterDuration As Integer, action As Action(Of Task), Optional additionalHookEvent As RunAfterEvent = RunAfterEvent.None, Optional additionalEventAction As Action(Of Task) = Nothing)
If ctrl Is Nothing Then Throw New ArgumentNullException(NameOf(ctrl), $"{NameOf(ctrl)} cannot be null (Nothing in Visual Basic).")
If ctrl.IsDisposed Then Throw New ObjectDisposedException(NameOf(ctrl), $"{NameOf(ctrl)} cannot be disposed.")
If action Is Nothing Then Throw New ArgumentNullException(NameOf(action), $"{NameOf(action)} cannot be null (Nothing in Visual Basic) and must be a method.")
If Not additionalHookEvent = RunAfterAdditionalEvent.None AndAlso additionalEventAction Is Nothing Then Throw New ArgumentNullException(NameOf(additionalEventAction), $"An {NameOf(additionalHookEvent)} is specified but the {NameOf(additionalEventAction)} is null (Nothing in Visual Basic).")
' Ensure the duration is within an appropriate integer range.
If runAfterDuration < 0 Then runAfterDuration = 0
If runAfterDuration > Integer.MaxValue Then runAfterDuration = Integer.MaxValue
' This allows us to marshal the invocation to the UI thread
' so we don't perform a cross-thread execution.
Dim ts As TaskScheduler = DirectCast(ctrl.Invoke(Function()
Return TaskScheduler.FromCurrentSynchronizationContext
End Function), TaskScheduler)
Dim task = Threading.Tasks.Task.Delay(runAfterDuration)
' The action will be executed after the initial runAfterDuration delay above.
Dim continuedTask = task.ContinueWith(action, ts)
Select Case additionalHookEvent
Case RunAfterEvent.OnClick
' Add the additional specified action to the Control's client event.
' This won't ever be executed if the user never clicks on the Control.
AddHandler ctrl.Click, Sub(sender As Object, e As EventArgs)
ctrl.Invoke(additionalEventAction, continuedTask)
End Sub
Case RunAfterEvent.OnLostFocus
AddHandler ctrl.LostFocus, Sub(sender As Object, e As EventArgs)
ctrl.Invoke(additionalEventAction, continuedTask)
End Sub
End Select
End Sub
#End Region
And the enumeration:
#Region " Enum: RunAfterEvent "
''' <summary>Specifies an event to be hooked.</summary>
<EditorBrowsable(EditorBrowsableState.Advanced)>
Public Enum RunAfterEvent As Integer
''' <summary>No event is hooked.</summary>
None
''' <summary>The <see cref="Control.Click"/> event is hooked.</summary>
OnClick
''' <summary>The <see cref="Control.LostFocus"/> event is hooked.</summary>
OnLostFocus
End Enum
#End Region
The formatting is completely off in this post, so it doesn't look like that in actuality. I also have extension methods for certain things (eg. ensuring a value is appropriately clamped between a range) but I've replaced them with "pure code" versions above.
The above allows you to run code after a specific duration has elapsed, and optionally run additional code based on an event that occurs - all without having to create a separate Timer control that needs to be manually instantiated, hooked, and torn down. Again, this appears to be true for VB.
While not posted here, I have another version that uses a TimeSpan instead of a duration (meh; not a major thing) as it's "more appropriate", and a version that has the initial ctrl As Control
parameter as item as ToolStripItem
to be used with anything that inherits from said ToolStripItem, such as a ToolStripButton.
Use
item.GetCurrentParent
() with a ToolStripItem as only the parent ToolStrip has the requiredInvoke
() method.
Oh, an example? Right - yep, this is the basic version that only specifies the code that should be run after a duration has elapsed. This hides the (eg.) ToolStripLabel Verified OK! indicator after two seconds and is used to let the user know a thing they validated/verified/whatever is fine.
Me.tbrMain_VerifiedOk.Visible = True
' Hides the success indicator after two seconds.
Me.tbrMain_VerifiedOk.RunAfter(2000, Sub()
Me.tbrMain_VerifiedOk.Visible = False
End Sub)
And another version that does the same as the above but also adds code that hides the visual indicator if the user clicks on it (this is a simplified version of code being used in Twitter Delitter).
Me.tbrMain_VerifiedOk.Visible = True
' Hides the success indicator after two seconds, or
' when the user clicks on the indicator itself.
Me.tbrMain_VerifiedOk.RunAfter(2000, Sub()
Me.tbrMain_VerifiedOk.Visible = False
End Sub,
RunAfterEvent.OnClick,
Sub()
Me.tbrMain_VerifiedOk.Visible = False
End Sub)
And that's about it. While countless changes could be made, it would make this post unwieldy; I have a particular loathing for code examples that have extraneous fluff that does nothing but obfuscates what is being shown.
Use this code at your own risk, etc - I (mainly) use VB .NET so you probably shouldn't listen to me in the first place...