.NET MAUI/iOS の CollectionView で GroupHeaderTemplate(StackLayout)が原因でリストが一部しか表示されずクラッシュする問題の完全対処ガイド

iOS の .NET MAUI で CollectionView をグループ化し、GroupHeaderTemplateStackLayout を使うと「項目が一部しか表示されない」「Pull‑to‑Refresh などの再読み込みでクラッシュする」という実害が発生します。この記事では原因をレイアウト計測の仕組みから解きほぐし、Grid への置換・ItemsLayout の明示・ItemsSource の再割り当て・UI 更新のメインスレッド化など、現場で再現性高く効く対処を“なぜそれで直るのか”とセットで解説します。

目次

問題の全体像(症状・条件・誤動作パターン)

対象: iOS(Android では未発生/再現しにくい)

  • 症状1:グループ内のアイテムが途中で途切れる(例:12 件あるのに 3 件しか描画されない)。スクロールしても表示が追従しない。
  • 症状2:Pull‑to‑Refresh/データ再読み込み後にクラッシュ(例:Objective‑C 例外、NSInternalInconsistencyException 相当の落下)。

発生条件:

  • CollectionViewグループ化している。
  • GroupHeaderTemplateStackLayout を使用している。
  • グループ化を外すとクラッシュは発生しない(=ヘッダー再利用と測定がトリガ)。

よくある誤動作の流れ

  1. 可変高さの StackLayout ヘッダーが iOS ハンドラで測定に失敗し、見積もり高さが不正(NaN/ゼロ/想定外)になる。
  2. 仮想化(リサイクル)レイアウトが誤推定したオフセットでアイテムを切り詰める。
  3. 刷新(Refresh)やデータ差し替えで再計測が走り、再利用されたヘッダーの 以前の測定結果新しいコンテンツ の不整合が増幅。
  4. 内部アサーションに抵触→クラッシュ。

なぜ iOS だけで起きるのか:レイアウト計測の“厳格さ”

.NET MAUI の iOS 実装(Handler 経由で UICollectionView を利用)は、グループヘッダーを 再利用可能な補助ビューsupplementary view)として扱います。補助ビューの高さはレイアウト前に 正当な数値 である必要があり、StackLayout のような“子の状態に依存して伸縮する”コンテナは、タイミングによって 未確定 の寸法を返すことがあります。

とくに以下の要因が重なると失敗確率が高まります。

  • ヘッダー内部で IsVisible の切り替え、TriggersDataTemplate の動的入れ替え。
  • 行数の多い LabelLineBreakModeMaxLines 指定なし)。
  • 親子の Margin/Padding の合成で実高が “仕様上は Auto” だが数値化できない瞬間がある。

このとき iOS 側は 測定不能=高さ 0 or 異常 と見なし、仮想化のオフセット演算が崩れて「見えている件数が少ない」「再読み込みで落ちる」という実害へつながります。

結論先取り:これで直る(対策まとめ)

まずは以下の 6 点を上から順に適用してください。多くの現場で ①+② だけで収束します。

対応策効果実装ポイント
① GroupHeaderTemplate を Grid に変更ヘッダーの高さが安定し計測失敗を回避<Grid Padding="10">...</Grid>。必要なら RowDefinitions で構造を明示。
② ItemsLayout を明示的に指定レイアウトの不確定要素を排除<CollectionView.ItemsLayout><LinearItemsLayout Orientation="Vertical"/></CollectionView.ItemsLayout>
③ Refresh 時に ItemsSource を入れ直す古いインスタンスの再利用を防止ItemsSource=null→新インスタンスを割り当て(ObservableCollection 推奨)。
④ UI 更新は必ず MainThreadスレッド競合による破綻を抑止MainThread.BeginInvokeOnMainThread(() => ...) でバインド対象の差し替えも含めて実行。
⑤ 内部リストも ObservableCollection に統一差分通知漏れを防止し UI 一貫性を確保グループ型も ObservableCollection<T> を継承して実装。
⑥ .NET MAUI 9 以降+最新 NuGet8 系の既知不具合を回避(ただし再現例あり)アプリとワークロードを更新(VS または dotnet CLI)。

最小修正例:ヘッダーを Grid に変える

以下はヘッダー部のみを差し替えた例です。構造が明示された Grid単純な Label+仕切り線 にするだけで、ほとんどの現場で “途中で切れる” 症状が消えます。

<CollectionView.GroupHeaderTemplate>
  <DataTemplate>
    <Grid Padding="10">
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="1" />
      </Grid.RowDefinitions>


  <Label
    Grid.Row="0"
    Text="{Binding GroupName}"
    FontAttributes="Bold"
    TextColor="Black"
    Style="{StaticResource FriendNameLabelStyle}" />

  <BoxView
    Grid.Row="1"
    HeightRequest="1"
    Color="#e6e6e6"
    VerticalOptions="End"
    HorizontalOptions="Fill" />
</Grid>



ポイントは 行の定義仕切り線の固定高StackLayout だと子の表示/非表示や Margin の合成次第で実高が確定しないのに対し、Grid は行・列で “ここまでがヘッダー” を明示できます。

ItemsLayout を縦リニアに固定

iOS の推定を安定化させるため、ItemsLayout は明示します。グループ化でも 縦方向の LinearItemsLayout を固定にしてください。

<CollectionView
  x:Name="friendListView"
  IsGrouped="True"
  SelectionMode="None">







この指定により、セル・ヘッダー双方の 縦方向の伸縮 が iOS にとって予測可能になり、バウンディングが正しく計算されます。

Refresh でクラッシュする場合の作法(ItemsSource の再割り当て)

再読み込みで落ちる場合、ItemsSource同じ参照 を再利用していないかを疑ってください。リサイクルされたヘッダーが古い測定結果を握ったまま、新しいデータに対して再利用されると破綻します。一度 null を入れてから新インスタンスを割り当てるのが安全です。

// RefreshCommand から実行される想定
async Task ReloadAsync()
{
    try
    {
        IsRefreshing = true;


    var list = await FriendService.LoadGroupedAsync();

    // UI 更新は必ずメインスレッドで
    MainThread.BeginInvokeOnMainThread(() =>
    {
        // ① 一旦切り離す
        friendListView.ItemsSource = null;

        // ② 新しい ObservableCollection を割り当て
        friendListView.ItemsSource =
            new ObservableCollection<GroupedFriends>(list);
    });
}
finally
{
    IsRefreshing = false;
}


}

MVVM で ItemsSource を ViewModel のプロパティにバインドしているなら、set 側で OnPropertyChanged() を確実に通知し、別インスタンス をセットするようにしてください。

UI 更新をメインスレッドで統一する

バックグラウンドでデータ取得→そのままコレクションを更新、という流れは危険です。ObservableCollectionCollectionChanged は UI スレッドで発火する前提でハンドラが実装されるケースが多く、iOS 側の内部不変条件と衝突しがちです。

MainThread.BeginInvokeOnMainThread(() =>
{
    // 既存コレクションに対して Add/Remove を連打するのではなく、
    // なるべく新しいコレクションを丸ごと差し替える。
    GroupedFriends = new ObservableCollection<GroupedFriends>(fresh);
});

コレクション実装:グループも ObservableCollection に

内部のグループ型やアイテム列が List<T> のままだと、差分通知が発生せず再測定が遅延・欠落することがあります。グループ自体を ObservableCollection とし、グループ内のリストObservableCollection<T> に統一してください。

public class GroupedFriends : ObservableCollection<UserFriendHBList>
{
    public string GroupName { get; }


public GroupedFriends(string groupName, IEnumerable<UserFriendHBList> items)
    : base(items)
{
    GroupName = groupName;
}


}

この構成により、CollectionChanged がヘッダーとアイテム双方に適切に伝播し、iOS 側の再測定タイミングとも整合します。

.NET MAUI のバージョンを上げる(ただし万能ではない)

.NET MAUI 9 ではコレクション系の安定化修正が複数入っており、8 系で再現した不具合が沈静化するケースが多いです。ただし、複雑なヘッダー構造+仮想化+リサイクル が絡むパスでは依然として再現報告があります。バージョンを上げつつも本稿の作法(Grid 化など)を併用するのが現実解です。

より深掘り:StackLayout ヘッダーが招く計測失敗のメカニズム

StackLayout は“縦(または横)に積む”というシンプルなモデルですが、Auto未確定 の概念が入りやすいレイアウトです。例えば、上部に Label、下部に BoxView(1px の区切り線)を置いたとします。LabelMaxLines 未指定、VerticalOptionsMargins に依存する場合、測定時点での文字折返し結果が確定していないことがあります。

この「確定していない高さ」を抱えたまま 推定高さ を iOS に戻すと、UICollectionView 側は項目のオフセット(積み上げ)を誤ることがあり、結果として スクロール量と表示件数が噛み合わない 状態に落ちます。Refresh で再利用されたヘッダーが古い高さを保持していると、より致命的な不整合となりクラッシュに至ります。

Grid 化はこの不定性を 行の枠組み で囲い込み、最小限の要素(ラベル+1px 線)でも「高さが常に数値化可能」な状態にします。

アンチパターン(やりがちだが危険)

  • ヘッダーで視覚効果を盛り込みすぎる:角丸・影・動的折り畳み・入れ子 Stack の多用は避ける。まずは単純化して安定性を優先。
  • Refresh で既存コレクションに差分適用:ClearAdd を大量発行するより、新インスタンスに差し替え の方が安全。
  • 非 UI スレッドからコレクション更新:とくに iOS はシビア。メインスレッド徹底。
  • ラベルの自動折り返し任せ:LineBreakModeMaxLines を検討。ヘッダーに長文を置かない。

検証手順:Xcode Instruments で“再計測の失敗”を可視化する

  1. アプリを iOS 実機/シミュレータで起動。
  2. Xcode Instruments → Core Animation を選択。
  3. Color Offscreen‑RenderedColor Hits Green and Misses Red を有効化し、ヘッダーの再描画領域を観察。
  4. Refresh 実行前後でヘッダーの領域が不自然に 0 または極小に変わる箇所があれば、測定失敗の可能性が高い。

あわせて iOS のログ(Objective‑C exception)に注目してください。UICollectionViewlayoutAttributesForSupplementaryView 周辺で不整合が出ていれば今回の事象と整合します。

完全実装例(最小構成)

XAML(ページ)

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="SampleApp.FriendsPage">




<CollectionView x:Name="friendListView"
                ItemsSource="{Binding GroupedFriends}"
                IsGrouped="True"
                SelectionMode="None">

  <CollectionView.ItemsLayout>
    <LinearItemsLayout Orientation="Vertical"/>
  </CollectionView.ItemsLayout>

  <CollectionView.GroupHeaderTemplate>
    <DataTemplate>
      <Grid Padding="10">
        <Grid.RowDefinitions>
          <RowDefinition Height="Auto"/>
          <RowDefinition Height="1"/>
        </Grid.RowDefinitions>

        <Label Grid.Row="0"
               Text="{Binding GroupName}"
               FontAttributes="Bold"/>

        <BoxView Grid.Row="1"
                 HeightRequest="1"
                 Color="#e6e6e6"/>
      </Grid>
    </DataTemplate>
  </CollectionView.GroupHeaderTemplate>

  <CollectionView.ItemTemplate>
    <DataTemplate>
      <Grid Padding="10">
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="Auto"/>
          <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>

        <Image Grid.Column="0"
               WidthRequest="40" HeightRequest="40"
               Aspect="AspectFill"
               Source="{Binding Avatar}" />

        <Label Grid.Column="1"
               Text="{Binding Name}"
               VerticalOptions="Center"/>
      </Grid>
    </DataTemplate>
  </CollectionView.ItemTemplate>

</CollectionView>



ViewModel

public class FriendsViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;


bool _isRefreshing;
public bool IsRefreshing
{
    get => _isRefreshing;
    set { _isRefreshing = value; OnPropertyChanged(); }
}

ObservableCollection<GroupedFriends> _groupedFriends;
public ObservableCollection<GroupedFriends> GroupedFriends
{
    get => _groupedFriends;
    set { _groupedFriends = value; OnPropertyChanged(); }
}

public ICommand RefreshCommand { get; }

public FriendsViewModel()
{
    GroupedFriends = new ObservableCollection<GroupedFriends>(BuildInitial());
    RefreshCommand = new Command(async () => await ReloadAsync());
}

async Task ReloadAsync()
{
    try
    {
        IsRefreshing = true;
        var fresh = await FriendService.LoadGroupedAsync();

        MainThread.BeginInvokeOnMainThread(() =>
        {
            // ItemsSource 再割り当て(安定策)
            GroupedFriends = new ObservableCollection<GroupedFriends>(fresh);
        });
    }
    finally
    {
        IsRefreshing = false;
    }
}

void OnPropertyChanged([CallerMemberName] string name = null) =>
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

IEnumerable<GroupedFriends> BuildInitial() => FriendService.LoadGroupedSync();


}

チェックリスト(配布用)

項目確認内容対応状態
ヘッダーのレイアウトGrid を使用。行/列/高さを明示。□ 未 / □ 済
ItemsLayoutLinearItemsLayout Orientation="Vertical" を明示。□ 未 / □ 済
Refresh 処理ItemsSource=null→新インスタンス割り当て。□ 未 / □ 済
スレッドUI 更新は MainThread で。□ 未 / □ 済
コレクション型グループ/内部リストとも ObservableCollection□ 未 / □ 済
ラベル設定MaxLines / LineBreakMode を検討。□ 未 / □ 済
MAUI バージョン.NET MAUI 9 以降+最新 NuGet。□ 未 / □ 済

テスト観点(落ちないことを証明する)

  • 縦スクロール連打:最上部↔最下部を素早く往復し、表示件数が途切れないか。
  • Pull‑to‑Refresh:連続 10 回実施。クラッシュ/チラつき/ヘッダー高さの瞬間ゼロ化が起きないか。
  • グループ数×アイテム数の極端化:グループ 1/100、アイテム 1/100 など端を攻める。
  • ヘッダー文言の長文化:最大文言で折り返しが安定するか(Grid であれば OK)。
  • ダークモード:描画更新(tint/背景)で高さが変わらないか。

パフォーマンス最適化(副作用として効くことが多い)

  • テンプレートの軽量化:ヘッダー・セル双方でバインディング式を減らし、ValueConverter 連鎖を避ける。
  • 画像のサイズ固定:ImageWidthRequest/HeightRequest を与え、測定を高速化。
  • トランジション効果の無効化:ヘッダーにアニメーションを置かない。必要ならセル側に限定。

FAQ(現場で多い質問)

Q. Android では平気なのに、なぜ iOS だけ壊れる?

A. Android の RecyclerView は高さの推定に寛容で、未確定でも実測で帳尻を合わせるパスが多い一方、iOS は補助ビューの寸法整合性に厳格です。結果として iOS で顕在化します。

Q. どうしても StackLayout を使いたい場合は?

A. 最低限 HeightRequest を与える、MaxLines を設定して折返しの上限を固定する、といった強制的な上限で安定化を図れますが、Grid への置換が第一選択です。

Q. DataTemplateSelector でグループごとのヘッダーを変えたい。

A. 可能です。ただし各テンプレートで Grid 構造の一貫性(最低 1 行+仕切り 1 行など)を保ってください。テンプレートごとに計測モデルが変わると、再利用時の不整合が増えます。

Q. セル側(ItemTemplate)が原因のことは?

A. あります。セル内で StackLayout が多段にネストし、IsVisible 切り替えを多用すると同種の不具合に波及します。セルも Grid ベース を推奨します。

デバッグの実例ログ(参考)

Objective-C exception thrown. Name: NSInternalInconsistencyException Reason: invalid layoutAttributesForSupplementaryView:atIndexPath...
...
stacktrace: UICollectionView ... layoutAttributesForElementsInRect ...

このパターンは 補助ビュー(=グループヘッダー) の属性(位置・サイズ)が不整合であることを示唆します。ヘッダーの Grid 化と ItemsSource 再割り当てで沈静化するのが通例です。

実運用のベストプラクティス

  • リリース前に“極端ケース”の UI テストを必須化:長文ヘッダー/大量スクロール/高速 Refresh。
  • テンプレートの設計規約:ヘッダーは Grid、セルも原則 Grid、StackLayout は小さなブロックに限定。
  • 障害が出たらまずヘッダーを抜く:グループ化を外し、症状が消えるかで切り分け。
  • 更新順序の固定:データ更新→UI 差し替え→状態フラグ更新の順で、MainThread にまとめる。

サンプル:リフレッシュの正しい流れ(MVVM)

public ICommand RefreshCommand => new AsyncCommand(RefreshAsync);

async Task RefreshAsync()
{
IsRefreshing = true;
var data = await repository.GetGroupedAsync();


MainThread.BeginInvokeOnMainThread(() =>
{
    // 参照を切り替えて再測定を促す
    GroupedFriends = new ObservableCollection<GroupedFriends>(data);
});

IsRefreshing = false;


}

まとめ

iOS の CollectionView で「グループ化+StackLayout ヘッダー」は、測定不能/未確定高さのために 仮想化が破綻しやすい危険な組み合わせです。ヘッダーを Grid 化し、ItemsLayout を縦リニアに固定Refresh 時は ItemsSource を再割り当てUI 更新は MainThread に寄せる――この 4 本柱で、途中で切れる再読み込みで落ちる という実害はほぼ解消できます。さらに .NET MAUI 9 以降への更新と、ObservableCollection の徹底で安定度を底上げしましょう。

付録:原因・対策早見表

現象主因最短解備考
アイテムが 3 件までしか出ないヘッダー高さの推定が 0/異常ヘッダーを Grid 化仕切り線は HeightRequest="1" で固定
Pull‑to‑Refresh でクラッシュリサイクルと再計測の不整合ItemsSource を新インスタンスに差し替えnull → 新インスタンスが安全
スクロールでチラつく未確定高さ/再描画過多ItemsLayout を明示縦リニア固定で推定を単純化
再現がランダムバックグラウンド更新MainThread に統一非 UI スレッド更新は避ける

最後に:現場導入の手順(5 分版)

  1. グループヘッダーを Grid に置換(ラベル+水平線)。
  2. CollectionView.ItemsLayout を縦リニアで明示。
  3. Refresh ロジックを null → 新インスタンス の差し替えに変更。
  4. ViewModel の更新を MainThread でラップ。
  5. グループ/内部リストを ObservableCollection に統一。
  6. .NET MAUI を 9 以降に引き上げ、NuGet を最新化。

この順で導入すれば、“表示が途中で切れる/Refresh で落ちる” という iOS 固有問題は止まります。根治の鍵は 高さを確定させる設計リサイクルと再計測の整合 です。

この記事を書いた人

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

コメント

コメントする

目次