WinUI 3 ListViewで行背景色を交互にする方法(ゼブラ表示)|Windows App SDKのDataTemplateSelector活用

Windows App SDK(WinUI 3)のListViewで行の背景色を交互に変える「ゼブラ表示」は、一覧の可読性を上げる定番テクニックです。この記事ではDataTemplateSelectorを使った実装を軸に、インデックス判定の落とし穴と選択ハイライトを崩さない実運用のコツまで整理します。

目次

WinUI 3のListViewで「行の背景色を交互にしたい」と感じる場面

管理ツール、ログビューア、設定画面など、WinUI 3(Windows App SDK)で作るデスクトップアプリは「縦に長い一覧」を扱うことが多いです。行数が増えるほど視線が横に流れやすくなり、どの行を読んでいるのか見失いがちになります。

そこで役に立つのが、1行ごとに薄い背景色を切り替えるゼブラ表示です。区切り線を増やすよりも情報量を保ったまま読みやすさを上げやすく、行高さが可変でも効果が出ます。

ListViewの見た目は「コンテンツ」と「コンテナ」で決まる

WinUI 3のListViewは、各データ項目(ItemsSourceの1要素)を直接描画するのではなく、まずListViewItem(アイテムコンテナ)を自動生成し、その中にItemTemplateの内容(行の中身)を配置します。ゼブラ表示は「行の背景色」を変えたいので、コンテンツ側(DataTemplate)で背景を描くのか、コンテナ側(ListViewItem)に背景を持たせるのかで、挙動やハマりどころが変わります。

変更する層触るプロパティ/仕組み特徴
コンテンツ(行の中身)ItemTemplate / ItemTemplateSelector(DataTemplateSelector)背景を付けると選択ハイライトを隠しやすい。2テンプレートで実装しやすい
コンテナ(ListViewItem)ItemContainerStyle / ItemContainerStyleSelector(StyleSelector)標準の選択/ホバーの視覚効果と両立しやすい。ItemTemplateを1つに保ちやすい

結論:DataTemplateSelectorで偶数行/奇数行のテンプレートを切り替える

WinUI 3のListViewには「AlternatingRowBackground」のような専用プロパティが用意されていないため、一般的にはテンプレート選択の仕組みを使って実現します。最もシンプルで分かりやすいのは、DataTemplateSelectorで偶数行/奇数行のDataTemplateを返す方法です。

ざっくり言うと、以下の流れでゼブラ表示を作れます。

  • 背景色が違うDataTemplateを2つ用意する(偶数行用/奇数行用)
  • DataTemplateSelectorを継承したクラスで、行インデックスの偶奇に応じてテンプレートを返す
  • ListViewのItemTemplateSelectorにそのSelectorを設定する

まずは最小構成で実装してみる

サンプル用のデータモデル(例)

ここでは分かりやすいように、表示用のモデルを用意した例で進めます(実際はViewModelでもDTOでも構いません)。

public sealed class UserRow
{
    public int Id { get; init; }
    public string Name { get; init; } = "";
    public string UpdatedAt { get; init; } = "";
}

DataTemplate(偶数/奇数)を2つ定義する

まずは「背景色だけ違う」DataTemplateを2つ用意します。テーマ(ライト/ダーク)に追従させたいので、固定色よりもThemeResourceのBrushを使うのがおすすめです。

<Page.Resources>
    <!-- 偶数行 -->
    <DataTemplate x:Key="RowEvenTemplate" x:DataType="local:UserRow">
        <Border Background="{ThemeResource SystemControlBackgroundBaseLowBrush}" Padding="10,6">
            <Grid ColumnDefinitions="120,*,160">
                <TextBlock Text="{x:Bind Id}" />
                <TextBlock Grid.Column="1" Text="{x:Bind Name}" />
                <TextBlock Grid.Column="2" Text="{x:Bind UpdatedAt}" HorizontalAlignment="Right" />
            </Grid>
        </Border>
    </DataTemplate>

    <!-- 奇数行 -->
    <DataTemplate x:Key="RowOddTemplate" x:DataType="local:UserRow">
        <Border Background="{ThemeResource SystemControlBackgroundAltHighBrush}" Padding="10,6">
            <Grid ColumnDefinitions="120,*,160">
                <TextBlock Text="{x:Bind Id}" />
                <TextBlock Grid.Column="1" Text="{x:Bind Name}" />
                <TextBlock Grid.Column="2" Text="{x:Bind UpdatedAt}" HorizontalAlignment="Right" />
            </Grid>
        </Border>
    </DataTemplate>
</Page.Resources>

この時点でレイアウトが重複する点が気になるかもしれません。後半で「重複を減らす設計」と「選択ハイライトを壊しにくい方法」を紹介しますが、まずは仕組みを理解するために2テンプレートで進めます。

DataTemplateSelectorを作る

次に、偶数行/奇数行のテンプレートを切り替えるSelectorを作ります。ポイントは「アイテムの値」ではなく並び順(インデックス)で判定することです。

WinUI 3では、コンテナ(ListViewItem)から所有元のItemsControlを取得し、IndexFromContainerで行インデックスを取れます。これを使うと、IndexOfで線形探索するよりもスッキリします。

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace YourApp.Selectors;

public sealed class ZebraRowTemplateSelector : DataTemplateSelector
{
    public DataTemplate? EvenTemplate { get; set; }
    public DataTemplate? OddTemplate { get; set; }

    protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
    {
        // ListViewItem(コンテナ)から親のListView(ItemsControl)を辿り、インデックスを取得する
        var owner = ItemsControl.ItemsControlFromItemContainer(container);
        int index = owner?.IndexFromContainer(container) ?? -1;

        // 取得に失敗した場合は偶数扱いにしておく(安全側のフォールバック)
        if (index < 0)
        {
            return EvenTemplate ?? base.SelectTemplateCore(item, container);
        }

        return (index % 2 == 0)
            ? (EvenTemplate ?? base.SelectTemplateCore(item, container))
            : (OddTemplate ?? base.SelectTemplateCore(item, container));
    }
}

ListViewにItemTemplateSelectorとして設定する

最後に、ListViewのItemTemplateSelectorへ設定します。

<Page.Resources>
    <DataTemplate x:Key="RowEvenTemplate" x:DataType="local:UserRow">...</DataTemplate>
    <DataTemplate x:Key="RowOddTemplate" x:DataType="local:UserRow">...</DataTemplate>

    <local:ZebraRowTemplateSelector x:Key="ZebraRowTemplateSelector"
        EvenTemplate="{StaticResource RowEvenTemplate}"
        OddTemplate="{StaticResource RowOddTemplate}" />
</Page.Resources>

<ListView
    ItemsSource="{x:Bind ViewModel.Rows}"
    ItemTemplateSelector="{StaticResource ZebraRowTemplateSelector}"
    SelectionMode="Single" />

これでゼブラ表示自体は完成です。以降は、実運用で「見た目が崩れる」「並び替えでズレる」「選択ハイライトが消える」など、よくあるハマりどころを潰していきます。

「item.ToString()で偶奇判定」が危険な理由

サンプルコードの中には、item.ToString()を数値に変換して偶奇判定する例があります。しかしこれは、アイテムが「1,2,3,4…」のような行番号そのものを表すデータである前提に強く依存します。実アプリでは、表示するデータが文字列だったり、並び替え・フィルタで順序が変わったり、同じ値が複数行に現れたりします。

ゼブラ表示の判定に必要なのは「データの値」ではなく表示上の行の並び順です。つまり、安定して使えるのは以下のどれかです。

インデックス取得方法メリットデメリット/注意点おすすめ度
itemの値を数値化して判定実装が最短「値=行番号」前提で壊れやすい。並び替え/重複/文字列で破綻低
ItemsSourceでIndexOf(item)どのSelectorでも使いやすい行数が多いと線形探索になりやすい。フィルタ/ビューを挟むと参照先に注意中
containerからIndexFromContainer「表示順」を直接取得できる。実装が読みやすいcontainerが取れない状況では使えない(フォールバックが必要)高
ViewModelにRowIndexを持たせるバインドだけで背景を決められる追加/削除/並び替えのたびにRowIndex更新が必要中

選択ハイライトを壊したくないなら「ItemContainerStyleSelector」が安定

DataTemplate側でBorder Background=...を固定すると、ListViewItemが持つ標準の視覚効果(選択・ホバー・押下など)が見えにくくなることがあります。理由はシンプルで、コンテナが描くハイライトより手前に、テンプレート内の背景が描画されてしまうからです。

選択表示もきれいに両立したい場合は、背景はコンテナ(ListViewItem)側に持たせるのが安定します。そこで役立つのが、ListViewが持つItemContainerStyleSelectorです。偶数行/奇数行でListViewItemに適用するStyleを切り替えられます。

行の内容は1つのItemTemplateにまとめる

まず、行の中身(レイアウト)は1つのDataTemplateにまとめます。背景はここでは付けません。

<DataTemplate x:Key="RowContentTemplate" x:DataType="local:UserRow">
    <Grid Padding="10,6" ColumnDefinitions="120,*,160">
        <TextBlock Text="{x:Bind Id}" />
        <TextBlock Grid.Column="1" Text="{x:Bind Name}" />
        <TextBlock Grid.Column="2" Text="{x:Bind UpdatedAt}" HorizontalAlignment="Right" />
    </Grid>
</DataTemplate>

偶数/奇数用のListViewItem Styleを2つ用意する

次に、ListViewItemの背景だけが違うStyleを2つ用意します。標準のVisualStateを活かすため、Templateを丸ごと書き換えるのではなく、まずはBackgroundなどのSetterだけで済ませるのが安全です。

<Style x:Key="ZebraEvenItemStyle" TargetType="ListViewItem">
    <Setter Property="Background" Value="{ThemeResource SystemControlBackgroundBaseLowBrush}" />
    <Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>

<Style x:Key="ZebraOddItemStyle" TargetType="ListViewItem">
    <Setter Property="Background" Value="{ThemeResource SystemControlBackgroundAltHighBrush}" />
    <Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>

StyleSelectorで偶数/奇数のStyleを返す

今度はDataTemplateSelectorではなく、StyleSelectorを継承してStyleを選びます。インデックス取得は先ほどと同じくIndexFromContainerを使えます。

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace YourApp.Selectors;

public sealed class ZebraItemContainerStyleSelector : StyleSelector
{
    public Style? EvenStyle { get; set; }
    public Style? OddStyle { get; set; }

    protected override Style SelectStyleCore(object item, DependencyObject container)
    {
        var owner = ItemsControl.ItemsControlFromItemContainer(container);
        int index = owner?.IndexFromContainer(container) ?? -1;

        if (index < 0)
        {
            return EvenStyle ?? base.SelectStyleCore(item, container);
        }

        return (index % 2 == 0)
            ? (EvenStyle ?? base.SelectStyleCore(item, container))
            : (OddStyle ?? base.SelectStyleCore(item, container));
    }
}

ListViewに適用する

ListViewには、行の中身としてItemTemplateを、コンテナ側としてItemContainerStyleSelectorを設定します。これで「ゼブラ背景は常時」「選択/ホバーのハイライトは標準のまま」を両立しやすくなります。

<Page.Resources>
    <DataTemplate x:Key="RowContentTemplate" x:DataType="local:UserRow">...</DataTemplate>

    <Style x:Key="ZebraEvenItemStyle" TargetType="ListViewItem">...</Style>
    <Style x:Key="ZebraOddItemStyle" TargetType="ListViewItem">...</Style>

    <local:ZebraItemContainerStyleSelector x:Key="ZebraItemContainerStyleSelector"
        EvenStyle="{StaticResource ZebraEvenItemStyle}"
        OddStyle="{StaticResource ZebraOddItemStyle}" />
</Page.Resources>

<ListView
    ItemsSource="{x:Bind ViewModel.Rows}"
    ItemTemplate="{StaticResource RowContentTemplate}"
    ItemContainerStyleSelector="{StaticResource ZebraItemContainerStyleSelector}" />

どの方法を選ぶべきか:実運用目線の比較

方法実装コスト選択/ホバーとの相性保守性向いているケース
DataTemplateSelector(2テンプレート)低テンプレ背景がハイライトを隠すことがある中(レイアウト重複が出やすい)とにかく早くゼブラにしたい、小規模画面
ItemContainerStyleSelector(2スタイル)中標準のハイライトを活かしやすい高(ItemTemplateは1つで済む)選択表示も重視、行レイアウトが複雑、将来変更が多い
ContainerContentChangingで背景設定中〜高実装次第中(コードビハインドが増える)大量データで最適化したい、動的に色を変えたい
ListView派生+PrepareContainerForItemOverride高最も制御しやすい中(独自コントロールの維持が必要)社内フレームワーク化して横展開したい

イベントで背景を当てる(ContainerContentChanging)

「スクロールでコンテナが再利用されるたびに確実に背景を当てたい」「データの状態で色分けもしたい」など、より動的な要件ならContainerContentChangingイベントでコンテナ背景を更新する方法もあります。

このイベントは、ItemsPanelがItemsStackPanelやItemsWrapGridのときにのみ発火する点に注意してください(独自パネルに差し替えると発火しない)。

<ListView
    x:Name="UsersList"
    ItemsSource="{x:Bind ViewModel.Rows}"
    ItemTemplate="{StaticResource RowContentTemplate}"
    ContainerContentChanging="UsersList_ContainerContentChanging" />
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;

private void UsersList_ContainerContentChanging(ListViewBase sender, ContainerContentChangingEventArgs args)
{
    // リサイクル中のコンテナは触らない or 先にリセットする
    if (args.InRecycleQueue)
    {
        return;
    }

    if (args.ItemContainer is ListViewItem lvi)
    {
        int index = sender.IndexFromContainer(lvi);

        // フォールバック:取得できなければ偶数扱い
        bool isOdd = index >= 0 && (index % 2 == 1);

        // システムのテーマBrushをそのまま使う(自前でBrushを定義してもOK)
        var key = isOdd ? "SystemControlBackgroundAltHighBrush" : "SystemControlBackgroundBaseLowBrush";
        lvi.Background = (Brush)Application.Current.Resources[key];
    }
}

ただし、背景をコードで変更する方法は環境やスタイルの影響を受けやすく、バージョンによっては「ホバーするまで背景が反映されない」といった報告もあります。StyleSelectorなどXAML側で完結できるなら、そちらを優先するとトラブルが減ります。

ゼブラ表示を「壊れにくく」する設計のコツ

並び替え・フィルタでのズレを防ぐ

  • 表示順に追従させたいなら、インデックスは「UI上のインデックス」を使う(IndexFromContainerなど)。
  • 元データの順序を固定したいなら、ViewModel側でRowIndexを管理し、並び替え時に更新する。

テーマ(ライト/ダーク)と高コントラストを意識する

ゼブラ色は「視認性を上げるための薄い差」が基本です。濃すぎると選択ハイライトと競合し、薄すぎるとダークテーマで差が消えます。ThemeResourceのBrushを使い、ライト/ダーク双方で確認するのが安全です。

大量データでの性能劣化を避ける

  • IndexOfで毎回探索すると、行数が多い一覧でスクロールが重くなる原因になります。
  • Selectorでインデックスを取るなら、可能であればIndexFromContainerを使う。
  • ItemTemplateは軽く保ち、画像や複雑なレイアウトは必要最小限にする(ListViewは仮想化が効いても「1行が重い」と体感速度が落ちます)。

よくあるトラブルとチェックリスト

選択した行のハイライトが見えない

  • DataTemplate内のBorder Backgroundが、ListViewItemの選択背景を隠している可能性があります。
  • 対策:背景はItemContainerStyleSelectorでListViewItem側に持たせ、ItemTemplateは背景なしにする。

ゼブラがスクロール中に崩れる(縞がずれる)

  • コンテナ再利用(リサイクル)により、背景更新が「一度だけ」だとズレます。
  • 対策:StyleSelectorで常にStyleを選ぶか、ContainerContentChangingで毎回背景をセットし直す。

並び替えたらゼブラが意図と違う

  • 判定をアイテムの値で行っていると、並び替えで破綻します。
  • 対策:表示順のインデックスで判定する(IndexFromContainer/IndexOf(ビュー)など)。

背景がホバーするまで反映されない

  • コードビハインドでBackgroundを変更している場合、テンプレート/状態遷移の影響で反映が遅れることがあります。
  • 対策:可能ならXAML(StyleSelector)で完結させる。どうしてもコードでやるなら、コンテナの状態(選択/ホバー)も含めて設計する。

まとめ

WinUI 3(Windows App SDK)のListViewでゼブラ表示を作るなら、まずはDataTemplateSelectorで偶数行/奇数行のテンプレートを切り替える方法が分かりやすく実装も簡単です。一方で、選択ハイライトや保守性まで考えると、ItemContainerStyleSelectorでコンテナ側の背景を切り替える方が「崩れにくい」ケースが多いです。要件(選択の見え方、データ量、並び替え頻度)に合わせて最適なパターンを選びましょう。

この記事を書いた人

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

コメント

コメントする

目次