アバター選択時に枠線を赤くしたいのに変わらない——.NET MAUI の CollectionView で MultiBinding の片方が常に null になる典型的な落とし穴を、再現コード・原因分析・4つの解決策で徹底解説します。ライフサイクル、x:Reference、非同期初期化の順序まで踏み込み、堅牢な実装とデバッグ術をまとめます。
アバター選択時に枠線が赤くならない問題の全体像
本記事では、以下の要件を満たす UI を例に解説します。
- 目的:表示中のアバター(アイテム)と、アプリ側が保持する「選択中のアバター」(
SelectedAvatar) が一致したら、Border.Strokeを赤 (Colors.Red) にして強調表示する。 - 構成:.NET MAUI、
CollectionView(GridItemsLayout)、XAML のMultiBindingで「アイテム自身 (.)」+「AppShellViewModel.SelectedAvatar」を比較するIMultiValueConverterを使用。 - 現象:デバッガで見ると、コンバータに渡る2値のうち一方(多くは
SelectedAvatar側)が常にnullになり、赤枠にならない。
なぜ起きるのか:ライフサイクルとバインディングのズレ
根本原因は複合的です。特に次の4点が絡むと、MultiBinding の比較が成立しません。
バインディング評価はテンプレート生成時に先行する
CollectionView はアイテムテンプレートを作った直後にバインディングを評価しがちです。ページ遷移直後などでは、ビューモデルの GetCurrentUserAsync() が未完了で SelectedAvatar がまだ null。そのタイミングでコンバータが走るため、比較は常に不一致(透明)になります。その後 SelectedAvatar がセットされても、変更通知が正しく飛ばなければ Border.Stroke は再評価されません。
x:Reference の解決タイミング問題
x:Reference を経由して AppShell やページの BindingContext を参照する構成では、参照先の BindingContext が未確立の瞬間に評価され、null が流れ込むことがあります。特にネストの深いテンプレートやナビゲーション直後で発生しやすいです。
等価性の扱い(参照等価 vs 値等価)
取得元が異なると、同じアバターを表す別インスタンス(Id は等しいが参照は別)が作られます。コンバータ内で単純な参照比較をすると不一致扱いになり、赤枠になりません。Id ベースの値等価で比較するのが安全です。
仮想化・セル再利用による表示の持ち越し
CollectionView はセルを再利用します。Stroke の既定値を常に透明へ戻すロジックがない/再評価が走らない場合、意図しない赤枠の残留や未反映が起きます。コンバータが 常に 透明または赤を返す実装で、変更通知が確実に届くようにすることが重要です。
最小再現コード(問題の現象を確認)
以下はよくある書き方ですが、初期化順序次第で values[1] が null になります。
<ContentPage
x:Class="ExampleApp.Pages.AvatarPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:models="clr-namespace:ExampleApp.Models"
xmlns:converters="clr-namespace:ExampleApp.Converters"
x:Name="ThisPage">
<ContentPage.Resources>
<ResourceDictionary>
<converters:SelectedAvatarMultiValueConverter x:Key="SelectedAvatarToStroke" />
</ResourceDictionary>
</ContentPage.Resources>
<CollectionView ItemsSource="{Binding Avatars}"
SelectionMode="Single"
SelectedItem="{Binding SelectedAvatar, Mode=TwoWay}">
<CollectionView.ItemsLayout>
<GridItemsLayout Orientation="Vertical" Span="4" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:AvatarCharacter">
<Border StrokeThickness="2" Padding="8">
<Border.Stroke>
<MultiBinding Converter="{StaticResource SelectedAvatarToStroke}">
<Binding Path="." />
<!-- ↓ ここが null になりうる(BindingContext 未確立) -->
<Binding Path="BindingContext.SelectedAvatar"
Source="{x:Reference ThisPage}" />
</MultiBinding>
</Border.Stroke>
<Image Source="{Binding ImageUri}" WidthRequest="64" HeightRequest="64"/>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</ContentPage>
public sealed class SelectedAvatarMultiValueConverter : IMultiValueConverter
{
private static readonly Brush RedBrush = new SolidColorBrush(Colors.Red);
private static readonly Brush TransparentBrush = new SolidColorBrush(Colors.Transparent);
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
var current = values.Length > 0 ? values[0] as AvatarCharacter : null;
var selected = values.Length > 1 ? values[1] as AvatarCharacter : null;
Debug.WriteLine($"current={current?.Id}, selected={selected?.Id}");
if (current is null || selected is null)
return TransparentBrush;
// 値等価で比較(Id ベース)
return current.Id == selected.Id ? RedBrush : TransparentBrush;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
このコードは一見正しそうですが、SelectedAvatar の代入が遅れると、テンプレート生成時の初回評価で null が入ります。以降、PropertyChanged が正しく発火しないと、Brush は更新されません。
解決策の比較(どれを選ぶべき?)
| 方法 | 具体手順 | メリット | デメリット/注意 | 適用ケース |
|---|---|---|---|---|
① Syncfusion SfListView へ置換 | CollectionView を SfListView に置換。テンプレート確定後の評価とイベントが豊富で、初期化タイミング問題の影響が小さい。 | 短時間で解決。付加機能が多い。 | 依存先が増える/ライセンス要確認。パッケージサイズ増。 | 工数より納期優先。既に Syncfusion を使っている。 |
② CollectionView を継続(推奨) | 非同期初期化完了後に SelectedAvatar を設定し、必ず OnPropertyChanged(nameof(SelectedAvatar)) を発火。また、 x:Reference 依存を減らし RelativeSource/ページ参照で堅牢に。 | 標準だけで完結。将来の保守が容易。 | ライフサイクル順序の理解が必須。通知漏れを作りやすい。 | 標準コンポーネントで統一したい場合。 |
③ コンバータで null を吸収 | null 時は透明を返し、ログを出して状態を可視化。原因調査に有効。 | デバッグ容易。副作用が少ない。 | 根本解決ではない。表示タイミングのズレは残る。 | 暫定対応。挙動の観測が目的。 |
④ DataTrigger に置き換え | MultiBinding→DataTrigger+bool コンバータに変更。ロジックを XAML 寄りにして読みやすく。 | 可読性向上。テストしやすい。 | 評価タイミングの原理は同じ。通知が必須。 | XAML に寄せたい。チーム規約でコンバータ最小化。 |
標準だけで堅牢に直す(推奨:方法②)
ベストプラクティスは「非同期初期化を終えてから SelectedAvatar を設定し、確実に変更通知で再評価させる」です。さらに x:Reference 依存を避けると、BindingContext の未確立に強くなります。
1) モデルを値等価にする(Id 比較)
public sealed class AvatarCharacter : IEquatable<AvatarCharacter>
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string ImageUri { get; init; } = string.Empty;
public bool Equals(AvatarCharacter? other) => other is not null && Id == other.Id;
public override bool Equals(object? obj) => obj is AvatarCharacter a && Equals(a);
public override int GetHashCode() => Id.GetHashCode();
}
2) ビューモデル:確実な変更通知
public partial class AppShellViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<AvatarCharacter> avatars = new();
[ObservableProperty]
private AvatarCharacter? selectedAvatar;
public async Task InitializeAsync()
{
// ユーザーとアバターを先にロード
var user = await _userService.GetCurrentUserAsync();
var list = await _avatarService.GetAllAsync();
Avatars = new ObservableCollection<AvatarCharacter>(list);
// 値等価で選択(Id 基準)
SelectedAvatar = Avatars.FirstOrDefault(a => a.Id == user.SelectedAvatarId);
// CommunityToolkit の [ObservableProperty] は setter で自動的に通知を出す
// もし独自 INotifyPropertyChanged 実装なら、必ず OnPropertyChanged(nameof(SelectedAvatar)) を呼ぶ
}
}
3) ページのライフサイクルで初期化順序を制御
public partial class AvatarPage : ContentPage
{
public AvatarPage(AppShellViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
protected override async void OnAppearing()
{
base.OnAppearing();
// ここで初期化(UI が表示される直前)
if (BindingContext is AppShellViewModel vm && vm.Avatars.Count == 0)
{
await vm.InitializeAsync();
}
}
}
4) XAML:RelativeSource / ページ参照で堅牢に
BindingContext を辿る経路を明示し、x:Reference の未確立リスクを抑えます。
<CollectionView ItemsSource="{Binding Avatars}"
SelectionMode="Single"
SelectedItem="{Binding SelectedAvatar, Mode=TwoWay}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:AvatarCharacter">
<Border StrokeThickness="2" Padding="8">
<Border.Stroke>
<MultiBinding Converter="{StaticResource SelectedAvatarToStroke}">
<Binding Path="." />
<Binding Path="BindingContext.SelectedAvatar"
RelativeSource="{RelativeSource AncestorType={x:Type ContentPage}}" />
</MultiBinding>
</Border.Stroke>
<Image Source="{Binding ImageUri}" WidthRequest="64" HeightRequest="64"/>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
5) コンバータは Brush を返す(型を合わせる)
Border.Stroke は Brush 型です。Color からの暗黙変換に任せず、明示的に Brush を返すと安定します(上のサンプル参照)。また、同一インスタンスの Brush を再利用して GC を抑制しましょう。
代替案:DataTrigger で実装
コンバータは bool を返し、トリガで Brush を切り替えます。ロジックが XAML に寄るため読みやすく、UI デザイナが調整しやすくなります。
public sealed class AvatarEqualsBoolConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
var current = values.Length > 0 ? values[0] as AvatarCharacter : null;
var selected = values.Length > 1 ? values[1] as AvatarCharacter : null;
return current is not null && selected is not null && current.Id == selected.Id;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
<Border StrokeThickness="2" Padding="8">
<Border.Stroke>
<SolidColorBrush Color="Transparent" />
</Border.Stroke>
<Border.Triggers>
<DataTrigger TargetType="Border" Value="True">
<DataTrigger.Binding>
<MultiBinding Converter="{StaticResource AvatarEqualsBoolConverter}">
<Binding Path="." />
<Binding Path="BindingContext.SelectedAvatar"
RelativeSource="{RelativeSource AncestorType={x:Type ContentPage}}" />
</MultiBinding>
</DataTrigger.Binding>
<Setter Property="Stroke">
<Setter.Value>
<SolidColorBrush Color="Red" />
</Setter.Value>
</Setter>
</DataTrigger>
</Border.Triggers>
<Image Source="{Binding ImageUri}" WidthRequest="64" HeightRequest="64"/>
</Border>
Syncfusion SfListView に置き換える(方法①)
既に Syncfusion を導入済み、または短期間で確実に直したい場合に有効です。初期化順序の影響を受けにくく、選択・テンプレートに関するイベントも豊富です。以下は概念的な移行例です。
<syncfusion:SfListView ItemsSource="{Binding Avatars}"
SelectionMode="Single"
SelectedItem="{Binding SelectedAvatar, Mode=TwoWay}">
<syncfusion:SfListView.ItemTemplate>
<DataTemplate>
<Grid>
<Border StrokeThickness="2" Padding="8"
Stroke="{Binding ., Converter={StaticResource SelectedAvatarToStroke},
ConverterParameter={Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.SelectedAvatar}}">
<Image Source="{Binding ImageUri}" />
</Border>
</Grid>
</DataTemplate>
</syncfusion:SfListView.ItemTemplate>
</syncfusion:SfListView>
注意点として、ライセンスやパッケージサイズ、他機能への影響を事前に確認してください。
デバッグ強化:状態を見える化する
- ログ出力: コンバータで
Debug.WriteLine($"current={values[0]}, selected={values[1]}");を出し、nullの発生タイミングを把握。 - 初期値の可視化:
SelectedAvatarを設定する直前と直後でログ(Id/Name)を出す。 - 通知の検証: プロパティ setter にブレークポイント。
PropertyChangedが UI スレッドで発火しているかを確認。 - 仮想化チェック: スクロールでセルが再利用されても赤枠が正しく付け替わるか、しつこく上下にスクロールして観察。
チェックリスト:ハマりどころを一掃
SelectedAvatarを非同期初期化完了後にセットし、確実にPropertyChangedを出している。- コンバータ/トリガは Brush を返し、透明の既定値を常に設定している。
- 等価比較はId ベース(値等価)。参照等価には依存しない。
x:Referenceへの過度な依存を避け、RelativeSourceまたはページ参照でBindingContextを辿っている。- スクロール後や画面回転後にも、赤枠状態が正しく継承される。
よくある落とし穴と対処
- Color を返している:
Border.Strokeは Brush。暗黙変換に頼らずSolidColorBrushを返す。 - 通知が別スレッド: 非同期処理直後の代入は
MainThread.BeginInvokeOnMainThreadやDispatcher.Dispatchで UI スレッドへ。 - SelectedItem と SelectedAvatar の二重管理: 双方向バインドが循環しないよう、どちらかに統一。
SelectedItemの変更を VM 側で受けてSelectedAvatarをセットするのが明快。 - セル再利用で赤が残る: 透明の初期 Stroke を必ず指定し、全パスで透明を返す分岐を用意。
完全版コード(安定動作のサンプル)
以下は「標準コンポーネントのみ」「通知・型・比較を適正化」した、実戦投入可能なサンプルです。
<ContentPage
x:Class="ExampleApp.Pages.AvatarPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:models="clr-namespace:ExampleApp.Models"
xmlns:converters="clr-namespace:ExampleApp.Converters"
x:Name="ThisPage">
<ContentPage.Resources>
<ResourceDictionary>
<converters:SelectedAvatarMultiValueConverter x:Key="SelectedAvatarToStroke" />
</ResourceDictionary>
</ContentPage.Resources>
<Grid Padding="12">
<CollectionView ItemsSource="{Binding Avatars}"
SelectionMode="Single"
SelectedItem="{Binding SelectedAvatar, Mode=TwoWay}">
<CollectionView.ItemsLayout>
<GridItemsLayout Span="4" Orientation="Vertical"/>
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:AvatarCharacter">
<Border StrokeThickness="2" Padding="8">
<Border.Stroke>
<MultiBinding Converter="{StaticResource SelectedAvatarToStroke}">
<Binding Path="." />
<Binding Path="BindingContext.SelectedAvatar"
RelativeSource="{RelativeSource AncestorType={x:Type ContentPage}}" />
</MultiBinding>
</Border.Stroke>
<VerticalStackLayout Spacing="4" HorizontalOptions="Center">
<Image Source="{Binding ImageUri}" WidthRequest="72" HeightRequest="72"/>
<Label Text="{Binding Name}" />
</VerticalStackLayout>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>
public sealed class SelectedAvatarMultiValueConverter : IMultiValueConverter
{
private static readonly Brush RedBrush = new SolidColorBrush(Colors.Red);
private static readonly Brush TransparentBrush = new SolidColorBrush(Colors.Transparent);
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
var current = values.Length > 0 ? values[0] as AvatarCharacter : null;
var selected = values.Length > 1 ? values[1] as AvatarCharacter : null;
Debug.WriteLine($"[Converter] current={current?.Id}, selected={selected?.Id}");
if (current is null || selected is null) return TransparentBrush;
return current.Id == selected.Id ? RedBrush : TransparentBrush;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public partial class AppShellViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<AvatarCharacter> avatars = new();
[ObservableProperty]
private AvatarCharacter? selectedAvatar;
public async Task InitializeAsync()
{
var user = await _userService.GetCurrentUserAsync();
var all = await _avatarService.GetAllAsync();
// UI スレッドでセット(重要)
MainThread.BeginInvokeOnMainThread(() =>
{
Avatars = new ObservableCollection<AvatarCharacter>(all);
SelectedAvatar = Avatars.FirstOrDefault(a => a.Id == user.SelectedAvatarId);
});
}
}
public partial class AvatarPage : ContentPage
{
public AvatarPage(AppShellViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (BindingContext is AppShellViewModel vm && vm.Avatars.Count == 0)
{
await vm.InitializeAsync();
}
}
}
パフォーマンス・保守性の観点
- Brush の再利用: 毎回
new SolidColorBrushせず静的インスタンスを共有。 - 簡潔な比較: コンバータでは Id の比較だけにし、副作用を書かない。ログは
#if DEBUGで抑制可能に。 - ViewModel に処理を寄せる: 選択ロジックは VM に集約し、UI 側は「表示のみ」に徹する。
まとめ:直すべきポイントは3つだけ
- 初期化順序: ユーザー情報/
SelectedAvatarを UI 表示より前、または直後に確実に設定し、変更通知を飛ばす。 - 安定した参照経路:
RelativeSourceなどを使い、x:Referenceによる未確立のBindingContextを避ける。 - 値等価+Brush: Id ベースで比較し、
Brushを返すコンバータで赤枠と透明を明示する。
この3点を押さえれば、CollectionView でも SfListView でも安定して「選択中のアバターを赤枠で強調」できます。デバッグログで状態を見える化しつつ、通知と初期化の順序を丁寧に整えることが、もっとも効果的な解決策です。
付録:トラブルシューティング早見表
| 症状 | 想定原因 | 対処 |
|---|---|---|
| 常に透明のまま | SelectedAvatar が null/通知なし | 初期化完了後にセットし、PropertyChanged を確認 |
| 赤枠が別アイテムに残る | セル再利用+初期 Stroke 未設定 | 透明で初期化し、全分岐で透明を返す |
| 比較が合わない | 参照比較になっている | Id ベースの値等価に変更 |
| たまに赤くならない | x:Reference 解決前に評価 | RelativeSource へ移行/ページ参照に変更 |
| スクロールで乱れる | 仮想化&再評価が走らない | SelectedAvatar 更新時の通知を保証 |

コメント