今日はVBAの長年の疑問であった、十字キーを押すとチェックボックスやトグルボタンが押し下げ状態になる問題の解決策を発見したので記録に残す。
問題の再現方法
この問題は、VBAのユーザーフォームにおいて十字キー操作でフォーカスが移った時に、一部のコントロールで ON(obj.Value = True
) となる症状である。なお、OFF( obj.Value = False
)に変化することはない。
状態が変化することによる悪影響としては、誤った設定値で作業を進めてしまうとか、意図しないタイミングでChangeイベント
が走ってしまうと言った点が挙げられる。
※フォーカス:コントロールに入力カーソルがある状態のこと
次のようなユーザーフォームを作成することで、再現させることができる。
次のようなタイミングで発生する。
- チェックボックスにフォーカスがある状態で十字キーを押して、離した時に移動先のコントロールがなかった時
- テキストボックスにフォーカスがある状態で十字キーを押して、チェックボックスにフォーカスが移った状態でキーを離した時
なお、次のような場合は発生しない。
環境ごとの発生状況は次の通りであった。
- Excel 2007 再現しない
- Excel 2010 再現しない(らしい)
- Excel 2013 再現する(らしい)
- Excel 2016 32bit 再現する
- Microsoft 365 Excel 64bit 再現する
補足情報として、チェックボックスとトグルボタンは、継承元のコントロールが同じ(要は見た目のスタイルを変化させているだけ)なので、同じ挙動をするのだと思われる。
解決策
調査の結果、該当コントロールのKeyUp
イベントで KeyCode.Value = 0
とするのが、もっとも良い解決策であることが分かった。
簡単に言えば「キーの押し上げイベントで押されていたはずの十字キーを押されていなかったことにする」という方法である。
サンプルコードは次の通りである。
Private Sub CheckBox1_KeyUp(ByVal KeyCode As MSForms.ReturnInteger, ByVal Shift As Integer) If (37 <= KeyCode.Value And KeyCode.Value <= 40) Then KeyCode.Value = 0 End Sub
KeyCode.Value = 0
とすることで、「何も押されていない」という意味に上書きできる。
しかし、無条件に無効化すると、副作用が出る恐れがあるので十字キー( ← ↑ → ↓ キーコード 37,38,39,40)が押されているときだけに絞っている。
キーコードは定数vbKey~~~
で指定することもできる。(公式の一覧表は ここにある ) )
しかし、定数を不等号で表現すると (vbKeyLeft <= KeyCode.Value And KeyCode.Value <= vbKeyDown)
のように対象のキーが不明確となってしまう。
そのため、該当する全てのキーが明示できる Select Case
を使った書き方を推奨する。
Private Sub CheckBox1_KeyUp(ByVal KeyCode As MSForms.ReturnInteger, ByVal Shift As Integer) Select Case KeyCode.Value Case vbKeyLeft, vbKeyUp, vbKeyRight, vbKeyDown: KeyCode.Value = 0 End Select End Sub
何故これが解決策になるのか
「KeyCode.Value = 0
でキーが押されていなかったことになる」という仕組みは、少々受け入れがたいかもしれない。
同じ仕組みが使われている身近な例として、Excelの ThisWorkbookモジュールの BeforeCloseイベント の Cancel が挙げられる。
'ThisWorkbookモジュール 'このExcelブックを閉じられなくする Private Sub Workbook_BeforeClose(Cancel As Boolean) Cancel = True End Sub
VBAでは、仮引数に ByVal
ByRef
の表記がない場合、参照渡し( ByRef
)として解釈される。
参照渡しされた変数に代入する処理は、呼び出し元で指定した変数に書き込まれたことになるので、呼び出し元の処理に影響を与える事ができる。
(参照渡しだからといって呼び出し元で使用されるとは限らないが、 Cancel
は値を呼び出し元に返すために用意されている引数である。)
この「ブックを閉じる」という処理を、フローチャートで表すと次の様になる。
ユーザーやVBAが「閉じたい」という行動をとった時、Excelアプリケーションの内部で必ず「ブックを閉じる」という処理が走る。その部分はVBAのソースコードには現れていない。だが「BeforeCloseイベント」という形で一旦VBAに処理を移して、どうしたいのかCancelフラグで決定させる。アプリはCancelフラグを確認してから「本当にブックを閉じる」という処理が行われる。
本題の解決策は、Closeよりは多少複雑なものの、基本的にこの仕組みと同じである。
Cancelに相当するのが KeyCode で、 KeyCode.Value
を書き換えることで、呼び出し元に十字キーが押されていないと勘違いさせて、原因不明の CheckBox.Value = True
を実行されないようにしよう。という試みだ。
キーコードを操作する似たような仕組みは、テキストボックスの入力文字の制限で使われる事が多い。
例えば、次の方法で数字の0~9しか入力されないようにすることができる。
Private Sub TextBox1_KeyDown(ByVal KeyCode As MSForms.ReturnInteger, ByVal Shift As Integer) Select Case KeyCode.Value Case 48 To 57: '数字の0~9だけ受け入れる Case Else: KeyCode.Value = 0 End Select End Sub
これを応用して発見したのが今回の方法だが、2点疑問が残る。(+Shiftが押せない課題)
1.なぜテキストボックスの入力制限はKeyDownなのにチェックボックスのチェックの阻止はKeyUpなのか
GIF画像だけでは分かりづらいが、実際に試すと直ぐに確認することができる。
テキストボックスにおいて、キーの入力はリアルタイムに(キーを押し下げた瞬間)に確定する。キー長押ししたら連打される仕様になっている。そのたびに検知して弾かなければならない。だから KeyDown
なのだろう。
一方で、チェックボックスの「チェックON」は十字キーから手を離した直後に発生している。離す瞬間…だから KeyUp
なのだろう。
後から考えて見れば、十字キーを押し続けてフォーカスが通り過ぎた場合にも発生しないことから、KeyDownやKeyPressは関係ないのだろうと納得がいく。
なお、KeyUp / KeyDown イベントの公式の解説は ここにある
2. なぜ値渡しなのに呼び出し元に影響を与えることができるのか
先ほどCloseイベント
の Cancelプロパティ
は、参照渡しだから呼び出し元の分岐に影響することができるのだと説明していた。
しかし、KeyCodeは 値渡し ByVal
で挙動を変化させることに成功している。「これはおかしい」と思うかもしれない。
だが、別におかしくもなんとも無い。
宣言文はこうなっている。
Private Sub CheckBox1_KeyUp( ByVal KeyCode As MSForms.ReturnInteger, ByVal Shift As Integer)
KeyCode
のデータ型が MSForms.ReturnInteger
となっているのだ。
MSForms.ReturnInteger
とはオブジェクトである。
値渡し(実引数の値のコピー)となるのは、引数で渡された変数そのものだけである。
オブジェクト型の値は「実体の位置を示すメモリアドレス」のみである。そこにプロパティ( .Value
)は含まれない。
従って、Set KeyCode = New MSForms.ReturnInteger
は、呼び出し元に効果を与えないが、KeyCode.Value = True
は呼び出し元の評価に影響を与える事ができる。
また、.Value
はデフォルトプロパティとして設定されているため、 KeyCode = True
は KeyCode.Value = True
と解釈されて期待通りに動く。
3. なぜ修飾キーは押すことができないのか
Private Sub CheckBox1_KeyUp( _ ByVal KeyCode As MSForms.ReturnInteger, _ ByVal Shift As Integer) 'これはShiftを押したことにならない。 Shift = 1 End Sub
KeyUpイベントには引数Shiftがあり、次の数値が加算して組み合わさった値が入っている。
- 0 何も押されていない。
- 1 Shiftが押されている。
- 2 Ctrlが押されている。
- 4 Altが押されている。
ここまでの説明で分かったと思うが、Shiftは値渡しでありデータ型もIntegerとなっているので、イベント処理の中でいくら書き換えても、呼び出し元に伝えることはできない。
以上が十字キーを使うと勝手にチェックが入る問題の解決策である。
参考資料
以下の方々のツイートをヒントに解決にたどり着きました。ありがとうございます。
KeyDownイベントで、矢印キーのkeycodeをキャンセルしてやる、とかどうでしよ?
— Junji (@kubojung) 2021年5月20日
矢印で移動させない、という対処です。
(コンボボックスとか使ってたらそこだけ考慮するとか)
サンプルコードはこんな感じでした。 pic.twitter.com/7pg6q6hTHC
— Takeru Saso (@takeruko) 2021年5月20日
イベント:CheckBox値
— 踊るエクセル@VBEアドイン作ってVBEハック中・・・ (@ExcelVBAer) 2021年5月20日
を追ってみた💡
十字キー(フォーカス無し)
Enter:False
KeyUp:False
Change:True
Click:True
BeforeUpdate:True
AfterUpdate:True
有り
KeyDown:False
KeyUp:False
Change:True
Click:True
BeforeUpdate:True
AfterUpdate:True
まとめ
十字キーを使うと勝手にチェックが入る問題は、KeyUpイベントのKeyCodeを書き換えることで回避することができる。
必要なのはたったの3行で、副作用や可読性の低下はほぼ無いと言える。
あと一歩で処理の流れを追いづらいアクロバティックなコードを書く羽目になっていたので、この方法が見つけられたのは本当に良かった。
VBAのユーザーフォーム特有の挙動は、情報が見つからないことが多いので、こうして良い解決策を後世に残していきたい。
一方で「何故このような挙動をするのか」という謎は解けていない。古いバージョンでは発生していなかったので、バグの疑いが強いが、いつか謎が解けることに期待したい。
以上
何か御座いましたらコメント欄、またはTwitterからどうぞ♪
それではまた来週♪ ちゅんちゅん(・8・)