Long-time reader and friend of the blog, Ben Sacherich, wrote in with an API call that I had never used before: GetAsyncKeyState.
According to the docs, this function:
Determines whether a key is up or down at the time the function is called, and whether the key was pressed after a previous call to GetAsyncKeyState.
As it is an API call, we need to declare it using the
Declare keyword like so:
Breaking Out of a Loop
One way to use this function, as Ben himself suggested, is to give your users a way to break out of a long-running loop:
Do If LongRunningProcessIsFinished() Then Exit Do If GetAsyncKeyState(vbKeyEscape) Then DoEvents 'This is required or the MsgBox won't show MsgBox "User canceled with Escape", vbInformation Exit Do 'or Exit For, etc. End If Loop
Here's a fully working example you can drop into VBA and test yourself. I use the
SysCmd status bar actions to update a progress meter for something interesting to look at while the loop progresses:
Private Declare PtrSafe Sub Sleep Lib "kernel32" _ (ByVal dwMilliseconds As Long) Private Declare PtrSafe Function GetAsyncKeyState Lib "user32" _ (ByVal vKey As Long) As Integer Sub TestLoopEscape() Dim i As Long For i = 0 To 100 'Set the status bar text, max value, and current value SysCmd acSysCmdInitMeter, i & " of 100", 100 SysCmd acSysCmdUpdateMeter, i 'Pause for half a second Sleep 500 'Check to see if user pressed Escape ' NOTE: this returns True even if the Access window ' does not have the focus (e.g., if the user ' presses [Escape] while using Notepad) If GetAsyncKeyState(vbKeyEscape) Then DoEvents 'This is required or the MsgBox won't show MsgBox "User canceled with Escape", vbInformation Exit For End If Next i 'Clear the status bar SysCmd acSysCmdSetStatus, " " End Sub
I like to wrap APIs in my own procedures, rather than call them directly.
Here's a very simple convenience function. I'm calling it "UserPressed" because it makes for more readable code when combined with an
If statement, which is how it most often will be used:
' ---------------------------------------------------------------- ' Procedure : UserPressed ' Date : 11/2/2023 ' Author : Mike Wolfe ' Source : https://nolongerset.com/userpressed/ ' Purpose : Simple wrapper for the GetAsyncKeyState API function. ' Notes - Returns True: ' o if the user is pressing the button ' at the time of the function call OR ' o if the user pressed the button at any time ' since the last call to the function ' - This detects key presses in ANY Windows application, ' not just the host application ' - You do not need to call DoEvents for this to work ' (not even in a tight loop) ' - Use one of the VBA.KeyCodeConstants to pass in a ' KeyCode to make your code more readable ' - The form KeyPreview property is unrelated to this API ' (it does not need to be set any specific way) ' ---------------------------------------------------------------- Public Function UserPressed(KeyCode As Long) As Boolean Dim Result As Integer Result = GetAsyncKeyState(KeyCode) 'Debug.Print Result 'Uncomment for debugging UserPressed = CBool(Result) End Function
Convenience Function Sample Usage
Here's a different sample method that uses the UserPressed convenience function and proves that
Sleep are not necessary preconditions for the
GetAsyncKeyState() function to work.
Sub TestLoopEscapeWithoutSleepOrDoEvents() 'Initialize a loop counter Dim i As Long i = 0 Do Until i > 100 i = i + 1 'Set the status bar text, max value, and current value SysCmd acSysCmdInitMeter, i & " of 100", 100 SysCmd acSysCmdUpdateMeter, i 'Insert an artificial pause without Sleep or DoEvents to ' demonstrate that GetAsyncKeyState works even without ' those methods there to yield processing time Dim j As Long: j = 0 Do While j < 200000000 j = j + 1 Loop 'Substitute the convenience function for the direct API call If UserPressed(vbKeyEscape) Then DoEvents 'This is still required or the MsgBox won't show MsgBox "User canceled with Escape", vbInformation Exit Do End If Loop 'Clear the status bar SysCmd acSysCmdSetStatus, " " End Sub
There are some important things to be aware of when using this API call:
- Key presses in ANY application get picked up (not just in your Access app): this means that if your user switches to their web browser while waiting for your long process to complete, they could inadvertently kill that long-running process without even realizing it!
- According to the docs, the behavior we're relying on is unreliable: "Although the least significant bit of the return value indicates whether the key has been pressed since the last query, due to the preemptive multitasking nature of Windows, another application can call GetAsyncKeyState and receive the 'recently pressed' bit instead of your application. The behavior of the least significant bit of the return value is retained strictly for compatibility with 16-bit Windows applications (which are non-preemptive) and should not be relied upon."
Personally, I use a custom cancelable Progress Meter form to allow my users to exit prematurely from certain types of long-running loops. I use a [Cancel] command button on the form, which avoids both of the pitfalls detailed above.
In other words, I don't plan on changing my UX for long-running cancelable processes.
That said, I found this API call to be an interesting option and, in practice, it may work just fine. If you do use it in your code, be sure to report back with your results to help guide future readers.
Special thanks to Ben Sacherich for submitting today's tip.
Cover image created with Microsoft Designer and StableDiffusionXL