WinUI 3 の ItemsRepeater と LinedFlowLayout で Pinterest 風の“ときどき大きい”タイルを実現したい──そんなときに最小手数で確実に動く実装手順を、仮想化の落とし穴まで含めて徹底解説します。インデックス 2 と 6 を幅 2 倍にする例を軸に、拡張しやすいコード構成と運用のコツをまとめました。
前提とゴールの整理
本記事のゴールは、WinUI 3 の ItemsRepeater+LinedFlowLayout を使ったタイル表示で、「一部のアイテムだけ幅(や高さ)を 2 倍にする」非均一レイアウトを、仮想化と再利用に強いかたちで安定実装することです。
- 課題:通常のタイルは 100px。インデックス 2 と 6 だけ 200px(2 倍)にしたい。
- 要件:スクロール時の仮想化(要素の生成・再利用)でもサイズが勝手に戻らないこと。
- 使うレイアウト:
LinedFlowLayout(横幅が変わると自動的に折り返し位置が調整される)
全体像:最小実装の考え方
最もシンプルで堅牢な方針は、ItemsRepeater.ElementPrepared イベントで毎回サイズをセットすることです。ElementPrepared は仮想化による要素の生成・再利用のたびに呼ばれるため、ここで “対象インデックスなら 2 倍、それ以外なら標準” を必ず適用し直します。これにより、スクロールでアイテムが再利用されてもサイズがリセットされる問題を防げます。
最小構成の XAML
まずはページに ItemsRepeater を配置し、LinedFlowLayout を設定。タイルのルートは Border とし、後で名前で参照できるよう x:Name="ItemBorder" を付けます。
<Page
x:Class="SampleApp.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.UI.Xaml.Controls"
xmlns:local="using:SampleApp">
<Grid Padding="16">
<controls:ItemsRepeater x:Name="ir1"
ItemsSource="{x:Bind Items, Mode=OneWay}"
ElementPrepared="ir1_ElementPrepared">
<controls:ItemsRepeater.Layout>
<controls:LinedFlowLayout />
</controls:ItemsRepeater.Layout>
<controls:ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="local:TileItem">
<Border x:Name="ItemBorder"
Width="100"
Height="100"
CornerRadius="8"
Margin="4"
Background="{Binding BaseColor}">
<TextBlock Text="{x:Bind Title}"
FontSize="20"
FontWeight="SemiBold"
VerticalAlignment="Center"
HorizontalAlignment="Center"/>
</Border>
</DataTemplate>
</controls:ItemsRepeater.ItemTemplate>
</controls:ItemsRepeater>
</Grid>
</Page>
データモデルと初期データ
標準幅のタイル 10 個を用意します。タイトルだけの素朴なモデルから始め、必要なら色などを後で足します。
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using System.Collections.ObjectModel;
using System.Collections.Generic;
namespace SampleApp;
public sealed partial class MainPage : Page
{
public ObservableCollection<TileItem> Items { get; } = new();
// 今回は「2 と 6」を 2 倍にするルール
private readonly HashSet<int> _wideIndices = new() { 2, 6 };
public MainPage()
{
this.InitializeComponent();
for (int i = 0; i < 10; i++)
{
Items.Add(new TileItem
{
Title = $"Item {i:D2}",
BaseColor = new SolidColorBrush(Colors.LightGray)
});
}
}
private void ir1_ElementPrepared(ItemsRepeater sender,
ItemsRepeaterElementPreparedEventArgs args)
{
int index = sender.GetElementIndex(args.Element);
if (args.Element is FrameworkElement fe
&& fe.FindName("ItemBorder") is Border border)
{
bool isWide = _wideIndices.Contains(index);
// 幅 2 倍(標準 100 の想定)
border.Width = isWide ? 200 : 100;
// 高さも変えたい場合はここで設定可能(例:縦 2 倍)
// border.Height = isWide ? 200 : 100;
// 視覚確認用の色(本番不要なら削除)
border.Background = new SolidColorBrush(
isWide ? Colors.IndianRed : Colors.SteelBlue);
}
}
}
public class TileItem
{
public string Title { get; set; } = "";
public Brush BaseColor { get; set; } = new SolidColorBrush(Colors.LightGray);
}
キモは「毎回セット」:仮想化に強い書き方
ItemsRepeater はパフォーマンスのために要素を再利用(リサイクル)します。つまり、一度 200px にした Border が、スクロール後に別インデックスのアイテムに再割り当てされることがあります。だからこそ、ElementPrepared のたびに “正しいサイズ” を必ずセットし直す必要があります。
- ダメな例:ロード時に一度だけ
Widthを変更する。 - 良い例:
ElementPrepared内でindexを判定して、毎回Width/Heightを設定。
この「毎回セット」さえ守れば、スクロールやデータの増減があっても、サイズの取り違えは起きません。
インデックス以外のルールへ広げる
「2 と 6 を広く」のような固定インデックスから始めても、実務ではさまざまなルールが欲しくなります。以下のように柔軟に拡張できます。
モデル側のフラグで制御(推奨)
データ駆動にしたい場合、モデルに IsWide を追加し、ElementPrepared で参照します。並べ替えやフィルタにも強く、テストもしやすい構成です。
public class TileItem
{
public string Title { get; set; } = "";
public bool IsWide { get; set; }
}
private void ir1_ElementPrepared(ItemsRepeater sender,
ItemsRepeaterElementPreparedEventArgs args)
{
int index = sender.GetElementIndex(args.Element);
if (args.Element is FrameworkElement fe
&& fe.DataContext is TileItem vm
&& fe.FindName("ItemBorder") is Border border)
{
bool isWide = vm.IsWide; // データが真理
border.Width = isWide ? 200 : 100;
}
}
周期ルール(例:3 枚ごとに 2 枚目を 2 倍)
bool isWide = (index % 3) == 1; // 0-based: 1, 4, 7, ...
border.Width = isWide ? 200 : 100;
条件を関数化してテスト容易に
private bool ShouldBeWide(int index) => index == 2 || index == 6;
...
border.Width = ShouldBeWide(index) ? 200 : 100;
高さも自在:横 2 倍+縦 2 倍の長方形にも
縦方向も伸ばしたいなら Height を同じ場所で設定します。LinedFlowLayout は幅や高さの違いに応じて折り返し・整列を再計算してくれます。
bool isWide = _wideIndices.Contains(index);
// 横だけ 2 倍
border.Width = isWide ? 200 : 100;
border.Height = 100;
// たとえば縦も 2 倍にしたい場合
// border.Height = isWide ? 200 : 100;
スタイル切替で見通しを良くする
Width/Height や角丸など、複数の外観をまとめて切り替えたいなら、スタイルを 2 つ用意して ElementPrepared で適用するのが整然です。
<Page.Resources>
<Style x:Key="TileNormal" TargetType="Border">
<Setter Property="Width" Value="100"/>
<Setter Property="Height" Value="100"/>
<Setter Property="CornerRadius" Value="8"/>
</Style>
<Style x:Key="TileWide" TargetType="Border">
<Setter Property="Width" Value="200"/>
<Setter Property="Height" Value="100"/>
<Setter Property="CornerRadius" Value="8"/>
</Style>
</Page.Resources>
border.Style = ShouldBeWide(index) ? (Style)Resources["TileWide"]
: (Style)Resources["TileNormal"];
LinedFlowLayout と折り返しの挙動
LinedFlowLayout は「行(line)」の概念を持ち、アイテム幅が異なっても自然に折り返し・整列されます。横幅 200 のタイルが途中に混ざると、その分だけスペースの使い方が変わり、次アイテムの配置位置も調整されます。特別なアルゴリズムを実装しなくても、見栄えのよいタイル列を得やすいのが利点です。
ただし、常に「最適充填」が得られるわけではありません。見た目をさらに整えたい場合は、アイテム間の余白やコンテナ幅(親 Grid やウィンドウ幅)を調整したり、広いタイルの出現位置を規則化する(例:行頭に必ず広いタイルが来るようインデックスを選ぶ)と崩れが減ります。
よくある落とし穴と対策
| 症状 | 原因 | 対処 |
|---|---|---|
| スクロール後にサイズが戻る/崩れる | 仮想化で要素が再利用。サイズを一度しか設定していない | ElementPrepared のたびに必ず Width/Height をセット |
| 配置が瞬時に反映されない | 測定/配置のスケジューリング待ち | 大量変更時は最後に ir1.InvalidateMeasure() を呼ぶ |
| アクセシビリティでフォーカス移動が直感的でない | 不均一グリッドはフォーカスナビゲーションが難しい | 方向キーでの移動(XY フォーカス)を手当。必要に応じてカスタム |
| 行頭・行末の余白が気になる | 幅 2 倍タイルの出現位置と親の幅の組合せ | 広いタイルの出現規則を固定化/ウィンドウ幅に応じルールを切替 |
大量データ時のパフォーマンス指針
- サイズ計算を O(1) に:
HashSet<int>などで即判定。重いロジックは避ける。 - 見た目変更はまとめて:多数の
IsWide更新後にInvalidateMeasure()を 1 度。 - 色やスタイルの計算をキャッシュ:
Style切替で再計算を減らす。 - 画像読み込みは遅延:タイル内に画像を置く場合は遅延読み込みや低解像度プレビューを併用。
応用:動的ルールの切替(レスポンシブ)
ウィンドウ幅が狭いときは全アイテムを等幅に、広いときは「2 と 6 を 2 倍」にするなど、ルールの切替も簡単です。サイズクラスの変化やウィンドウ幅の変更イベントで内部フラグを切り替え、ItemsRepeater を再測定すれば OK です。
bool _useWideRule = true;
private void OnWindowSizeChanged(object sender, WindowSizeChangedEventArgs e)
{
_useWideRule = e.Size.Width >= 800;
ir1.InvalidateMeasure(); // 全体の再レイアウト
}
private void ir1_ElementPrepared(ItemsRepeater sender,
ItemsRepeaterElementPreparedEventArgs args)
{
int index = sender.GetElementIndex(args.Element);
if (args.Element is FrameworkElement fe
&& fe.FindName("ItemBorder") is Border border)
{
bool isWide = _useWideRule && (index == 2 || index == 6);
border.Width = isWide ? 200 : 100;
}
}
品質向上のためのチェックリスト
- キーボード操作で各タイルに到達できるか(フォーカス可視化・順序)
- スクリーンリーダーで読み上げ順が視覚順と矛盾していないか
- マウス/タッチで広いタイルと狭いタイルの操作領域に差が出すぎていないか
- 表示密度が高いときにもラベルが欠けないか(省略記号・ツールチップ)
「本格 Masonry(マス目)風」を目指す場合
WinUI 3 には Pinterest のような完全 Masonry を実現するレイアウトが標準では用意されていません。もっと自由度の高い配置が必要なら、次のアプローチを検討します。
- UniformGridLayout をベースに疑似スパン:アイテムの幅/高さを倍数で統一し、出現位置のルールを工夫する。
- サードパーティ:CommunityToolkit for WinUI 3 などで Masonry 風のレイアウトが提供されていないか確認。
- 独自の
VirtualizingLayoutを実装:MeasureOverride/ArrangeOverride相当で列ごとの最短列に積むアルゴリズムを記述。
まずは本記事の「幅/高さを個別に変える」アプローチで要件を満たせるかを見極め、足りなければ上記に進むのがおすすめです。
実運用に効くテスト観点
- スクロール往復テスト:最上部→最下部→最上部を繰り返し、サイズブレが起きないかを検証。
- 動的変更テスト:対象インデックスの追加/削除後に
InvalidateMeasure()を呼び、配置が即時反映されるか。 - リサイズテスト:ウィンドウの横幅を境界値(例:799px/800px)で往復させ、折り返しが不自然にならないか。
よくある質問(FAQ)
Q. XAML だけでインデックス判定して幅を変えられますか?
A. 直接インデックスにアクセスする仕組みはないため、ElementPrepared での制御がシンプルで堅牢です。データ駆動にしたい場合はモデルに IsWide を持たせてください。
Q. ElementClearing は使うべき?
A. 追加の後片付けが必要な場合に有用ですが、サイズは ElementPrepared で毎回セットしておけば、ElementClearing でリセットする必要は基本的にありません。
Q. LinedFlowLayout と UniformGridLayout の違いは?
A. 前者は幅/高さがばらつくアイテムを“行”の単位で自然に整列するのが得意。後者は等間隔のグリッドで見通しが良い反面、非均一サイズは苦手です。
レイアウト選択の目安(比較表)
| レイアウト | 得意な配置 | 非均一サイズ | 主な用途 |
|---|---|---|---|
| LinedFlowLayout | 横方向に流れるタイル、行ベースの整列 | ◎(本記事の方式と相性良) | カード一覧、ギャラリー、ニュース面 |
| UniformGridLayout | 規則正しい等間隔グリッド | △(疑似スパンなら可) | 写真サムネ、ダッシュボード |
| StackLayout | 縦/横 1 列のリスト | ○(幅は親次第) | チャット、設定リスト |
完全版サンプル(まとめて貼れる)
以下をそのままコピペすれば、インデックス 2 と 6 が幅 2 倍で表示される最小アプリが動きます。
MainPage.xaml
<Page
x:Class="SampleApp.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.UI.Xaml.Controls"
xmlns:local="using:SampleApp">
```
<controls:ItemsRepeater.Layout>
<controls:LinedFlowLayout />
</controls:ItemsRepeater.Layout>
<controls:ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="local:TileItem">
<Border x:Name="ItemBorder"
Style="{StaticResource TileNormal}"
Margin="4"
Background="{Binding BaseColor}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Text="{x:Bind Title}"
FontSize="20"
FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<TextBlock Text="{x:Bind Description}"
Opacity="0.7"
FontSize="12"
HorizontalAlignment="Center"/>
</StackPanel>
</Border>
</DataTemplate>
</controls:ItemsRepeater.ItemTemplate>
</controls:ItemsRepeater>
```
MainPage.xaml.cs
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace SampleApp;
public sealed partial class MainPage : Page
{
public ObservableCollection Items { get; } = new();
```
private readonly HashSet<int> _wideIndices = new() { 2, 6 };
public MainPage()
{
this.InitializeComponent();
for (int i = 0; i < 10; i++)
{
Items.Add(new TileItem
{
Title = $"Item {i:D2}",
Description = (i == 2 || i == 6) ? "2x Width" : "Normal",
BaseColor = new SolidColorBrush(Colors.LightGray)
});
}
}
private void ir1_ElementPrepared(ItemsRepeater sender,
ItemsRepeaterElementPreparedEventArgs args)
{
int index = sender.GetElementIndex(args.Element);
if (args.Element is FrameworkElement fe
&& fe.FindName("ItemBorder") is Border border)
{
bool isWide = _wideIndices.Contains(index);
// スタイル切替(見通しが良い)
border.Style = (Style)Resources[isWide ? "TileWide" : "TileNormal"];
// デバッグ用の色分け(任意)
border.Background = new SolidColorBrush(
isWide ? Colors.IndianRed : Colors.SteelBlue);
}
}
```
}
public class TileItem
{
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public Brush BaseColor { get; set; } = new SolidColorBrush(Colors.LightGray);
}
変更に強い設計のポイント
- ルールの局所化:
ShouldBeWide(index)のように関数に隔離。仕様変更に強い。 - データの真理化:UI ロジックではなくモデルの
IsWideに寄せると、並べ替え・検索と相性が良い。 - 視覚と意味の分離:色はデバッグ専用。見た目はスタイル、意味はデータで表現。
運用チートシート
| 目的 | やること | メモ |
|---|---|---|
| 特定のタイルを 2 倍 | ElementPrepared で index 判定 → Width セット | 「毎回セット」を厳守 |
| 縦横どちらも 2 倍 | Height も同所で設定 | レイアウトは自動で折り返し |
| ルールを動的切替 | フラグ更新 → InvalidateMeasure() | サイズクラスと連動させると良い |
| 読みやすいコード | スタイル 2 種+切替 | 見通しと保守性が向上 |
まとめ
- やることはシンプル:
ElementPreparedでインデックスを判定し、対象だけWidth(必要ならHeight)を変更。 - 仮想化に注意:サイズは毎回設定。これで再利用時の崩れを未然に防止。
- 拡張は容易:モデルの
IsWide、周期ルール、スタイル切替、レスポンシブ対応などに素直に広げられる。 - もっと自由度が必要なら:CommunityToolkit の活用や独自
VirtualizingLayoutを検討。
以上で、WinUI 3 × ItemsRepeater × LinedFlowLayout による非均一タイル整列の実装は完成です。まずは最小構成で体験し、プロダクト要件に合わせて「ルール」「スタイル」「データ駆動」の 3 点を段階的に強化していきましょう。

コメント