iOS の .NET MAUI で CollectionView をグループ化し、GroupHeaderTemplate に StackLayout を使うと「項目が一部しか表示されない」「Pull‑to‑Refresh などの再読み込みでクラッシュする」という実害が発生します。この記事では原因をレイアウト計測の仕組みから解きほぐし、Grid への置換・ItemsLayout の明示・ItemsSource の再割り当て・UI 更新のメインスレッド化など、現場で再現性高く効く対処を“なぜそれで直るのか”とセットで解説します。
問題の全体像(症状・条件・誤動作パターン)
対象: iOS(Android では未発生/再現しにくい)
- 症状1:グループ内のアイテムが途中で途切れる(例:12 件あるのに 3 件しか描画されない)。スクロールしても表示が追従しない。
- 症状2:Pull‑to‑Refresh/データ再読み込み後にクラッシュ(例:Objective‑C 例外、
NSInternalInconsistencyException相当の落下)。
発生条件:
CollectionViewを グループ化している。GroupHeaderTemplateにStackLayoutを使用している。- グループ化を外すとクラッシュは発生しない(=ヘッダー再利用と測定がトリガ)。
よくある誤動作の流れ
- 可変高さの
StackLayoutヘッダーが iOS ハンドラで測定に失敗し、見積もり高さが不正(NaN/ゼロ/想定外)になる。 - 仮想化(リサイクル)レイアウトが誤推定したオフセットでアイテムを切り詰める。
- 刷新(Refresh)やデータ差し替えで再計測が走り、再利用されたヘッダーの 以前の測定結果 と 新しいコンテンツ の不整合が増幅。
- 内部アサーションに抵触→クラッシュ。
なぜ iOS だけで起きるのか:レイアウト計測の“厳格さ”
.NET MAUI の iOS 実装(Handler 経由で UICollectionView を利用)は、グループヘッダーを 再利用可能な補助ビュー(supplementary view)として扱います。補助ビューの高さはレイアウト前に 正当な数値 である必要があり、StackLayout のような“子の状態に依存して伸縮する”コンテナは、タイミングによって 未確定 の寸法を返すことがあります。
とくに以下の要因が重なると失敗確率が高まります。
- ヘッダー内部で
IsVisibleの切り替え、Triggers、DataTemplateの動的入れ替え。 - 行数の多い
Label(LineBreakModeやMaxLines指定なし)。 - 親子の
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 以降+最新 NuGet | 8 系の既知不具合を回避(ただし再現例あり) | アプリとワークロードを更新(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 更新をメインスレッドで統一する
バックグラウンドでデータ取得→そのままコレクションを更新、という流れは危険です。ObservableCollection の CollectionChanged は 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 の区切り線)を置いたとします。Label の MaxLines 未指定、VerticalOptions や Margins に依存する場合、測定時点での文字折返し結果が確定していないことがあります。
この「確定していない高さ」を抱えたまま 推定高さ を iOS に戻すと、UICollectionView 側は項目のオフセット(積み上げ)を誤ることがあり、結果として スクロール量と表示件数が噛み合わない 状態に落ちます。Refresh で再利用されたヘッダーが古い高さを保持していると、より致命的な不整合となりクラッシュに至ります。
Grid 化はこの不定性を 行の枠組み で囲い込み、最小限の要素(ラベル+1px 線)でも「高さが常に数値化可能」な状態にします。
アンチパターン(やりがちだが危険)
- ヘッダーで視覚効果を盛り込みすぎる:角丸・影・動的折り畳み・入れ子 Stack の多用は避ける。まずは単純化して安定性を優先。
- Refresh で既存コレクションに差分適用:
Clear→Addを大量発行するより、新インスタンスに差し替え の方が安全。 - 非 UI スレッドからコレクション更新:とくに iOS はシビア。メインスレッド徹底。
- ラベルの自動折り返し任せ:
LineBreakModeとMaxLinesを検討。ヘッダーに長文を置かない。
検証手順:Xcode Instruments で“再計測の失敗”を可視化する
- アプリを iOS 実機/シミュレータで起動。
- Xcode Instruments → Core Animation を選択。
- Color Offscreen‑Rendered や Color Hits Green and Misses Red を有効化し、ヘッダーの再描画領域を観察。
- Refresh 実行前後でヘッダーの領域が不自然に 0 または極小に変わる箇所があれば、測定失敗の可能性が高い。
あわせて iOS のログ(Objective‑C exception)に注目してください。UICollectionView の layoutAttributesForSupplementaryView 周辺で不整合が出ていれば今回の事象と整合します。
完全実装例(最小構成)
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 を使用。行/列/高さを明示。 | □ 未 / □ 済 |
| ItemsLayout | LinearItemsLayout 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 連鎖を避ける。
- 画像のサイズ固定:
ImageにWidthRequest/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 分版)
- グループヘッダーを Grid に置換(ラベル+水平線)。
CollectionView.ItemsLayoutを縦リニアで明示。- Refresh ロジックを null → 新インスタンス の差し替えに変更。
- ViewModel の更新を
MainThreadでラップ。 - グループ/内部リストを
ObservableCollectionに統一。 - .NET MAUI を 9 以降に引き上げ、NuGet を最新化。
この順で導入すれば、“表示が途中で切れる/Refresh で落ちる” という iOS 固有問題は止まります。根治の鍵は 高さを確定させる設計 と リサイクルと再計測の整合 です。

コメント