.NET MAUI CollectionViewのMultiBindingがnullになる原因と解決策:アバター選択時にBorderの赤枠が反映されない問題を完全解説

アバター選択時に枠線を赤くしたいのに変わらない——.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 の未確立リスクを抑えます。

&lt;CollectionView ItemsSource="{Binding Avatars}"
                SelectionMode="Single"
                SelectedItem="{Binding SelectedAvatar, Mode=TwoWay}"&gt;
  &lt;CollectionView.ItemTemplate&gt;
    &lt;DataTemplate x:DataType="models:AvatarCharacter"&gt;
      &lt;Border StrokeThickness="2" Padding="8"&gt;
        &lt;Border.Stroke&gt;
          &lt;MultiBinding Converter="{StaticResource SelectedAvatarToStroke}"&gt;
            &lt;Binding Path="." /&gt;
            &lt;Binding Path="BindingContext.SelectedAvatar"
                     RelativeSource="{RelativeSource AncestorType={x:Type ContentPage}}" /&gt;
          &lt;/MultiBinding&gt;
        &lt;/Border.Stroke&gt;
        &lt;Image Source="{Binding ImageUri}" WidthRequest="64" HeightRequest="64"/&gt;
      &lt;/Border&gt;
    &lt;/DataTemplate&gt;
  &lt;/CollectionView.ItemTemplate&gt;
&lt;/CollectionView&gt;

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 &gt; 0 ? values[0] as AvatarCharacter : null;
        var selected = values.Length &gt; 1 ? values[1] as AvatarCharacter : null;
        return current is not null &amp;&amp; selected is not null &amp;&amp; current.Id == selected.Id;
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        =&gt; throw new NotSupportedException();
}
&lt;Border StrokeThickness="2" Padding="8"&gt;
  &lt;Border.Stroke&gt;
    &lt;SolidColorBrush Color="Transparent" /&gt;
  &lt;/Border.Stroke&gt;

  &lt;Border.Triggers&gt;
    &lt;DataTrigger TargetType="Border" Value="True"&gt;
      &lt;DataTrigger.Binding&gt;
        &lt;MultiBinding Converter="{StaticResource AvatarEqualsBoolConverter}"&gt;
          &lt;Binding Path="." /&gt;
          &lt;Binding Path="BindingContext.SelectedAvatar"
                   RelativeSource="{RelativeSource AncestorType={x:Type ContentPage}}" /&gt;
        &lt;/MultiBinding&gt;
      &lt;/DataTrigger.Binding&gt;
      &lt;Setter Property="Stroke"&gt;
        &lt;Setter.Value&gt;
          &lt;SolidColorBrush Color="Red" /&gt;
        &lt;/Setter.Value&gt;
      &lt;/Setter&gt;
    &lt;/DataTrigger&gt;
  &lt;/Border.Triggers&gt;

  &lt;Image Source="{Binding ImageUri}" WidthRequest="64" HeightRequest="64"/&gt;
&lt;/Border&gt;

Syncfusion SfListView に置き換える(方法①)

既に Syncfusion を導入済み、または短期間で確実に直したい場合に有効です。初期化順序の影響を受けにくく、選択・テンプレートに関するイベントも豊富です。以下は概念的な移行例です。

&lt;syncfusion:SfListView ItemsSource="{Binding Avatars}"
                       SelectionMode="Single"
                       SelectedItem="{Binding SelectedAvatar, Mode=TwoWay}"&gt;
  &lt;syncfusion:SfListView.ItemTemplate&gt;
    &lt;DataTemplate&gt;
      &lt;Grid&gt;
        &lt;Border StrokeThickness="2" Padding="8"
                Stroke="{Binding ., Converter={StaticResource SelectedAvatarToStroke},
                         ConverterParameter={Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.SelectedAvatar}}"&gt;
          &lt;Image Source="{Binding ImageUri}" /&gt;
        &lt;/Border&gt;
      &lt;/Grid&gt;
    &lt;/DataTemplate&gt;
  &lt;/syncfusion:SfListView.ItemTemplate&gt;
&lt;/syncfusion:SfListView&gt;

注意点として、ライセンスやパッケージサイズ、他機能への影響を事前に確認してください。

デバッグ強化:状態を見える化する

  • ログ出力: コンバータで 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 を必ず指定し、全パスで透明を返す分岐を用意。

完全版コード(安定動作のサンプル)

以下は「標準コンポーネントのみ」「通知・型・比較を適正化」した、実戦投入可能なサンプルです。

&lt;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"&gt;

  &lt;ContentPage.Resources&gt;
    &lt;ResourceDictionary&gt;
      &lt;converters:SelectedAvatarMultiValueConverter x:Key="SelectedAvatarToStroke" /&gt;
    &lt;/ResourceDictionary&gt;
  &lt;/ContentPage.Resources&gt;

  &lt;Grid Padding="12"&gt;
    &lt;CollectionView ItemsSource="{Binding Avatars}"
                    SelectionMode="Single"
                    SelectedItem="{Binding SelectedAvatar, Mode=TwoWay}"&gt;

      &lt;CollectionView.ItemsLayout&gt;
        &lt;GridItemsLayout Span="4" Orientation="Vertical"/&gt;
      &lt;/CollectionView.ItemsLayout&gt;

      &lt;CollectionView.ItemTemplate&gt;
        &lt;DataTemplate x:DataType="models:AvatarCharacter"&gt;
          &lt;Border StrokeThickness="2" Padding="8"&gt;
            &lt;Border.Stroke&gt;
              &lt;MultiBinding Converter="{StaticResource SelectedAvatarToStroke}"&gt;
                &lt;Binding Path="." /&gt;
                &lt;Binding Path="BindingContext.SelectedAvatar"
                         RelativeSource="{RelativeSource AncestorType={x:Type ContentPage}}" /&gt;
              &lt;/MultiBinding&gt;
            &lt;/Border.Stroke&gt;

            &lt;VerticalStackLayout Spacing="4" HorizontalOptions="Center"&gt;
              &lt;Image Source="{Binding ImageUri}" WidthRequest="72" HeightRequest="72"/&gt;
              &lt;Label Text="{Binding Name}" /&gt;
            &lt;/VerticalStackLayout&gt;
          &lt;/Border&gt;
        &lt;/DataTemplate&gt;
      &lt;/CollectionView.ItemTemplate&gt;

    &lt;/CollectionView&gt;
  &lt;/Grid&gt;
&lt;/ContentPage&gt;
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 &gt; 0 ? values[0] as AvatarCharacter : null;
        var selected = values.Length &gt; 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)
        =&gt; throw new NotSupportedException();
}
public partial class AppShellViewModel : ObservableObject
{
    [ObservableProperty]
    private ObservableCollection&lt;AvatarCharacter&gt; avatars = new();

    [ObservableProperty]
    private AvatarCharacter? selectedAvatar;

    public async Task InitializeAsync()
    {
        var user = await _userService.GetCurrentUserAsync();
        var all  = await _avatarService.GetAllAsync();

        // UI スレッドでセット(重要)
        MainThread.BeginInvokeOnMainThread(() =&gt;
        {
            Avatars = new ObservableCollection&lt;AvatarCharacter&gt;(all);
            SelectedAvatar = Avatars.FirstOrDefault(a =&gt; 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 &amp;&amp; vm.Avatars.Count == 0)
        {
            await vm.InitializeAsync();
        }
    }
}

パフォーマンス・保守性の観点

  • Brush の再利用: 毎回 new SolidColorBrush せず静的インスタンスを共有。
  • 簡潔な比較: コンバータでは Id の比較だけにし、副作用を書かない。ログは #if DEBUG で抑制可能に。
  • ViewModel に処理を寄せる: 選択ロジックは VM に集約し、UI 側は「表示のみ」に徹する。

まとめ:直すべきポイントは3つだけ

  1. 初期化順序: ユーザー情報/SelectedAvatar を UI 表示より前、または直後に確実に設定し、変更通知を飛ばす。
  2. 安定した参照経路: RelativeSource などを使い、x:Reference による未確立の BindingContext を避ける。
  3. 値等価+Brush: Id ベースで比較し、Brush を返すコンバータで赤枠と透明を明示する。

この3点を押さえれば、CollectionView でも SfListView でも安定して「選択中のアバターを赤枠で強調」できます。デバッグログで状態を見える化しつつ、通知と初期化の順序を丁寧に整えることが、もっとも効果的な解決策です。

付録:トラブルシューティング早見表

症状想定原因対処
常に透明のままSelectedAvatar が null/通知なし初期化完了後にセットし、PropertyChanged を確認
赤枠が別アイテムに残るセル再利用+初期 Stroke 未設定透明で初期化し、全分岐で透明を返す
比較が合わない参照比較になっているId ベースの値等価に変更
たまに赤くならないx:Reference 解決前に評価RelativeSource へ移行/ページ参照に変更
スクロールで乱れる仮想化&再評価が走らないSelectedAvatar 更新時の通知を保証

この記事を書いた人

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

コメント

コメントする

目次