UWP ComboBoxでドロップダウンを開くと選択表示が消える問題の回避策(ContentPresenter差し替え)

UWP の ComboBox で、閉じているときは選択中の項目名が見えるのに、ドロップダウンを開いた瞬間だけ本体側の表示が消えてしまうことがあります。見た目の違和感だけでなく、現在値を確認しながら選び直したい場面で地味にストレスになります。開いている間も本体に選択値を残すための、実運用向けの回避策を具体例つきで解説します。

目次

現象の整理:何が起きているのか

問題の状況を一言でまとめると、「ComboBox を閉じているときは選択値が本体に表示されるのに、ドロップダウン(ポップアップ)を開くと本体側のテキストが空になる」というものです。リスト側では選択中の項目がハイライトされるため選択状態自体は失われていませんが、ユーザーが視線を本体側に戻した瞬間に“現在値”が読めないため、UX が落ちます。

特に次のような UI で影響が大きくなります。

  • 項目名が長く、リストでは省略されやすい(フォント名、デバイス名、プリンター名など)
  • 選択肢が多く、開いたままスクロールして比較したい
  • 入力支援や説明文が ComboBox の周囲にあり、視線移動が多い

再現しやすいパターン

標準の見た目では発生しないこともありますが、スタイルを少し触ったり、テーマ(Light/Dark)やアクセントカラーに合わせてテンプレートを差し替えたり、独自の ItemTemplate を入れたりすると、ドロップダウンを開いた状態で本体表示が消える/見えにくくなるケースが出てきます。原因が「色」なのか「要素の表示切り替え」なのかで対応が変わるため、まずは状況を切り分けるのがポイントです。

見え方よくある原因最初に確認したいこと
テキストが完全に消える(空っぽ)テンプレート内の表示要素が Open 状態で内容を持たなくなる/内部ロジックで上書きされるOpened のときに ContentPresenter の Content が null になっていないか
うっすら見える/背景と同化するVisualState により Foreground が背景色に近い値へ変化しているOpened 状態の Foreground/Opacity を Live Visual Tree で確認
PlaceholderText だけ出たり消えたりするPlaceholderTextBlock の Visibility 制御がテンプレートや内部状態と噛み合っていない未選択時と選択時で表示要素が分かれていないか

なぜ VisualState を直しても効かないことがあるのか

ComboBox の ControlTemplate には、選択値を表示するための ContentPresenter、未選択時に案内文を出すための PlaceholderTextBlock(またはそれに相当する TextBlock)などが含まれます。ここを “見た目” として編集すれば解決しそうに見えるのですが、現実には次のような事情でハマりやすいです。

  • ComboBox は開閉に合わせて内部状態が変化し、テンプレート内の一部プロパティ(Content や Visibility 等)が内部ロジックにより更新されることがある
  • スタイル側で値を固定しても、開いた瞬間に内部ロジックが再度設定して上書きすることがある
  • テーマや SDK バージョン、スタイルの差し替えによって、テンプレート内の要素構造や名前が変わる

つまり「VisualState をちょっと直す」だけでは、目的のタイミングで表示を維持できない場合があります。そこで、発想を切り替えて “開いた瞬間に本体の表示要素を自分で差し替える”アプローチを取ります。

解決策:DropDownOpened で ContentPresenter を差し替えて現在値を固定表示する

やることはシンプルで、ドロップダウンを開いたタイミング(DropDownOpened)で ComboBox のビジュアルツリーから選択表示担当の ContentPresenter を探し、そこに自前の TextBlock を入れてしまいます。表示したい文字列は SelectedItem から取り出し、未選択なら PlaceholderText を使います。

処理の流れ(実装の全体像)

タイミングイベントやること
テンプレート適用後LoadedApplyTemplate() を呼び、ContentPresenter を取得して保持する
開いた瞬間DropDownOpenedTextBlock を生成(または再利用)し、SelectedItem 由来のテキストをセットして ContentPresenter.Content に入れる
選択が変わったときSelectionChanged開いている最中でも TextBlock.Text を更新する(スクロール選択やタップ選択に追随)
閉じた瞬間DropDownClosed差し替えた Content をクリアして、テンプレート標準の表示に戻す(内部ロジックに委ねる)

サンプル:最小構成で再現してから直す

まずは “表示が消える” を再現できる最小構成を用意し、その上で修正コードを入れていくと、原因の切り分けがしやすくなります。以下は Item の表示に DisplayMemberPath を使う典型例です(フォント名などを想定)。

XAML(ComboBox とイベント)

<Page
    x:Class="Sample.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:Sample">


<Grid Padding="24">
    <ComboBox x:Name="FontCombo"
              Width="320"
              PlaceholderText="フォントを選択"
              DisplayMemberPath="FontName"
              Loaded="FontCombo_Loaded"
              DropDownOpened="FontCombo_DropDownOpened"
              DropDownClosed="FontCombo_DropDownClosed"
              SelectionChanged="FontCombo_SelectionChanged"/>
</Grid>


 

C#(ItemsSource の例)

using System.Collections.Generic;
using Windows.UI.Xaml.Controls;

public sealed partial class MainPage : Page
{
public MainPage()
{
InitializeComponent();


    FontCombo.ItemsSource = new List<FontItem>
    {
        new FontItem { FontName = "Item 1" },
        new FontItem { FontName = "Item 2" },
        new FontItem { FontName = "Item 3" },
    };
    FontCombo.SelectedIndex = 0;
}


}

public sealed class FontItem
{
public string FontName { get; set; }
} 

上記のような構成で、環境やスタイルによっては「閉じていると表示されるが、開くと本体側が空になる」現象が出ることがあります。次章の回避策を入れると、開いている間も本体側に選択値を出し続けられます。

実装例:VisualTreeHelper で ContentPresenter を見つけて差し替える

ComboBox のテンプレート内部にある ContentPresenter にアクセスするため、Loaded 時点でテンプレートを適用し、ビジュアルツリーから該当要素を探します。ポイントは次の 3 つです。

  • ApplyTemplate() を呼んでから探す(テンプレートがまだ生成されていないと見つからない)
  • 要素名が変わる可能性があるため、型(ContentPresenter)+名前で探す(名前が違う場合は型だけで探索)
  • 差し替えは DropDownOpened の瞬間だけ行い、DropDownClosed で元に戻す

C#(回避策のコア実装)

using System.Reflection;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media;

public sealed partial class MainPage : Page
{
private ContentPresenter _selectionPresenter;
private TextBlock _forcedTextBlock;


public MainPage()
{
    InitializeComponent();
}

private void FontCombo_Loaded(object sender, RoutedEventArgs e)
{
    var combo = (ComboBox)sender;

    // テンプレートを適用してから VisualTree を探索する
    combo.ApplyTemplate();

    // 既定テンプレートでは ContentPresenter という名前のことが多い
    _selectionPresenter = FindDescendant<ContentPresenter>(combo, "ContentPresenter");

    // もし見つからなければ、名前指定なしで最初の ContentPresenter を拾う(スタイル差し替え対策)
    if (_selectionPresenter == null)
    {
        _selectionPresenter = FindDescendant<ContentPresenter>(combo);
    }
}

private void FontCombo_DropDownOpened(object sender, object e)
{
    var combo = (ComboBox)sender;
    if (_selectionPresenter == null) return;

    EnsureTextBlock();
    _forcedTextBlock.Text = GetDisplayText(combo);

    // ここで自前の TextBlock を差し込む
    _selectionPresenter.Content = _forcedTextBlock;
}

private void FontCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    var combo = (ComboBox)sender;

    if (_forcedTextBlock == null) return;

    _forcedTextBlock.Text = GetDisplayText(combo);

    // 開いている最中の更新に追随させたい場合
    if (combo.IsDropDownOpen && _selectionPresenter != null)
    {
        _selectionPresenter.Content = _forcedTextBlock;
    }
}

private void FontCombo_DropDownClosed(object sender, object e)
{
    // 標準テンプレート側の表示に戻したいので、差し替えを解除する
    if (_selectionPresenter != null)
    {
        _selectionPresenter.ClearValue(ContentPresenter.ContentProperty);
    }
}

private void EnsureTextBlock()
{
    if (_forcedTextBlock != null) return;

    _forcedTextBlock = new TextBlock
    {
        VerticalAlignment = VerticalAlignment.Center,
        TextTrimming = TextTrimming.CharacterEllipsis,
        IsHitTestVisible = false
    };
}

private static string GetDisplayText(ComboBox combo)
{
    // 未選択時:PlaceholderText を表示(空なら空文字)
    if (combo.SelectedItem == null)
    {
        return combo.PlaceholderText ?? string.Empty;
    }

    // DisplayMemberPath がある場合は、そのプロパティ値を優先する
    var displayMemberPath = combo.DisplayMemberPath;
    if (!string.IsNullOrWhiteSpace(displayMemberPath))
    {
        var prop = combo.SelectedItem.GetType().GetRuntimeProperty(displayMemberPath);
        if (prop != null)
        {
            var value = prop.GetValue(combo.SelectedItem);
            return value?.ToString() ?? string.Empty;
        }
    }

    // 最後の手段:ToString()
    return combo.SelectedItem.ToString();
}

private static T FindDescendant<T>(DependencyObject root, string name = null) where T : DependencyObject
{
    if (root == null) return null;

    var count = VisualTreeHelper.GetChildrenCount(root);
    for (int i = 0; i < count; i++)
    {
        var child = VisualTreeHelper.GetChild(root, i);

        if (child is T typed)
        {
            if (string.IsNullOrEmpty(name))
            {
                return typed;
            }

            if (child is FrameworkElement fe && fe.Name == name)
            {
                return typed;
            }
        }

        var result = FindDescendant<T>(child, name);
        if (result != null) return result;
    }

    return null;
}


} 

この方法の狙いは単純で、ComboBox の内部が「開いたときに SelectionBox 側の表示を空にする」挙動をしたとしても、SelectedItem は残っているので、そこから文字列を復元して自前の TextBlock に出してしまう、という考え方です。

実装を安定させるコツ

VisualTree を触る実装は、動けば強力ですが、運用では「思ったより壊れやすい」側面もあります。ここでは、壊れにくくするための実践的な工夫をまとめます。

ContentPresenter が取れないときの考え方

カスタムスタイルを当てていると、テンプレート内の要素名が変わり、”ContentPresenter” という名前が存在しないことがあります。その場合は次の順番で対処するとスムーズです。

  • まずは Live Visual Tree(Visual Studio)で、ComboBox のテンプレート内部構造を確認する
  • 選択値を表示している要素が ContentPresenter ではない場合、該当する要素型に合わせて探索を変える
  • 名前指定で取れないときは、型指定のみで最初の ContentPresenter を拾う(ただし誤検出の可能性があるため、必要に応じて条件を追加する)

DisplayMemberPath / ItemTemplate を使っている場合の文字列化

SelectedItem は object なので、単純に ToString() すると「型名が出る」「空文字になる」といった事故が起きます。実運用では、次のルールで表示文字列を決めるのがおすすめです。

データ構造おすすめの取り出し方理由
Items が stringSelectedItem?.ToString()そのまま表示して問題になりにくい
DisplayMemberPath を指定DisplayMemberPath のプロパティを Reflection で読むComboBox の表示ルールと揃えやすい
ItemTemplate を指定「表示用の文字列プロパティ」を ViewModel 側に用意して参照するテンプレートの見た目と表示ロジックを分離できる

ItemTemplate の見た目を完全に再現したい場合は、TextBlock での固定表示ではなく、テンプレートを流用した ContentControl を差し込む方法もあります。ただしテンプレート流用は依存関係が増えるため、まずは「開いている間に現在値が読める」ことを優先するなら TextBlock がシンプルです。

DropDownClosed での復元方法は「ClearValue」が安全

差し替えた Content を戻すとき、元の Content を保持してセットし直す方法もあります。しかし、既定テンプレートでは TemplateBinding や内部ロジックによって Content が更新される場合があり、保持した古い値を戻してしまうと “閉じたときの表示” がズレることがあります。

そのため、基本は ClearValue(ContentPresenter.ContentProperty) で「差し替えだけ解除して、標準の仕組みに戻す」ほうが事故が少なくなります。

SelectionChanged でも更新しておくとズレにくい

ドロップダウンを開いた後、ユーザーはリストをスクロールして別の項目をタップします。このとき SelectionChanged が発生しますが、開いている間に本体側の固定表示を更新しないと「本体は古い値のまま」になって混乱を招きます。上の実装例のように、SelectionChanged でも TextBlock.Text を更新しておくと体感品質が上がります。

MVVM で使いたい場合:Attached Property 化して再利用する

コードビハインドにイベントを並べる形は分かりやすい反面、画面数が増えると同じ処理をコピペしがちです。MVVM 寄りの設計にしたい場合は、Attached Property として「この ComboBox は開いている間も選択表示を維持する」を宣言的に付けられる形にすると運用が楽になります。

XAML(1行で有効化)

<Page
    ...
    xmlns:fix="using:Sample.Fixes">


<ComboBox ItemsSource="{x:Bind ViewModel.Fonts}"
          DisplayMemberPath="FontName"
          PlaceholderText="フォントを選択"
          fix:ComboBoxHeaderTextFix.IsEnabled="True"/>


 

C#(Attached Property の実装例)

using System.Reflection;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media;

namespace Sample.Fixes
{
public static class ComboBoxHeaderTextFix
{
public static readonly DependencyProperty IsEnabledProperty =
DependencyProperty.RegisterAttached(
"IsEnabled",
typeof(bool),
typeof(ComboBoxHeaderTextFix),
new PropertyMetadata(false, OnIsEnabledChanged));


    public static bool GetIsEnabled(DependencyObject obj) => (bool)obj.GetValue(IsEnabledProperty);
    public static void SetIsEnabled(DependencyObject obj, bool value) => obj.SetValue(IsEnabledProperty, value);

    // 内部的に Presenter と TextBlock を保持するための Attached Property
    private static readonly DependencyProperty PresenterProperty =
        DependencyProperty.RegisterAttached("Presenter", typeof(ContentPresenter), typeof(ComboBoxHeaderTextFix), new PropertyMetadata(null));

    private static readonly DependencyProperty TextBlockProperty =
        DependencyProperty.RegisterAttached("TextBlock", typeof(TextBlock), typeof(ComboBoxHeaderTextFix), new PropertyMetadata(null));

    private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var combo = d as ComboBox;
        if (combo == null) return;

        if ((bool)e.NewValue)
        {
            combo.Loaded += Combo_Loaded;
            combo.Unloaded += Combo_Unloaded;
            combo.DropDownOpened += Combo_DropDownOpened;
            combo.DropDownClosed += Combo_DropDownClosed;
            combo.SelectionChanged += Combo_SelectionChanged;
        }
        else
        {
            combo.Loaded -= Combo_Loaded;
            combo.Unloaded -= Combo_Unloaded;
            combo.DropDownOpened -= Combo_DropDownOpened;
            combo.DropDownClosed -= Combo_DropDownClosed;
            combo.SelectionChanged -= Combo_SelectionChanged;
        }
    }

    private static void Combo_Loaded(object sender, RoutedEventArgs e)
    {
        var combo = (ComboBox)sender;
        combo.ApplyTemplate();

        var presenter = FindDescendant<ContentPresenter>(combo, "ContentPresenter");
        if (presenter == null)
        {
            presenter = FindDescendant<ContentPresenter>(combo);
        }

        combo.SetValue(PresenterProperty, presenter);

        var tb = new TextBlock
        {
            VerticalAlignment = VerticalAlignment.Center,
            TextTrimming = TextTrimming.CharacterEllipsis,
            IsHitTestVisible = false
        };
        combo.SetValue(TextBlockProperty, tb);
    }

    private static void Combo_Unloaded(object sender, RoutedEventArgs e)
    {
        var combo = (ComboBox)sender;
        var presenter = combo.GetValue(PresenterProperty) as ContentPresenter;
        if (presenter != null)
        {
            presenter.ClearValue(ContentPresenter.ContentProperty);
        }
    }

    private static void Combo_DropDownOpened(object sender, object e)
    {
        var combo = (ComboBox)sender;
        var presenter = combo.GetValue(PresenterProperty) as ContentPresenter;
        var tb = combo.GetValue(TextBlockProperty) as TextBlock;

        if (presenter == null || tb == null) return;

        tb.Text = GetDisplayText(combo);
        presenter.Content = tb;
    }

    private static void Combo_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var combo = (ComboBox)sender;
        var presenter = combo.GetValue(PresenterProperty) as ContentPresenter;
        var tb = combo.GetValue(TextBlockProperty) as TextBlock;
        if (tb == null) return;

        tb.Text = GetDisplayText(combo);

        if (combo.IsDropDownOpen && presenter != null)
        {
            presenter.Content = tb;
        }
    }

    private static void Combo_DropDownClosed(object sender, object e)
    {
        var combo = (ComboBox)sender;
        var presenter = combo.GetValue(PresenterProperty) as ContentPresenter;
        if (presenter != null)
        {
            presenter.ClearValue(ContentPresenter.ContentProperty);
        }
    }

    private static string GetDisplayText(ComboBox combo)
    {
        if (combo.SelectedItem == null)
        {
            return combo.PlaceholderText ?? string.Empty;
        }

        var displayMemberPath = combo.DisplayMemberPath;
        if (!string.IsNullOrWhiteSpace(displayMemberPath))
        {
            var prop = combo.SelectedItem.GetType().GetRuntimeProperty(displayMemberPath);
            if (prop != null)
            {
                var value = prop.GetValue(combo.SelectedItem);
                return value?.ToString() ?? string.Empty;
            }
        }

        return combo.SelectedItem.ToString();
    }

    private static T FindDescendant<T>(DependencyObject root, string name = null) where T : DependencyObject
    {
        if (root == null) return null;

        var count = VisualTreeHelper.GetChildrenCount(root);
        for (int i = 0; i < count; i++)
        {
            var child = VisualTreeHelper.GetChild(root, i);

            if (child is T typed)
            {
                if (string.IsNullOrEmpty(name))
                {
                    return typed;
                }

                if (child is FrameworkElement fe && fe.Name == name)
                {
                    return typed;
                }
            }

            var result = FindDescendant<T>(child, name);
            if (result != null) return result;
        }

        return null;
    }
}


} 

Attached Property 化しておくと、画面側は fix:ComboBoxHeaderTextFix.IsEnabled="True" を付けるだけで済みます。実装の入れ替えも 1 箇所でできるので、スタイル変更や SDK 更新でテンプレート構造が変わったときの保守が楽になります。

別案:見た目だけを補う「オーバーレイ表示」

VisualTree を触るのが怖い/テンプレートが頻繁に差し替わるプロジェクトでは、もっと単純に「ComboBox の上に TextBlock を重ねて現在値を表示する」方法もあります。たとえば Grid の中で ComboBox と TextBlock を重ね、TextBlock は IsHitTestVisible=false にしてクリックを邪魔しないようにします。

この方式のメリットは、テンプレート内部に依存しないことです。デメリットは、余白や矢印アイコンとの位置合わせなど、レイアウト調整が必要になる点です。プロジェクトの UI ルールによっては、こちらのほうが “長期的に壊れにくい” こともあります。

方法メリットデメリット向いているケース
ContentPresenter 差し替え(本記事の方法)既存のテンプレート内で完結し、見た目が揃いやすいテンプレート構造に依存し、スタイル変更で壊れる可能性スタイルが安定している/既定テンプレートに近い
オーバーレイ TextBlockテンプレート内部に依存せず、壊れにくいレイアウト調整が必要/アクセシビリティ対応に注意テーマやスタイル差し替えが多い
ControlTemplate を全面的に作り直す挙動も見た目も完全にコントロールできる工数が大きい/OS や SDK の変更に追随が必要デザインシステムが固定で、長期運用する

よくあるつまずきと対処

開いている間は表示されるが、閉じた後に空になる

DropDownClosed で ContentPresenter.Content を “元の値” に戻してしまうと、内部ロジックの更新と競合して空になったり古い値になったりします。基本は ClearValue で差し替えだけ解除し、閉じた後の表示は ComboBox 本来の仕組みに任せるほうが安定します。

ContentPresenter が複数あり、違うものを拾ってしまう

型だけで探索すると、テンプレート内の別用途の ContentPresenter を拾うことがあります。見つけたいのは「選択値表示」の Presenter なので、可能なら名前(x:Name)で絞り込みます。名前が取れないテンプレートなら、親要素の型(Grid など)や配置(矢印アイコンの近く)を手がかりに探索条件を追加すると安定します。

フォントや色が標準と合わない

自前 TextBlock は、既定テンプレートが持っている Foreground や FontSize の設定を引き継ぎません。見た目を揃えたい場合は、次のどれかを採用します。

  • TextBlock に FontSize や Foreground を明示的に設定する
  • ComboBox 自体の Foreground を参照して揃える(例:_forcedTextBlock.Foreground = combo.Foreground;)
  • アプリ側のリソース(ThemeResource)を参照して揃える

まずは「読めること」を優先し、必要になったら見た目を合わせる、という順で進めると手戻りが減ります。

まとめ:内部ロジックに勝ちたいなら「表示要素を握る」のが近道

UWP の ComboBox は、開閉状態に応じてテンプレート内の表示が切り替わるため、スタイル調整だけでは「開いている間も本体に現在値を残す」目的を達成できないことがあります。その場合は、DropDownOpened をフックして ContentPresenter を見つけ、SelectedItem から作った TextBlock を差し込むのが実用的です。

  • DropDownOpened で差し替え、DropDownClosed で ClearValue して戻す
  • SelectionChanged でも更新して “開いたまま選ぶ” 操作に追随する
  • スタイル変更が多いなら Attached Property 化して保守点を 1 箇所に集約する

「標準挙動と少し違うけれど、ユーザーにとって分かりやすい UI を優先したい」というときに、今回の回避策は効きます。まずは 1 つの ComboBox で試し、問題がなければ共通部品化して全画面に展開すると、UX の底上げにつながります。

この記事を書いた人

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

コメント

コメントする

目次