Start Debugging

高パフォーマンスな Xamarin.Forms ListView を MAUI CollectionView へ移行する

ListView からすでにパフォーマンスを絞り出していたアプリ向けの、Xamarin.Forms 5.0 ListView から .NET MAUI 11 CollectionView へのステップバイステップな移行ガイド。セルのリサイクル、仮想化、グルーピング、プルツーリフレッシュ、コンテキストアクション、選択、ItemsLayout、EmptyView、そして実アプリで踏みがちな落とし穴を扱います。

短く言うと、ListViewCollectionView に置き換え、プルツーリフレッシュが必要なら RefreshView でラップし、ContextActionsSwipeView に差し替え、MAUI ではセルリサイクル付き仮想化が唯一のモードなので HasUnevenRowsRowHeightCachingStrategy を捨て、選択は ItemTapped / ItemSelected から SelectionModeSelectionChangedCommand に書き直します。古い ListViewCachingStrategy="RecycleElement"HasUnevenRows="True"DataTemplateSelector でチューニングされていたなら、移行は概ね機械的です。.NET MAUI 11.0 GA で net11.0-android35.0net11.0-ios18.0net11.0-maccatalyst18.0 上でテスト済みです。リストが 5 から 10 個あるアプリで集中的に 1 から 3 日の作業を見込んでください。TableView 風のグルーピングやカスタムの ViewCell レンダラーに大きく依存しているならもう少しかかります。

Xamarin.Forms は 2024 年 5 月 1 日にサポートが終了し、MAUI 11 GA は 2025 年 11 月にリリースされ、Android のジャンク対策として CollectionView の修正の第 3 世代が含まれています。RecycleElementAndDataTemplate のような ListView の癖に合わせてアプリのパフォーマンスを手作業でチューニングしていたために移行を見送ってきたのなら、今こそアップグレードの価値があります。MAUI 11 の CollectionView は、ItemSizingStrategy.MeasureAllItems を切れば、ようやく Android 上で旧 Xamarin.Forms の ListView に匹敵するか、それを上回るようになりました。本稿はその層に向けたガイドです。

なぜ今移行するのか

何が壊れるのか

領域Xamarin.Forms ListViewMAUI CollectionView重大度
キャッシング戦略CachingStrategy="RetainElement" または RecycleElement常にリサイクルされる。プロパティは存在しない。
行の高さHasUnevenRowsRowHeightItemSizingStrategy="MeasureAllItems" または MeasureFirstItem
プルツーリフレッシュListView 上の IsPullToRefreshEnabledRefreshCommandRefreshView でラップ
セルの型ViewCellTextCellImageCell任意のレイアウトをルートにできる素の DataTemplate
タップ処理ItemTappedItemSelectedSelectionModeSelectionChangedSelectionChangedCommand
スワイプアクションContextActionsSwipeView
セパレーターSeparatorVisibilitySeparatorColorなし。テンプレートに BoxView を追加する。
ヘッダー / フッターHeaderFooter (オブジェクトまたはテンプレート)名前は同じだが、テンプレート / ビューとしてのみ
グルーピングIsGroupingEnabledGroupHeaderTemplateIsGroupedGroupHeaderTemplateGroupFooterTemplate
スクロールScrollTo(item, ScrollToPosition)ScrollTo(item, position, animate)
空状態兄弟ラベルを手作業でEmptyViewEmptyViewTemplate

痛い変更はプルツーリフレッシュと選択の 2 つです。それ以外はそのままのリネームか削除です。

事前チェックリスト

  1. .NET 11 SDK がインストール済みであること (dotnet --version11.0.x を返す)。MAUI 11 には .NET 11 SDK が必要です。それより古い SDK にはワークロードマニフェストがありません。
  2. MAUI ワークロードがインストール済みであること: dotnet workload install maui。古いインストールの上にアップグレードした場合は、まず dotnet workload repair を実行してください。
  3. Android SDK 35、iOS 18、Mac Catalyst 18 のプラットフォームが利用可能であること。Visual Studio 2022 17.13、または .NET MAUI 拡張機能 1.6 以上の Visual Studio Code が必要です。
  4. 捨ててよい git ブランチ。MAUI のビルドが実機で緑色になるまでは、main 上で Xamarin.Forms プロジェクトをコンパイルできる状態に保ってください。
  5. プロジェクト内のすべての ListView インスタンスのリスト。機能領域ごとにグループ化し、機能を 1 つずつ移行します。リリースサイクルが月次なら機能フラグの背後に隠してリリースしてください。
  6. プロジェクトに対して .NET Upgrade Assistant を一度実行し、csproj、名前空間、MauiProgram の変更を処理します。Upgrade Assistant は ListView の XAML までは書き換えてくれません。本稿の残りの部分が扱うのはまさにそこです。

移行手順

1. ListViewCollectionView に置き換える

最もシンプルなケースは、カスタムの DataTemplate を持つフラットなリストです。

<!-- Before. Xamarin.Forms 5.0 -->
<ListView ItemsSource="{Binding Articles}"
          CachingStrategy="RecycleElementAndDataTemplate"
          HasUnevenRows="True"
          SeparatorVisibility="None"
          ItemTapped="OnArticleTapped">
    <ListView.ItemTemplate>
        <DataTemplate>
            <ViewCell>
                <Grid Padding="12" RowDefinitions="Auto,Auto">
                    <Label Text="{Binding Title}" FontAttributes="Bold" />
                    <Label Grid.Row="1" Text="{Binding Excerpt}" />
                </Grid>
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>
<!-- After. .NET MAUI 11.0 -->
<CollectionView ItemsSource="{Binding Articles}"
                SelectionMode="Single"
                SelectionChangedCommand="{Binding OpenArticleCommand}"
                SelectionChangedCommandParameter="{Binding Source={RelativeSource Self}, Path=SelectedItem}">
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="vm:ArticleVm">
            <Grid Padding="12" RowDefinitions="Auto,Auto">
                <Label Text="{Binding Title}" FontAttributes="Bold" />
                <Label Grid.Row="1" Text="{Binding Excerpt}" />
            </Grid>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

変わった点は次のとおりです。

検証: Android 向けにビルドし (dotnet build -t:Run -f net11.0-android35.0)、ミドルレンジの端末で 1000 件のリストをスクロールします。Android Studio の Profile GPU Rendering ビューでフレームタイムが 16 ms 未満であるべきです。そうでない場合は、x:DataType が設定されていること、テンプレート内のバインディングが Source={x:Reference ...} を使っていないことを確認してください。

2. プルツーリフレッシュのために RefreshView でラップする

IsPullToRefreshEnabledIsRefreshingRefreshCommand はもうリストにはありません。これらは RefreshView 上にあり、スクロール可能な任意のコンテンツをラップします。

<!-- After. .NET MAUI 11.0 -->
<RefreshView IsRefreshing="{Binding IsRefreshing}"
             Command="{Binding RefreshCommand}">
    <CollectionView ItemsSource="{Binding Articles}"
                    SelectionMode="Single">
        <CollectionView.ItemTemplate>
            <DataTemplate x:DataType="vm:ArticleVm">
                <!-- as before -->
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>
</RefreshView>

RefreshView はラップしたスクロール可能要素がオフセット 0 のときにのみ発火するので、挙動は古い ListView と一致します。IsPullToRefreshEnabled はないので、無効にしたい場合は RefreshViewIsEnabled="False" を設定してください。

検証: オフセット 0 でリストを下に引きます。プラットフォームのスピナーが現れ、RefreshCommand がジェスチャーごとに正確に 1 回発火するはずです。

3. 選択を書き直す

Xamarin.Forms の ItemTapped イベントは SelectedItem が変わらないときでもタップごとに発火します。CollectionView はこれを明確に分けます。タップは選択であり、選択は SelectionChanged または SelectionChangedCommand を通して観測可能です。同じ項目に 2 回連続でタップしてナビゲートする挙動に依存していた場合は、毎回ナビゲーション後に SelectedItem を読んでクリアすることで選択状態をオプトアウトする必要があります。

// MainViewModel.cs, .NET MAUI 11.0, C# 14
[RelayCommand]
private async Task OpenArticleAsync(ArticleVm? selected)
{
    if (selected is null) return;
    await Shell.Current.GoToAsync(nameof(ArticleDetailPage),
        new Dictionary<string, object> { ["article"] = selected });
    SelectedArticle = null; // re-arm so the same row can fire next time
}

SelectionMode="None" プラスでテンプレート内の TapGestureRecognizer が、古い ItemTapped のセマンティクスに最も近い相当品です。理由がある場合のみ使ってください。プラットフォーム側が期待しているのは選択ベースのパターンであり、Windows ではアクセシビリティのフォーカスリングを無料で得られます。

検証: 項目をタップし、戻り、同じ項目をタップします。両方のタップで詳細ページが開くはずです。

4. ContextActionsSwipeView に置き換える

<!-- Before. Xamarin.Forms 5.0 -->
<ViewCell>
    <ViewCell.ContextActions>
        <MenuItem Text="Delete" IsDestructive="True"
                  Command="{Binding DeleteCommand}" />
    </ViewCell.ContextActions>
    <Grid>...</Grid>
</ViewCell>
<!-- After. .NET MAUI 11.0 -->
<SwipeView>
    <SwipeView.RightItems>
        <SwipeItems Mode="Execute">
            <SwipeItem Text="Delete"
                       BackgroundColor="Crimson"
                       Command="{Binding DeleteCommand}" />
        </SwipeItems>
    </SwipeView.RightItems>
    <Grid>...</Grid>
</SwipeView>

SwipeView はカスタム背景を持つ実物の左右スワイプです。IsDestructive は通常の BackgroundColor になります。Mode="Execute" はタップで自動的に閉じる古い挙動に対応し、Mode="Reveal" はメニューを開いたままにします。iOS の長押しでメニューを表示する機能はなくなりました。必要なら MAUI 11 の MenuFlyout (本リリースでの新機能) を使ってください。

検証: iOS と Android で左にスワイプします。delete をタップします。項目が削除されます。次の行で再度スワイプし、同時に開いているスワイプが常に 1 つだけであることを確認します。

5. グルーピングを切り替える

<!-- After. .NET MAUI 11.0 -->
<CollectionView ItemsSource="{Binding ArticlesByDay}"
                IsGrouped="True">
    <CollectionView.GroupHeaderTemplate>
        <DataTemplate x:DataType="vm:ArticleDayVm">
            <Label Text="{Binding Day, StringFormat='{0:D}'}"
                   FontAttributes="Bold"
                   Padding="12,8" />
        </DataTemplate>
    </CollectionView.GroupHeaderTemplate>
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="vm:ArticleVm">
            <!-- as before -->
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

変わるのは 2 点です。フラグは IsGroupingEnabled ではなく IsGrouped です。そしてソースは、各外側の項目自体が列挙可能なコレクションのコレクションでなければなりません。最もきれいなパターンは、List<TItem> から派生し Key を公開する Grouping<TKey, TItem> クラスです。これは Xamarin.Forms が使っていたのと同じパターンで、そのまま移植できます。

検証: 30 日分のグループ化されたリストをスクロールします。iOS ではグループヘッダーがデフォルトでグループの先頭に貼り付くはずです (iOS ネイティブの GroupHeadersStick="True" であり、MAUI のデフォルトもこれを反映しています)。

6. ItemsLayout を選ぶ

デフォルトは垂直の LinearItemsLayout で、これは ListView と一致します。1 行のグリッドに切り替えるには次のようにします。

<CollectionView ItemsSource="{Binding Photos}">
    <CollectionView.ItemsLayout>
        <GridItemsLayout Orientation="Vertical"
                         Span="3"
                         HorizontalItemSpacing="4"
                         VerticalItemSpacing="4" />
    </CollectionView.ItemsLayout>
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="vm:PhotoVm">
            <Image Source="{Binding ThumbnailUrl}" Aspect="AspectFill" />
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

Span="3" は固定の列数です。組み込みのアダプティブグリッドはありません。それが必要なら、ビヘイビアで利用可能な幅に対してセルをサイジングするか、サイズ変更時に再バインドします。

検証: 端末を回転します。グリッドはスクロール位置を失わずに再描画されるはずです。

7. EmptyView を追加する

<CollectionView ItemsSource="{Binding Articles}">
    <CollectionView.EmptyView>
        <VerticalStackLayout HorizontalOptions="Center"
                             VerticalOptions="Center" Spacing="8">
            <Label Text="No articles yet" FontSize="18" />
            <Button Text="Refresh" Command="{Binding RefreshCommand}" />
        </VerticalStackLayout>
    </CollectionView.EmptyView>
</CollectionView>

複数の空状態 (データなし vs エラー) には、EmptyView をプロパティにバインドし、DataTemplateSelector 付きの EmptyViewTemplate を使ってください。

検証: ソースをクリアします。empty ビューが表示されます。ソースを再充填します。フレームの重なりなしに empty ビューが消えます。

検証チェックリスト

すべてのリストを移行した後に行います。

ロールバック計画

これは実質的に一方向の移行です。プロジェクトが net11.0-* のターゲットフレームワークと MauiProgram.cs に乗ったあとに Xamarin.Forms に戻すには、古い csproj の形、Xamarin.Forms.Forms.Init、プラットフォーム固有のアプリエントリポイントを復元する必要があります。本稿の CollectionView 自体に関する内容は、プロジェクトのアップグレードと別に取り消せるものはありません。Xamarin.Forms ブランチを削除する前に、動作する MAUI ビルドを出荷する計画にしてください。

私たちが踏んだ落とし穴

関連記事

出典

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る