.NET MAUIのローディングポップアップで非同期処理が止まる原因と解決策|ShellナビゲーションとFirebase Authまで徹底解説

「ローディングを出してからログイン→画面遷移したいだけなのに、ポップアップは出るが処理がまったく進まない」――.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は完了しません。つまり、次のような流れが生じています。

  1. await ShowPopupAsync(loadingPopUp)を実行
  2. ポップアップが表示される
  3. ここでawaitがポップアップのクローズを待ち続ける
  4. しかし、Close()SignInWithEmailAndPasswordAsyncの後で呼ぶつもりだった
  5. 結果として、後続のコードは永遠に実行されない

したがって、ローディング用途では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を大きく左右します。CancellationTokenTask.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.MvvmIAsyncRelayCommandが便利です。二重起動ガード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();
    }
}


} 

ビュー側ではButtonIsEnabledIsBusyにバインドして多重押下を防止します。

チェックリスト(原因と対策のマッピング)

症状考えられる原因対策
ポップアップ後に処理が進まないShowPopupAsyncawaitしているShowPopupに変更/ShowPopupAsyncを待たない。閉じはfinallyで。
ブレークポイントに到達しない上記により制御が返ってこない同上。ログで実行順を確認。
画面遷移に失敗UIスレッド外からGoToAsync/ルート未登録MainThread.InvokeOnMainThreadAsyncで遷移。AppShellのルート確認。
ローディングが閉じない例外で閉じ処理に到達しないtry/finallypopup.Close()を保証。
通信がやたら長いサーバー遅延/ネットワーク不良WaitAsync(TimeSpan)でタイムアウト、キャンセル提供。
複数回ログインが走るボタン連打・多重実行IsBusy制御、IAsyncRelayCommandSemaphoreSlim

堅牢化のテクニック(発生しがちな落とし穴を潰す)

多重実行ガード(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に書くとテストが難しくなります。IFirebaseAuthILoginNavigatorのようなインターフェイスで抽象化し、遅延・失敗・キャンセルをモックで再現しましょう。

「正しいローディング」のリファレンス実装

ここまでの要点を統合した、実務投入できる実装例です。

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を待たずに発火。
  • 閉じる保証finallypopup.Close()
  • UIスレッド原則:遷移とダイアログはMainThread/Dispatcherで。
  • ルート健全性AppShellRoute定義を必ず検証。
  • 耐障害性:例外分類、キャンセル、タイムアウト、多重実行ガード。

これらをチームのコーディング規約に落とし込めば、今回のような「ポップアップは出るのに処理が動かない」事故は二度と起こりません。安全に、速く、そして気持ちよくユーザーを次の画面へ案内しましょう。

付録:改良版(ユーザー通知つきのサンプル)

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());
}


} 

付録:軽量オーバーレイという選択肢

ローディングのためだけにポップアップを使わず、ページ内に半透明のオーバーレイ(GridActivityIndicator)を常駐させ、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()finallyClose()を保証
UIスレッド任意のスレッドから操作MainThread/Dispatcherで操作
エラー時のUX沈黙/ローディングが消えない明確なアラート、再試行誘導
多重実行ボタン連打で競合IsBusySemaphoreSlimで抑止

結論

本件のボトルネックは、ローディング用途でShowPopupAsyncawaitしていたことに尽きます。ここを是正し、try/finallyで閉じ処理を保証、UIスレッド原則に従って遷移と通知を行えば、.NET MAUIの認証フローは堅牢かつ快適に動作します。あとは「ルート定義」「例外の可視化」「キャンセルとタイムアウト」「多重実行ガード」という良い習慣を積み重ねるだけ。今日からチームのベースに取り入れて、同種の不具合を未然に防ぎましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次