.NET MAUIのEntry UnfocusedでSaveCommentOnBlurが無限ループする原因と対処法(ListView再利用を回避)

.NET MAUI(.NET 8)でListView内のEntryにコメントを入力し、フォーカスが外れたタイミングで保存する設計にしたところ、Unfocusedが連発して保存処理が無限ループし、キーボードが固まる――そんな現象の原因と最短の回避策を整理します。

目次

起きていること:Unfocusedを契機に保存が連鎖してフリーズする

「入力欄から指を離した(別の場所をタップした)だけなのに、保存メソッドが何十回も呼ばれてアプリが固まる」。このタイプのトラブルは、保存処理そのものの重さだけでなく、UIの再利用とフォーカス遷移が噛み合ってイベントが雪だるま式に増えることで発生します。

  • EntryのUnfocused(フォーカス外れ)が何度も発火する
  • Unfocusedから呼ぶSaveCommentOnBlurが繰り返し実行される
  • 結果としてキーボードが固まる/画面操作が効かなくなる

再現しやすい条件として、ListView上に複数の入力欄(Entry)を並べている、スクロールを伴う、入力中に別行へ移動する、といったパターンがあります。

原因の本質:ListViewのセル再利用(リサイクル)とフォーカスイベントの相性が悪い

結論から言うと、今回の主因はListViewの「アイテム再利用(セルのリサイクル)」です。ListViewは表示パフォーマンスのために、画面外へ出たセルを破棄せず別アイテムとして使い回すことがあります。これが「入力中のEntryの状態」や「イベント発火タイミング」と衝突すると、Unfocusedが想定外に連発しやすくなります。

セル再利用が絡むと、次のような連鎖が起こります。

起点ListView内部で起こり得ること結果
ユーザーが別の場所をタップ入力中セルが再計測・再配置され、セルの見た目やBindingContextが切り替わることがあるEntryが「画面上から外れる」扱いになりUnfocusedが発火
Unfocusedで保存保存処理がプロパティ更新(Text/Binding)を誘発し、再度セルの更新や再レイアウトが走るさらにフォーカスが揺れてUnfocusedが再発火
スクロール/入力セルのリサイクルが頻発し、同じEntryが別アイテムに割り当てられる「別のEntryが勝手にUnfocused」→保存が連鎖

ここで重要なのは、あなたのコードが明示的にUnfocusedを複数回呼んでいるわけではなくても、ListViewがセルを使い回す過程で「Entryが一度ツリーから外れる」「見た目が差し替わる」などのイベントが重なり、結果としてUnfocusedが何度も飛ぶことがある点です。

ポイント:ListViewのセル再利用は「表示効率」を上げますが、入力欄のように状態とイベントが密接なUIでは、再利用が“予期しないフォーカス遷移”を生み、保存処理とループしやすくなります。

まず確認したい:無限ループを“増幅”する典型パターン

根本はセル再利用でも、実際のフリーズは「増幅要因」があると起きやすくなります。心当たりがあれば、対処の優先度が上がります。

増幅要因どう悪さをするか見分け方
Unfocused内でTextやBinding先の値を更新更新→レイアウト再計算→セル差し替え→Unfocused…の循環が起きやすい保存直後に画面がチラつく/別行の内容が一瞬変わる
保存処理が同期的で重いUIスレッドが詰まり、キーボード操作も巻き込んで固まる保存先がDB/ファイル/ネットワークで、awaitせず同期呼び出ししている
イベントの多重購読(付け外し漏れ)1回のUnfocusedが2回、3回…と増えるログを見ると「同一Entryで同一タイミングに複数回」実行される

今回の相談内容では「ListView上の入力欄で発生」「.NET 8」「入力/削除でUnfocusedが多発」という条件から、セル再利用に起因する可能性が非常に高いです。

最短の解決策:ListViewをやめて、再利用のないBindableLayoutに置き換える

最も確実で実装コストが低い回避策は、ListViewを使わない構成に切り替えることです。具体的には、StackLayoutのBindableLayout.ItemsSourceでアイテムを並べ、各行にMaterialEntry(カスタムEntry)を生成します。ListViewのようなセル再利用が発生しないため、Unfocusedが連鎖する土台を消せます。

サンプル:StackLayout + BindableLayout + ScrollView

アイテム数が少なければScrollViewは不要ですが、縦に長くなる場合はScrollViewで包みます。

<ScrollView>
  <StackLayout
      BindableLayout.ItemsSource="{Binding DocumentsList}"
      Spacing="12"
      Padding="16">

    <BindableLayout.ItemTemplate>
      <DataTemplate>
        <VerticalStackLayout Spacing="6">

          <Label Text="{Binding Title}" FontAttributes="Bold" />

          <controls:MaterialEntry
              Text="{Binding Comment}"
              Placeholder="コメントを入力"
              Unfocused="SaveCommentOnBlur" />

        </VerticalStackLayout>
      </DataTemplate>
    </BindableLayout.ItemTemplate>

  </StackLayout>
</ScrollView>

ここでは説明を分かりやすくするためにコードビハインドのイベントを例にしていますが、MVVMで運用している場合は、後述の「EventToCommand」や「Behavior」でViewModelへ渡す形にしてもOKです。

イベント側:再入防止と“差分保存”で安全にする

BindableLayoutへ置き換えるだけで改善するケースが多いですが、入力系は今後の保守を考えて再入防止(同時実行ガード)と差分チェックも入れておくと安心です。

private bool _isSaving;

private async void SaveCommentOnBlur(object sender, FocusEventArgs e)
{
    if (_isSaving) return;

    try
    {
        _isSaving = true;

        if (sender is not Entry entry) return;

        // 例:BindingContextがアイテム(Document)になっている想定
        if (entry.BindingContext is not DocumentModel item) return;

        // 差分が無ければ保存しない(無駄な更新を避ける)
        var current = entry.Text ?? string.Empty;
        if (string.Equals(item.Comment, current, StringComparison.Ordinal))
            return;

        item.Comment = current;

        // 例:DB保存など(UIスレッドを塞がないようawaitする)
        await _repository.SaveCommentAsync(item.Id, item.Comment);
    }
    finally
    {
        _isSaving = false;
    }
}

このように「保存が走る条件」を狭めると、万一Unfocusedが連続しても被害を最小化できます。

なぜBindableLayoutで直るのか

BindableLayout(StackLayoutなど)は、基本的にItemsSourceの各要素に対してUIを生成し、画面から消えても別要素として使い回す設計ではありません。つまり、ListView特有の“セルの再利用”が原因で起きるフォーカスの揺れを、構造的に排除できます。

言い換えると、今回のトラブルは「保存処理の書き方」よりも、入力欄を置くコンテナの選択が支配的だったケースです。

運用上の注意:BindableLayout + ScrollViewは全件分のUIを作る

BindableLayoutは分かりやすい反面、表示する全行のUIを一度に生成します。件数が増えるとメモリや初期表示速度に影響が出ます。導入前に「想定件数」と「端末性能」を踏まえて判断してください。

件数の目安BindableLayoutの体感おすすめ
〜20件程度ほぼ問題になりにくいBindableLayoutでシンプルに解決
20〜100件程度端末やテンプレート次第で重くなるUIを軽量化(行の要素を減らす)+差分保存
100件以上初期描画・スクロールが重くなりやすいCollectionViewの仮想化を検討

どうしてもListViewを変えられない場合の暫定策

画面構造や既存機能の都合で、すぐにBindableLayout/CollectionViewへ置き換えられないこともあります。その場合は、次の“応急処置”を組み合わせて被害を抑えます。ただし、根治ではなく、環境差で挙動が変わることがある点は理解しておきましょう。

暫定策狙い注意点
再入防止(フラグ/SemaphoreSlim)保存処理の多重実行を止める“保存し損ね”にならないよう、最後の入力値を優先する設計が必要
差分が無いなら保存しない無意味な更新で再レイアウトを誘発しないトリムや正規化(改行変換など)をする場合は比較方法を揃える
短時間の連続Unfocusedを無視(スロットリング)フォーカスが揺れても保存回数を制限するUX要件(すぐ保存したい等)と衝突する可能性
保存をUIイベントから分離(明示保存/デバウンス)Unfocused連発の影響を受けにくくする「いつ保存されるか」をユーザーに分かりやすくする工夫が要る

ListViewの再利用を完全にオフにできる設定が用意されている場合は、それを検討する価値もあります。ただし、再利用オフはメモリ消費やスクロール性能に影響するため、今回のように入力欄が主役の画面に限定して適用するのが現実的です。

大量データなら:CollectionViewで仮想化しつつ、保存トリガーを見直す

件数が多い画面では、仮想化が効くCollectionViewが候補になります。ただし、仮想化(再利用)がある以上、ListViewと同じ落とし穴にハマる可能性は残ります。そこで重要なのが、Unfocusedに保存を直結させない(またはガードを徹底する)という設計です。

コンテナ選定の比較

選択肢アイテム再利用入力欄(Entry)との相性パフォーマンス向いているケース
ListView設定次第で再利用あり保存トリガーをUnfocusedに置くと不安定になりやすい良い(ただし入力絡みは調整が必要)互換重視、既存資産が多い
BindableLayout + ScrollView基本的になし今回のような“保存ループ”を避けやすい件数が増えると重い少〜中件数で入力が主役
CollectionViewあり(仮想化)ガードや設計次第で安定させやすい良い中〜大件数、スクロールが主

おすすめの方針

  • 保存はUnfocusedだけに依存させず、「明示的な保存ボタン」や「一定時間入力が止まったら保存(デバウンス)」も併用する
  • Unfocusedで保存するなら、再入防止(SemaphoreSlimなど)と差分チェックを必須にする
  • セル再利用環境では、イベント購読の付け外し漏れを防ぐ(Behaviorを使う/OnBindingContextChangedで整理する)

デバウンス例:入力が止まったら保存(Unfocused連発の影響を弱める)

「フォーカスが外れた瞬間」にこだわりがない場合、入力のたびに保存せず、少し待ってから保存するだけでも劇的に安定することがあります。

private CancellationTokenSource? _debounceCts;

private async void Entry_TextChanged(object sender, TextChangedEventArgs e)
{
    _debounceCts?.Cancel();
    _debounceCts = new CancellationTokenSource();

    try
    {
        await Task.Delay(400, _debounceCts.Token); // 0.4秒入力が止まったら保存
        if (sender is not Entry entry) return;
        if (entry.BindingContext is not DocumentModel item) return;

        var current = entry.Text ?? string.Empty;
        if (string.Equals(item.Comment, current, StringComparison.Ordinal))
            return;

        item.Comment = current;
        await _repository.SaveCommentAsync(item.Id, item.Comment);
    }
    catch (TaskCanceledException)
    {
        // 次の入力が来たのでキャンセル
    }
}

この方式なら、Unfocusedの多発に引きずられにくく、保存回数も減らせます。

切り分けのコツ:ログで「何が保存を呼んでいるか」を可視化する

同じような症状でも、原因が「セル再利用によるフォーカス遷移」なのか「イベント多重購読」なのかで手当てが変わります。まずはUnfocusedの発火と保存の呼び出し回数を、ログで見える化してみてください。

private int _unfocusedCount;

private void Entry_Unfocused(object sender, FocusEventArgs e)
{
    _unfocusedCount++;
    if (sender is Entry entry)
    {
        System.Diagnostics.Debug.WriteLine(
            $"Unfocused #{_unfocusedCount}  Text='{entry.Text}'  Item='{entry.BindingContext}'");
    }
}

ログを見て、スクロールや画面更新のタイミングでUnfocusedが増えているなら、セル再利用由来の可能性が高いです。逆に「同じタイミングで同じログが複数回出る」なら、イベントの付け外し漏れも疑いましょう。

再発防止チェックリスト

  • 保存処理は必ずawaitし、UIスレッドをブロックしない
  • 差分が無いなら保存しない(Textをそのまま再セットしない)
  • 再入防止を入れる(連続Unfocusedでも保存が増殖しない)
  • 大量件数ならBindableLayoutではなくCollectionViewを検討する
  • 入力が主役の画面では「セル再利用」と「Unfocused保存」の組み合わせを避ける

まとめ:今回のケースで最優先すべきこと

  • Unfocusedの連発と保存ループは、ListViewのセル再利用が引き金になることがある
  • 最短の回避策は、ListViewをやめてBindableLayout(再利用なし)へ置き換える
  • ただしBindableLayoutは全件分UIを生成するため、件数が多い場合はCollectionView+保存設計(再入防止・差分保存・デバウンス)も検討

「Unfocusedで保存」は一見シンプルですが、仮想化(再利用)と組み合わせると想像以上に不安定になります。まずはListViewの再利用影響を排除し、安定した挙動を取り戻したうえで、件数や要件に合わせて最適化していくのが近道です。

この記事を書いた人

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

コメント

コメントする

目次