「ローディングを出してからログイン→画面遷移したいだけなのに、ポップアップは出るが処理がまったく進まない」――.NET MAUIでありがちなこのハマりは、ShowPopupAsyncの挙動とawaitの組み合わせを誤解したことが主因です。本記事では、原因の正体と再発防止までを、一歩ずつ丁寧に解説します。
.NET MAUIでローディング・ポップアップ後に処理が進まない現象
まず問題の再現コードを整理します。
await Shell.Current.CurrentPage.ShowPopupAsync(loadingPopUp);
await firebaseAuth.SignInWithEmailAndPasswordAsync(email, password);
loadingPopUp.Close();
await Shell.Current.GoToAsync("//ChatPage");
現象としては、ポップアップは表示されるものの、SignInWithEmailAndPasswordAsyncにも、GoToAsyncにも到達しません。ブレークポイントを置いても止まらない――この時点で「UIスレッドが詰まっている?」と疑いがちですが、実はもっとシンプルな原因です。
原因:ShowPopupAsyncは「閉じられるまで」完了しない
最大の誤解はここです。CommunityToolkit.MAUIのPopup系APIであるShowPopupAsyncは、ポップアップを表示してすぐ制御を返す関数ではありません。ポップアップがClose()で閉じられるまでawaitは完了しません。つまり、次のような流れが生じています。
await ShowPopupAsync(loadingPopUp)を実行- ポップアップが表示される
- ここで
awaitがポップアップのクローズを待ち続ける - しかし、
Close()はSignInWithEmailAndPasswordAsyncの後で呼ぶつもりだった - 結果として、後続のコードは永遠に実行されない
したがって、ローディング用途ではShowPopupAsyncを待ってはいけません。非同期待機するのは、ユーザーからの入力や何らかの結果(値)をポップアップで返金したい時だけです。
最短解:ShowPopupまたは「待たない」表示+try/finallyで確実に閉じる
ローディング用途では、非同期にしない(または待たない)表示が定石です。以下の2パターンのどちらかを選びましょう。
パターンA:同期版ShowPopupを使う
CommunityToolkit.MAUIのShowPopup(非Async版)なら、呼び出し直後に処理が先へ進みます。
using CommunityToolkit.Maui.Views;
public async Task LoginAsync(string email, string password, CancellationToken ct = default)
{
var popup = new LoadingPopup();
try
{
// UIスレッドで安全に表示
MainThread.BeginInvokeOnMainThread(() => Shell.Current.CurrentPage.ShowPopup(popup));
// 本処理(例外は必ず捕捉)
var user = await firebaseAuth.SignInWithEmailAndPasswordAsync(email, password);
// 画面遷移はUIスレッドで
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync("//ChatPage"));
}
catch (OperationCanceledException)
{
await MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.DisplayAlert("中断", "ログイン処理はキャンセルされました。", "OK"));
}
catch (Exception ex)
{
await MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.DisplayAlert("エラー", $"ログイン処理で例外が発生しました:\n{ex.Message}", "OK"));
}
finally
{
// 表示したら必ず閉じる(finallyで確実に)
MainThread.BeginInvokeOnMainThread(() => popup.Close());
}
}
パターンB:ShowPopupAsyncを「待たず」に呼ぶ
同期版が使えない状況やAPI構成によりShowPopupAsyncしかない場合は、戻り値を待たずに発火し、finallyで閉じます。
public async Task LoginAsync(string email, string password, CancellationToken ct = default)
{
var popup = new LoadingPopup();
try
{
// 待たない発火(fire-and-forget)
_ = MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.ShowPopupAsync(popup));
var user = await firebaseAuth.SignInWithEmailAndPasswordAsync(email, password);
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync("//ChatPage"));
}
catch (Exception ex)
{
await MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.DisplayAlert("エラー", ex.Message, "OK"));
}
finally
{
MainThread.BeginInvokeOnMainThread(() => popup.Close());
}
}
ポイントは2つです。①ローディングは「待たない」表示、②閉じ処理はfinallyで保証――これだけで今回のハマりは解消します。
例外の可視化:まずはtry-catchで失敗原因を掴む
通信や認証は失敗要因が多いので、必ず例外を捕捉してユーザーにも開発者にも分かる形で可視化します。以下は簡潔で実用的なパターンです。
try
{
// ... ログイン処理
}
catch (OperationCanceledException)
{
await DisplayAlert("中断", "ネットワークが不安定か、ユーザーによりキャンセルされました。", "OK");
}
catch (TimeoutException)
{
await DisplayAlert("タイムアウト", "サーバーから応答がありません。後でもう一度お試しください。", "OK");
}
catch (AuthenticationException ex)
{
await DisplayAlert("認証失敗", $"メールまたはパスワードが正しくありません。\n{ex.Message}", "OK");
}
catch (Exception ex)
{
await DisplayAlert("エラー", $"想定外のエラーが発生しました。\n{ex.Message}", "OK");
}
エラーの粒度は、ユーザー向け(やさしい文言)とログ向け(詳細)を分けると保守しやすくなります。
UIスレッド保証:画面遷移とUI操作はMainThread/Dispatcherで
.NET MAUIでは、画面遷移やポップアップ操作などUI関連はUIスレッドで実行するのが鉄則です。非UIスレッドから呼ぶ可能性があるコードでは、次のようにラップしましょう。
// 画面遷移
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync("//ChatPage"));
// ポップアップ表示・クローズ
MainThread.BeginInvokeOnMainThread(() => Shell.Current.CurrentPage.ShowPopup(popup));
MainThread.BeginInvokeOnMainThread(() => popup.Close());
// DisplayAlert
await MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.DisplayAlert("タイトル", "本文", "OK"));
もしページ側にDispatcherがあるなら、Dispatcher.Dispatch/DispatchAsyncも有効です。
シェルのルート確認:遷移先が未登録だと当然進まない
GoToAsync("//ChatPage")が失敗する典型は、ルート未定義/誤定義です。AppShellで正しく登録されているかを再確認します。
<Shell
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:YourApp.Views"
x:Class="YourApp.AppShell">
<TabBar>
<Tab Title="Home">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate views:HomePage}" />
</Tab>
</TabBar>
<ShellContent
Route="ChatPage"
Title="Chat"
ContentTemplate="{DataTemplate views:ChatPage}" />
階層がある場合は、"//Home/ChatPage"など完全ルート指定に切り替えると迷子になりません。ログでShell.Current.GoToAsyncの戻り値(例外)も監視しましょう。
ローディング・ポップアップのUI:最小で伝わる・止められる・閉じ忘れない
見た目はシンプルで十分ですが、閉じ忘れない設計とバックグラウンドタップで閉じない設定が大切です。
<!-- LoadingPopup.xaml -->
<toolkit:Popup
x:Class="YourApp.Views.LoadingPopup"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
CanBeDismissedByTappingOutsideOfPopup="False">
<Border Padding="20" WidthRequest="220"
BackgroundColor="{AppThemeBinding Light=White, Dark=#1E1E1E}"
StrokeShape="RoundRectangle 16,16,16,16">
<VerticalStackLayout Spacing="12"
HorizontalOptions="Center"
VerticalOptions="Center">
<ActivityIndicator IsRunning="True"
WidthRequest="40" HeightRequest="40"/>
<Label Text="サインインしています..."
FontAttributes="Bold"
HorizontalOptions="Center"/>
</VerticalStackLayout>
</Border>
閉じ忘れ対策はコード側のtry/finallyが本命です。UIレイヤーは「見せるだけ」に徹しましょう。
キャンセル/タイムアウト:ユーザー体験の底上げ
ネットワーク事情に左右される処理では、キャンセルとタイムアウトがUXを大きく左右します。CancellationTokenとTask.WaitAsyncを組み合わせると扱いやすくなります。
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); // タイムアウト20秒
var user = await firebaseAuth
.SignInWithEmailAndPasswordAsync(email, password)
.WaitAsync(cts.Token); // .NET 6+
ユーザーに中断手段(「キャンセル」ボタン)を与えるなら、ボタンからcts.Cancel()を呼びます。キャンセル時はOperationCanceledExceptionを捕捉し、優しい文言で伝えてください。
MVVMパターンへの落とし込み(AsyncRelayCommand)
ボタン多連打や多重実行を避けるには、CommunityToolkit.MvvmのIAsyncRelayCommandが便利です。二重起動ガードとIsBusyバインディングでUIも連動させましょう。
public partial class LoginViewModel : ObservableObject
{
private readonly IFirebaseAuth _auth;
private readonly ILoginNavigator _navigator;
[ObservableProperty] private bool isBusy;
public IAsyncRelayCommand LoginCommand { get; }
public LoginViewModel(IFirebaseAuth auth, ILoginNavigator navigator)
{
_auth = auth;
_navigator = navigator;
LoginCommand = new AsyncRelayCommand(LoginAsync, CanLogin);
}
private bool CanLogin() => !IsBusy;
private async Task LoginAsync()
{
if (IsBusy) return;
IsBusy = true;
try
{
await _navigator.ShowLoadingAsync(); // 中でShowPopup(待たない)
var user = await _auth.SignInAsync(Email, Password);
await _navigator.NavigateToAsync("//ChatPage");
}
catch (Exception ex)
{
await _navigator.AlertAsync("エラー", ex.Message);
}
finally
{
await _navigator.HideLoadingAsync();
IsBusy = false;
LoginCommand.NotifyCanExecuteChanged();
}
}
}
ビュー側ではButtonのIsEnabledをIsBusyにバインドして多重押下を防止します。
チェックリスト(原因と対策のマッピング)
| 症状 | 考えられる原因 | 対策 |
|---|---|---|
| ポップアップ後に処理が進まない | ShowPopupAsyncをawaitしている | ShowPopupに変更/ShowPopupAsyncを待たない。閉じはfinallyで。 |
| ブレークポイントに到達しない | 上記により制御が返ってこない | 同上。ログで実行順を確認。 |
| 画面遷移に失敗 | UIスレッド外からGoToAsync/ルート未登録 | MainThread.InvokeOnMainThreadAsyncで遷移。AppShellのルート確認。 |
| ローディングが閉じない | 例外で閉じ処理に到達しない | try/finallyでpopup.Close()を保証。 |
| 通信がやたら長い | サーバー遅延/ネットワーク不良 | WaitAsync(TimeSpan)でタイムアウト、キャンセル提供。 |
| 複数回ログインが走る | ボタン連打・多重実行 | IsBusy制御、IAsyncRelayCommand、SemaphoreSlim。 |
堅牢化のテクニック(発生しがちな落とし穴を潰す)
多重実行ガード(SemaphoreSlim)
private readonly SemaphoreSlim _loginGate = new(1, 1);
public async Task LoginAsync(string email, string password)
{
if (!await _loginGate.WaitAsync(0)) return; // すでに実行中
try
{
// ログイン処理 ...
}
finally
{
_loginGate.Release();
}
}
「ページが表示される前に表示命令」問題への保険
ページ遷移直後にローディングを出す場合など、OnAppearingの初回で描画がまだ安定しないことがあります。そんな時はawait Task.Yield()で描画キューを一周させてから表示すると安定します。
protected override async void OnAppearing()
{
base.OnAppearing();
await Task.Yield(); // UI初期化完了を待つ
// ShowPopup(待たない) ...
}
ログの粒度を上げて原因を素早く特定
Debug.WriteLine("Show loading...");
Debug.WriteLine("Start SignIn");
var sw = Stopwatch.StartNew();
var user = await firebaseAuth.SignInWithEmailAndPasswordAsync(email, password);
Debug.WriteLine($"SignIn done in {sw.ElapsedMilliseconds}ms");
Debug.WriteLine("Navigate");
テスト容易性を高める抽象化
Firebase依存を直接ViewModelに書くとテストが難しくなります。IFirebaseAuthやILoginNavigatorのようなインターフェイスで抽象化し、遅延・失敗・キャンセルをモックで再現しましょう。
「正しいローディング」のリファレンス実装
ここまでの要点を統合した、実務投入できる実装例です。
public sealed class LoginService
{
private readonly IFirebaseAuth _auth;
public LoginService(IFirebaseAuth auth) => _auth = auth;
public async Task DoLoginAsync(string email, string password, CancellationToken ct = default)
{
var popup = new LoadingPopup();
try
{
// 1) 待たずに表示
MainThread.BeginInvokeOnMainThread(() => Shell.Current.CurrentPage.ShowPopup(popup));
// 2) タイムアウト + キャンセル
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
var user = await _auth.SignInWithEmailAndPasswordAsync(email, password).WaitAsync(linked.Token);
// 3) UIスレッドで安全に遷移
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync("//ChatPage"));
}
catch (OperationCanceledException)
{
await MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.DisplayAlert("中断", "処理が中断されました。通信状況をご確認ください。", "OK"));
}
catch (Exception ex)
{
await MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.DisplayAlert("エラー", $"ログインに失敗しました。\n{ex.Message}", "OK"));
}
finally
{
// 4) 必ず閉じる
MainThread.BeginInvokeOnMainThread(() => popup.Close());
}
}
}
よくある質問(FAQ)
Q. それでもShowPopupAsyncを使いたい。
A. 結果(値)をポップアップから受け取りたい時にのみ使い、その場でawaitして完結させます。ローディング用途では非同期版を待たない使い方にしましょう。
Q. DisplayAlertでUIが固まる。
A. 非UIスレッドから呼ぶと詰まりがちです。MainThread.InvokeOnMainThreadAsyncでラップしてください。
Q. 画面遷移だけが失敗する。
A. ルート未定義の可能性が高いです。Route="ChatPage"の定義と、//から始める絶対ルートを再確認しましょう。
Q. 「Close忘れ」を完全に防ぐには?
A. すべてのreturn経路をtry/finallyで囲むこと。例外時も必ずClose()する構造にしてください。
まとめ:ハマりの根を断つ設計指針
- ローディングは「待たない表示」:
ShowPopupまたはShowPopupAsyncを待たずに発火。 - 閉じる保証:
finallyでpopup.Close()。 - UIスレッド原則:遷移とダイアログは
MainThread/Dispatcherで。 - ルート健全性:
AppShellのRoute定義を必ず検証。 - 耐障害性:例外分類、キャンセル、タイムアウト、多重実行ガード。
これらをチームのコーディング規約に落とし込めば、今回のような「ポップアップは出るのに処理が動かない」事故は二度と起こりません。安全に、速く、そして気持ちよくユーザーを次の画面へ案内しましょう。
付録:改良版(ユーザー通知つきのサンプル)
public async Task SafeLoginAsync(string email, string password)
{
var popup = new LoadingPopup();
try
{
MainThread.BeginInvokeOnMainThread(() => Shell.Current.CurrentPage.ShowPopup(popup));
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var user = await firebaseAuth
.SignInWithEmailAndPasswordAsync(email, password)
.WaitAsync(cts.Token);
if (user is null)
{
await MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.DisplayAlert("ログイン失敗", "メールまたはパスワードが違います。", "OK"));
return;
}
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync("//ChatPage"));
}
catch (OperationCanceledException)
{
await MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.DisplayAlert("タイムアウト", "応答がありません。通信を確認して再試行してください。", "OK"));
}
catch (Exception ex)
{
await MainThread.InvokeOnMainThreadAsync(() =>
Shell.Current.CurrentPage.DisplayAlert("エラー", $"ログイン処理で例外が発生しました:\n{ex.Message}", "OK"));
}
finally
{
MainThread.BeginInvokeOnMainThread(() => popup.Close());
}
}
付録:軽量オーバーレイという選択肢
ローディングのためだけにポップアップを使わず、ページ内に半透明のオーバーレイ(Grid+ActivityIndicator)を常駐させ、IsVisibleで出し入れする方式も実践的です。モバイルではこちらのほうが自然なことも多く、ナビゲーションやフォーカスの奪い合いリスクが減ります。
<Grid>
<!-- 本来のコンテンツ -->
<ContentPresenter />
<!-- ローディングオーバーレイ -->
<Grid x:Name="LoadingOverlay"
BackgroundColor="#80000000"
IsVisible="{Binding IsBusy}"
InputTransparent="False">
<ActivityIndicator IsRunning="True"
HorizontalOptions="Center"
VerticalOptions="Center" />
</Grid>
この場合、コード側はIsBusy = true/falseを切り替えるだけで済み、Close()忘れの心配はなくなります。
実装前後の比較
| 項目 | 誤った実装 | 正しい実装 |
|---|---|---|
| ローディング表示 | await ShowPopupAsyncで待つ | ShowPopupまたは待たないShowPopupAsync |
| 閉じ処理 | 成功時のみClose() | finallyでClose()を保証 |
| UIスレッド | 任意のスレッドから操作 | MainThread/Dispatcherで操作 |
| エラー時のUX | 沈黙/ローディングが消えない | 明確なアラート、再試行誘導 |
| 多重実行 | ボタン連打で競合 | IsBusy/SemaphoreSlimで抑止 |
結論
本件のボトルネックは、ローディング用途でShowPopupAsyncをawaitしていたことに尽きます。ここを是正し、try/finallyで閉じ処理を保証、UIスレッド原則に従って遷移と通知を行えば、.NET MAUIの認証フローは堅牢かつ快適に動作します。あとは「ルート定義」「例外の可視化」「キャンセルとタイムアウト」「多重実行ガード」という良い習慣を積み重ねるだけ。今日からチームのベースに取り入れて、同種の不具合を未然に防ぎましょう。

コメント