VB.NET のマップアプリで「ポイント一覧を初期化したあと、ユーザーのクリックで新しいポイントが確定するまで待ち、確定後に処理を再開したい」――そんな“途中で止めたい”要件は、同期処理のままだと UI が固まりがちです。本記事では TaskCompletionSource と async/await を使い、WPF/WinForms で自然にクリック待ちを実装する方法を解説します。
背景:AddPoint の途中でユーザークリックを待ちたい
マップ上にポイント(マーカー)を追加する機能は、業務アプリでも社内ツールでも定番です。ところが実装していると、次のような処理フローにぶつかります。
- 既存ポイントを描画し直す(最新状態にする)
- 内部のポイント一覧をクリアする(新規追加の準備)
- ここで一度止まって、ユーザーがマップ上をクリックするのを待つ
- クリック位置から座標を確定したら、残りの処理(パラメータ画面表示、登録、再描画など)を続ける
疑似コードで書くと、次の <<STOP HERE>> の位置で“待ちたい”イメージです。
Private Sub AddPoint()
PostAllPoints() ' 既存ポイントを描画し直す
Points.Clear() ' 既存ポイント一覧をクリア
' <<STOP HERE>> ここでユーザーのクリックを待ちたい
Dim win As New GeoElementParameters()
' 以降、newPoint を使った処理を続けたい
End Sub
しかし、UI アプリ(WPF / WinForms)では「ここで止めて待つ」を同期的にやろうとすると、ほぼ確実に詰まります。理由はシンプルで、UI スレッドはイベントを処理するためのスレッドだからです。
同期的に“待つ”と何が起きるか
UI でユーザー入力(クリック)を待つために、次のような実装を思いつきがちです。
| やりがちな方法 | 何が問題か | 起きやすい不具合 |
|---|---|---|
While Not clicked : End While | UI スレッドがループで占有され、イベントが処理されない | アプリが固まる/CPU 使用率が跳ねる |
Thread.Sleep() | メッセージポンプが止まり、画面更新・クリックが来ない | 「応答なし」表示、ウィンドウが白くなる |
WaitHandle.WaitOne() | 同期ブロックで UI が停止。場合によってはデッドロック | 一見動くが再現性の低いフリーズが発生 |
Application.DoEvents() | 無理やりイベントを回すが、再入(Re-entrancy)を招きやすい | 同じ処理が二重に走る/状態が壊れる |
重要なのは、マップクリックイベント(例:MouseLeftButtonUp)を受け取って座標を確定するにも、UI スレッドが動いている必要があるという点です。同期的に止めてしまうと、クリックイベント自体が実行されず、永遠に待ち続ける形になります。
解決の考え方:イベント完了を Task に変換して Await する
UI を止めずに「待つ」を表現したい場合は、処理をブロックするのではなく、非同期で“中断して、イベントが来たら再開”させます。VB.NET では Async/Await がこの用途に非常に相性が良く、さらにイベント側から完了を通知するために TaskCompletionSource を組み合わせます。
TaskCompletionSource(Of T) は、ざっくり言うと次のような道具です。
- 待つ側(AddPoint):
TaskCompletionSource.TaskをAwaitして「入力完了」を待つ - 通知する側(CreatePoint / クリックイベント):ポイントが確定した瞬間に
TrySetResultで完了を通知する
| 登場人物 | 役割 | 具体例 |
|---|---|---|
| AddPoint(呼び出し元の処理) | 「ユーザーがポイントを選ぶまで待って、続きへ進む」 | Dim pt = Await _tcs.Task |
| マップのクリックイベント | 「ポイントが決まったよ」と外側へ通知する | _tcs.TrySetResult(pt) |
この仕組みを入れると、見た目は「途中で止まって再開」なのに、実際は UI を止めずに自然な待機になります。ユーザー体験としても、フリーズせずにスムーズです。
基本実装:TaskCompletionSource でクリック待ちを作る
まずは最小構成の実装です。ポイントの型は例として Point を使いますが、地理座標(緯度経度)を扱うなら GeoPoint など独自型に置き換えてください。ポイントの確定条件(Ctrl 押下など)も、運用に合わせて調整できます。
ポイント:Async Sub はイベントハンドラに限定する
VB.NET では Async Sub を多用すると例外が握りつぶされやすく、呼び出し側が完了を待てません。基本は Async Function で Task を返す形にし、ボタンやメニューのクリックなど「イベントハンドラ」だけを Async Sub にします。
Imports System.Threading
Imports System.Threading.Tasks
' フォーム/ウィンドウのクラス内に保持
Private _pointTcs As TaskCompletionSource(Of Point)
Private _isWaitingPoint As Boolean
' 例:ボタンから「ポイント追加」を開始する
Private Async Sub btnAddPoint_Click(sender As Object, e As EventArgs) Handles btnAddPoint.Click
Try
Await AddPointAsync(CancellationToken.None)
Catch ex As OperationCanceledException
' キャンセル時の後始末(必要なら)
Catch ex As Exception
' 予期しない例外ログなど
End Try
End Sub
Private Async Function AddPointAsync(ct As CancellationToken) As Task
' 多重起動のガード(好みに応じて「前回をキャンセル」でもOK)
If _isWaitingPoint Then Return
_isWaitingPoint = True
Try
PostAllPoints() ' 既存ポイントを描画し直す
Points.Clear() ' 既存ポイント一覧をクリア
' クリック待ち用の Task を用意(継続がイベント内で同期実行されないようにする)
_pointTcs = New TaskCompletionSource(Of Point)(
TaskCreationOptions.RunContinuationsAsynchronously)
' キャンセル対応(不要なら削ってOK)
Using ct.Register(Sub() _pointTcs.TrySetCanceled(ct))
' ▼ここで「一時停止」:UI は生きたままクリックを待つ
Dim newPoint As Point = Await _pointTcs.Task
' ▼ここから先が「待った後の続き」
Dim win As New GeoElementParameters()
' newPoint を使った処理(登録、パラメータ表示、描画など)
SavePoint(newPoint)
PostMark(newPoint)
End Using
Finally
' 後始末:次回のために状態をリセット
_pointTcs = Nothing
_isWaitingPoint = False
End Try
End Function
そして、クリックイベント側で TrySetResult を呼び、待機している Await を解除します。
' WPF の例:MouseLeftButtonUp
Private Sub CreatePoint(sender As Object, e As MouseButtonEventArgs) Handles map.MouseLeftButtonUp
If Not _isWaitingPoint Then Return
If _pointTcs Is Nothing Then Return
' 例:Ctrl を押しているときだけ確定
If Keyboard.Modifiers <> ModifierKeys.Control Then Return
Dim pt As Point = map.ScreenToGeographic(e.GetPosition(map))
Points.Add(pt)
PostMark(pt) ' マーカー描画
' ▼ここで「完了」を通知 → AddPointAsync の Await が解除される
_pointTcs.TrySetResult(pt)
End Sub
これで、「ポイント一覧を初期化 → クリック待ち → 確定後に続きの処理」という流れが、コード上でも読みやすく表現できます。
実装のコツ:待機状態の UI を“作る”
クリック待ちが実現できても、ユーザーが今「入力待ち」なのか分からないと操作ミスが起きます。業務アプリでは、待機状態の表現が地味に効きます。
- ステータスバーに「Ctrl+クリックでポイントを指定してください」などの案内を出す
- カーソルをクロスヘアに変更する
- 他のボタンを一時的に無効化する(多重起動の防止にもなる)
- 確定後に案内を消し、UI を通常状態へ戻す
Private Sub EnterPointPickingMode()
lblStatus.Text = "Ctrl+クリックでポイントを指定してください"
btnAddPoint.IsEnabled = False
End Sub
Private Sub ExitPointPickingMode()
lblStatus.Text = ""
btnAddPoint.IsEnabled = True
End Sub
AddPointAsync の Try の前後で呼ぶだけでも、操作性が上がります。
WPF での実践例:待機と再開をより安全にする
WPF では、Await の後は通常 UI スレッドに戻るため(既定動作)、待機後の UI 更新もそのまま書けます。一方で、イベントが連打されたり二重に確定したりする可能性があるため、次の点を押さえると安定します。
- 二重確定を防ぐ:確定したら
_pointTcsをNothingにする、または_isWaitingPointを落とす - 取り消しやモード解除を用意する:Esc キーや右クリックでキャンセルできると運用が楽
- 例外を見逃さない:イベント以外は
Async Functionにして、呼び出し側でAwaitする
' クリック確定後に二重確定を防ぐ例
Private Sub CreatePoint(sender As Object, e As MouseButtonEventArgs) Handles map.MouseLeftButtonUp
If Not _isWaitingPoint OrElse _pointTcs Is Nothing Then Return
If Keyboard.Modifiers <> ModifierKeys.Control Then Return
Dim pt As Point = map.ScreenToGeographic(e.GetPosition(map))
' 先にモードを解除してから完了通知すると、二重クリックに強い
_isWaitingPoint = False
Dim tcs = _pointTcs
_pointTcs = Nothing
Points.Add(pt)
PostMark(pt)
tcs.TrySetResult(pt)
End Sub
「先にモード解除→後で TrySetResult」という順番にすると、クリックイベントが短時間に複数回発火しても状態が壊れにくくなります。
WinForms での実践例:MouseDown で同じことをする
WinForms でも考え方は同じです。イベント引数や修飾キーの取得方法が少し変わるだけで、TaskCompletionSource+Await はそのまま使えます。
Imports System.Threading
Imports System.Threading.Tasks
Imports System.Drawing
Private _pointTcs As TaskCompletionSource(Of PointF)
Private _isWaitingPoint As Boolean
Private Async Sub btnAddPoint_Click(sender As Object, e As EventArgs) Handles btnAddPoint.Click
Await AddPointAsync(CancellationToken.None)
End Sub
Private Async Function AddPointAsync(ct As CancellationToken) As Task
If _isWaitingPoint Then Return
_isWaitingPoint = True
Try
PostAllPoints()
Points.Clear()
_pointTcs = New TaskCompletionSource(Of PointF)(
TaskCreationOptions.RunContinuationsAsynchronously)
Using ct.Register(Sub() _pointTcs.TrySetCanceled(ct))
Dim p As PointF = Await _pointTcs.Task
Dim win As New GeoElementParameters()
SavePoint(p)
PostMark(p)
End Using
Finally
_pointTcs = Nothing
_isWaitingPoint = False
End Try
End Function
Private Sub map_MouseDown(sender As Object, e As MouseEventArgs) Handles map.MouseDown
If Not _isWaitingPoint OrElse _pointTcs Is Nothing Then Return
If (Control.ModifierKeys And Keys.Control) = 0 Then Return
If e.Button <> MouseButtons.Left Then Return
Dim p As PointF = map.ScreenToGeographic(New PointF(e.X, e.Y))
_isWaitingPoint = False
Dim tcs = _pointTcs
_pointTcs = Nothing
Points.Add(p)
PostMark(p)
tcs.TrySetResult(p)
End Sub
WinForms では PointF を使うなど、型を状況に合わせて選ぶと良いです。緯度経度なら (Double, Double) のタプルや独自構造体でも構いません。
複数ポイントを連続で追加したい場合
「1点だけ」ではなく、例えばルート作成のために複数点を順に選ばせたいケースもあります。こうした場合は、“クリック待ち”の部分をメソッド化してループで Await すると、読みやすく拡張しやすいです。
Private Function WaitForPointAsync(ct As CancellationToken) As Task(Of Point)
_pointTcs = New TaskCompletionSource(Of Point)(
TaskCreationOptions.RunContinuationsAsynchronously)
ct.Register(Sub() _pointTcs.TrySetCanceled(ct))
Return _pointTcs.Task
End Function
Private Async Function AddRouteAsync(pointCount As Integer, ct As CancellationToken) As Task
PostAllPoints()
Points.Clear()
EnterPointPickingMode()
Try
For i As Integer = 1 To pointCount
lblStatus.Text = $"Ctrl+クリックで {i}/{pointCount} 点目を指定してください"
_isWaitingPoint = True
Dim p = Await WaitForPointAsync(ct)
Points.Add(p)
PostMark(p)
Next
' すべて揃った後の処理(例:線を引く)
PostPolyline(Points)
Finally
ExitPointPickingMode()
_isWaitingPoint = False
_pointTcs = Nothing
End Try
End Function
このように「待機の仕組み」を小さく分離しておくと、後で「右クリックで取り消し」「Enter で確定」など要件が増えても対応が簡単です。
キャンセルとタイムアウトの入れ方
現場では「クリックを待っている間に、ユーザーが別の作業に移った」「誤って開始ボタンを押した」などが起きます。待機を無限にしないために、キャンセルやタイムアウトは最初から入れておくと運用が安定します。
キャンセル(CancellationToken)
すでに例に入れた ct.Register の形が簡単です。キャンセル時は OperationCanceledException が投げられるので、呼び出し元で捕捉して UI を戻します。
タイムアウト(Task.WhenAny)
「一定時間クリックがなければ終了」したい場合は、Task.Delay と Task.WhenAny を組み合わせます。
Private Async Function WaitForPointWithTimeoutAsync(timeoutMs As Integer) As Task(Of Point)
_pointTcs = New TaskCompletionSource(Of Point)(
TaskCreationOptions.RunContinuationsAsynchronously)
Dim pointTask = _pointTcs.Task
Dim timeoutTask = Task.Delay(timeoutMs)
Dim completed = Await Task.WhenAny(pointTask, timeoutTask)
If completed Is timeoutTask Then
_pointTcs.TrySetCanceled()
Throw New TimeoutException("ポイント指定がタイムアウトしました。")
End If
Return Await pointTask
End Function
タイムアウト時に「待機モードを解除」「メッセージ表示」などを行うと、ユーザーも状況を理解しやすくなります。
よくある落とし穴と対処法
TaskCompletionSource は便利ですが、UI 連携では“状態管理”のミスがバグにつながりやすいです。実装前に、次の典型パターンだけ押さえておくと、ハマりどころが減ります。
| 症状 | 原因 | 対処 |
|---|---|---|
| クリックしても先に進まない | UI スレッドを同期ブロックしてイベントが動いていない | Await で待つ設計に変更し、.Wait() や .Result を使わない |
| 二重クリックで例外/状態が壊れる | TrySetResult が複数回呼ばれている | 確定したら _pointTcs = Nothing、_isWaitingPoint = False を先に実行する |
| 2回目以降の追加で待機が解除されない | 完了済みの TaskCompletionSource を再利用している | ポイント追加を開始するたびに新しい TaskCompletionSource を作る |
| 例外がどこかに消える | Async Sub を通常メソッドとして使っている | イベント以外は Async Function ... As Task にする |
| 待機中に別操作ができてしまい、結果が不整合になる | 「入力待ち」中の UI 制御がない | 待機モード中はボタン無効化、ステータス表示などでガードする |
設計を一段ラクにする分割案
「AddPoint が大きくなって読みづらい」「待機前後の処理が入り組む」という場合は、処理を意識的に 2 つに分けるのが効果的です。
- 準備フェーズ:既存描画、リストクリア、待機モード UI へ切り替え
- 確定フェーズ:クリック結果(新ポイント)を受け取り、登録・パラメータ・再描画
Private Sub PrepareForNewPoint()
PostAllPoints()
Points.Clear()
EnterPointPickingMode()
End Sub
Private Sub CommitNewPoint(p As Point)
Dim win As New GeoElementParameters()
SavePoint(p)
PostMark(p)
ExitPointPickingMode()
End Sub
Private Async Function AddPointAsync(ct As CancellationToken) As Task
PrepareForNewPoint()
_pointTcs = New TaskCompletionSource(Of Point)(
TaskCreationOptions.RunContinuationsAsynchronously)
Using ct.Register(Sub() _pointTcs.TrySetCanceled(ct))
Dim p = Await _pointTcs.Task
CommitNewPoint(p)
End Using
End Function
この形にしておくと、後で「確定前にスナップ(近傍吸着)する」「確定後に属性入力フォームを必須にする」といった仕様変更にも耐えやすくなります。
まとめ:UI を止めずに“クリック待ち”を実現するのが正解
VB.NET の WPF / WinForms アプリで「処理の途中でユーザー入力を待つ」要件は、同期ブロックでは実現しにくく、実現できたとしてもフリーズや不具合を誘発しやすい領域です。TaskCompletionSource と async/await を使って、イベントの完了を Task として待つ設計にすることで、
- UI を固めずにクリック待ちできる
- 「準備→待機→確定後処理」をコード上で自然に表現できる
- キャンセルやタイムアウト、複数点追加などの拡張がしやすい
といったメリットが得られます。まずは「1点だけ確定」をこの形で安定させ、必要に応じて複数点やキャンセル機能へ広げていくのが、実務でも失敗しにくい進め方です。

コメント