.NET MAUI Pickerで入力検索を実装する方法【SearchBar×Picker】

.NET MAUI の Picker は標準では「選択するだけ」のコントロールですが、業務アプリでは「タイピングして候補を絞り込みたい」というニーズがとても多いです。本記事では、SearchBar(または Entry)と Picker を組み合わせて、入力と同時に候補をフィルタし、実質的に「検索付きコンボボックス」として使う具体的な実装パターンを、サンプルコードと共に詳しく解説します。

目次

.NET MAUI の Picker に「入力して検索」を付けたい理由

業務システムやマスタ選択画面では、選択肢が数十〜数百件になることが珍しくありません。例えば次のようなケースです。

  • 顧客マスタから顧客名を選ぶ
  • 都道府県・市区町村などの地名を選ぶ
  • 製品コードや品番を選ぶ

このとき、標準 Picker の UI は「一つずつスクロールして選ぶ」スタイルで、候補数が多いと非常に操作が重くなります。ユーザー体験を良くするには、次のような動きを実現したいところです。

  • テキストを入力すると、候補が即時に絞り込まれる
  • 大文字・小文字、全角・半角、ひらがな・カタカナの違いにある程度寛容
  • 候補が1件に絞れたら、自動で選択される

しかし、.NET MAUI の Picker 自体には「入力欄」がありません。そのため、Picker 単体をカスタマイズして入力ボックスを埋め込むのではなく、検索用の入力欄(SearchBar や Entry)を別途用意し、その文字列に応じて Picker の ItemsSource をフィルタリングする構成を取るのがシンプルで現実的な解決策になります。

標準 Picker の仕様を軽くおさらい

実装に入る前に、.NET MAUI の Picker がどう動くかを簡単に整理しておきます。

プロパティ役割ポイント
ItemsSource表示するアイテム一覧通常は ObservableCollection<T> をバインド。フィルタはこのコレクションを書き換えて行う。
SelectedItem選択されているオブジェクトMVVM では ViewModel のプロパティにバインドして扱う。
SelectedIndex選択中インデックス古い XAML 系の慣れで使いたくなるが、基本は SelectedItem を使う方が安全。
ItemDisplayBinding表示用テキストの指定オブジェクトを ItemsSource に流し込むときは、ここで表示するプロパティを指定する。

今回のように「入力文字列に応じて候補を絞り込む」場合は、ItemsSource にバインドしているコレクション(FilteredItems)を書き換えるのが王道パターンです。

SearchBar+Picker で「検索付きコンボボックス」を作る

まずは最小構成のサンプルとして、次の要件を満たす実装を考えます。

  • SearchBar に入力されたテキストで、Picker の候補を部分一致検索
  • 入力するたびに即時フィルタ
  • 候補が 1 件になったら自動でその項目を選択(任意機能)
  • 検索文字列が空のときは全件表示に戻す

XAML:SearchBar と Picker を縦に並べる

まずは画面側の XAML です。検索用の SearchBar と選択用 Picker を縦に並べ、Picker の ItemsSource は ViewModel の FilteredItems にバインドしています。


&lt;VerticalStackLayout Padding="16"&gt;

    &lt;SearchBar Placeholder="項目を検索…"
               TextChanged="OnSearchTextChanged" /&gt;

    &lt;Picker x:Name="MyPicker"
            Title="項目を選択"
            ItemsSource="{Binding FilteredItems}" /&gt;

&lt;/VerticalStackLayout&gt;

ここでは簡単のために TextChanged イベントをコードビハインドで処理する形にしています。MVVM に寄せたい場合の書き方も後ほど紹介します。

ViewModel:全件リストと絞り込み結果を分離して管理する

検索機能を作るときにやってはいけないのが、「元のリスト(AllItems)」を直接削ったり並べ替えたりする実装です。一度壊してしまうと「全件表示に戻す」ことができなくなり、思わぬバグの元になります。

そのため、AllItems(全件)と FilteredItems(表示用)を分けて持ち、フィルタのたびに FilteredItems を作り直す構成にします。


using System.Collections.ObjectModel;

public class MyViewModel
{
    // 元データ(不変・削らない)
    public ObservableCollection&lt;string&gt; AllItems { get; } = new()
    {
        "Baboon", "Capuchin Monkey", "Blue Monkey",
        "Squirrel Monkey", "Golden Lion Tamarin",
        "Howler Monkey", "Japanese Macaque"
    };

    // 画面に表示する用(フィルタ結果)
    public ObservableCollection&lt;string&gt; FilteredItems { get; } = new();

    public MyViewModel()
    {
        // 初期状態は全件表示
        foreach (var item in AllItems)
        {
            FilteredItems.Add(item);
        }
    }

    // SearchBar の入力に応じて FilteredItems を作り直す
    public void Filter(string? query)
    {
        var q = (query ?? string.Empty)
            .Trim()
            .ToLowerInvariant();

        FilteredItems.Clear();

        foreach (var item in AllItems)
        {
            // 空文字なら全件、そうでなければ部分一致
            if (string.IsNullOrEmpty(q) ||
                item.ToLowerInvariant().Contains(q))
            {
                FilteredItems.Add(item);
            }
        }
    }
}

コツは次の 3 点です。

  • 元リスト(AllItems)は絶対に削らない
  • フィルタ結果用リスト(FilteredItems)を毎回 Clear() して作り直す
  • 空文字は「フィルタなし」と見なして全件を追加

こうしておけば、ユーザーが入力を消したときにも自然に全件表示に戻せます。

コードビハインド:SearchBar の入力イベントで Filter を呼ぶ

最後に、SearchBar と ViewModel をつなぐコードビハインドです。


public partial class MainPage : ContentPage
{
    public MyViewModel VM { get; } = new();

    public MainPage()
    {
        InitializeComponent();
        BindingContext = VM;
    }

    // SearchBar の TextChanged イベント
    void OnSearchTextChanged(object sender, TextChangedEventArgs e)
    {
        // 入力に応じてフィルタを実行
        VM.Filter(e.NewTextValue);

        // &quot;1件だけ&quot; に絞り込まれたら自動選択(任意機能)
        if (MyPicker.ItemsSource is IList list &amp;&amp; list.Count == 1)
        {
            MyPicker.SelectedItem = list[0];
        }
    }
}

この実装で、ユーザーが SearchBar に文字を入力するたびに Filter() が呼ばれ、Picker の候補が即座に絞り込まれるようになります。さらに、1 件に絞れたタイミングで自動選択してあげると、ユーザーはドロップダウンを開くことなく、そのまま次の入力に進めるため非常に快適です。

MVVM らしくする:TextChanged イベントをなくすパターン

前述のコードは分かりやすさ重視でコードビハインドのイベントを使いましたが、MVVM を徹底したい場合は SearchBar の Text を ViewModel にバインドし、プロパティの setter 内でフィルタを呼ぶパターンがよく使われます。

XAML:SearchBar.Text を ViewModel プロパティにバインド


&lt;VerticalStackLayout Padding="16"&gt;

    &lt;SearchBar Placeholder="項目を検索…"
               Text="{Binding SearchText, Mode=TwoWay}" /&gt;

    &lt;Picker Title="項目を選択"
            ItemsSource="{Binding FilteredItems}"
            SelectedItem="{Binding SelectedItem, Mode=TwoWay}" /&gt;

&lt;/VerticalStackLayout&gt;

ViewModel:SearchText の setter で自動フィルタ

INotifyPropertyChanged を実装した ViewModel を用意し、SearchText の値が変わるたびにフィルタを掛けます。


using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;

public class SearchablePickerViewModel : INotifyPropertyChanged
{
    public ObservableCollection&lt;string&gt; AllItems { get; } = new();
    public ObservableCollection&lt;string&gt; FilteredItems { get; } = new();

    private string? _searchText;
    public string? SearchText
    {
        get =&gt; _searchText;
        set
        {
            if (_searchText == value) return;
            _searchText = value;
            OnPropertyChanged();
            ApplyFilter();   // &lt;= ここでフィルタ
        }
    }

    private string? _selectedItem;
    public string? SelectedItem
    {
        get =&gt; _selectedItem;
        set
        {
            if (_selectedItem == value) return;
            _selectedItem = value;
            OnPropertyChanged();
        }
    }

    public SearchablePickerViewModel()
    {
        var source = new[]
        {
            "Baboon", "Capuchin Monkey", "Blue Monkey",
            "Squirrel Monkey", "Golden Lion Tamarin",
            "Howler Monkey", "Japanese Macaque"
        };

        foreach (var item in source)
        {
            AllItems.Add(item);
            FilteredItems.Add(item);
        }
    }

    private void ApplyFilter()
    {
        var q = (SearchText ?? string.Empty)
            .Trim()
            .ToLowerInvariant();

        FilteredItems.Clear();

        foreach (var item in AllItems)
        {
            if (string.IsNullOrEmpty(q) ||
                item.ToLowerInvariant().Contains(q))
            {
                FilteredItems.Add(item);
            }
        }

        // フィルタ結果に SelectedItem が含まれない場合は選択解除
        if (!string.IsNullOrEmpty(SelectedItem) &amp;&amp;
            !FilteredItems.Contains(SelectedItem))
        {
            SelectedItem = null;
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string? name = null)
        =&gt; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

このパターンのメリットは次の通りです。

  • コードビハインドにロジックを書かずに済む
  • 単体テストでフィルタ動作を検証しやすい
  • 同じ ViewModel を別のページやプラットフォームでも再利用できる

一方で、構造が少し重くなるので、簡単なサンプルレベルであれば前述のコードビハインド版でも十分です。本番コードでは MVVM 版を採用するのがおすすめです。

検索ロジックを実用レベルにするための工夫

実際の業務アプリでは、単純な Contains だけでは「思ったようにヒットしない」場面が出てきます。改善のために入れておくと良い工夫をいくつか紹介します。

大文字小文字を無視する

英字を含む項目では、大文字小文字を区別しない検索はほぼ必須です。サンプルでも紹介した通り、ToLowerInvariant() や ToUpperInvariant() を両側に掛けて比較すれば簡単に実現できます。


var q = (SearchText ?? string.Empty).Trim().ToLowerInvariant();

if (item.ToLowerInvariant().Contains(q))
{
    FilteredItems.Add(item);
}

前方一致・部分一致・複数キーワード

検索の種類を切り替えたい場合は、判定条件を切り替えるだけです。

種類条件例用途
前方一致item.StartsWith(q, StringComparison.OrdinalIgnoreCase)コードや品番など、先頭から打つケースが多い場合
部分一致item.IndexOf(q, StringComparison.OrdinalIgnoreCase) >= 0名称など途中の文字列でも検索したい場合
AND 検索スペース区切りで分割し、すべて Contains「東京 営業」のような複数キーワード検索

例えば、「スペース区切りのすべてのキーワードを含むものだけを表示する」場合は次のように書けます。


private void ApplyFilter()
{
    var raw = (SearchText ?? string.Empty).Trim();
    var keywords = raw
        .Split(' ', StringSplitOptions.RemoveEmptyEntries)
        .Select(x =&gt; x.ToLowerInvariant())
        .ToArray();

    FilteredItems.Clear();

    foreach (var item in AllItems)
    {
        var lower = item.ToLowerInvariant();

        bool match = keywords.All(k =&gt; lower.Contains(k));

        if (keywords.Length == 0 || match)
        {
            FilteredItems.Add(item);
        }
    }
}

日本語特有の問題(全角・半角、ひらがな・カタカナ)

日本語では、同じ単語でも次のような揺れが頻繁に発生します。

  • 「カタカナ」と「カタカナ」(半角・全角)
  • 「トウキョウ」と「トウキョウ」(半角カナ・全角カナ)
  • 「とうきょう」と「トウキョウ」(ひらがな・カタカナ)

厳密に対応するには正規化処理が必要ですが、簡易的には「ひらがなをカタカナに揃える」「全角・半角を変換する」などの前処理を噛ませることで、実用度が大きく向上します。文字種正規化を行うヘルパーメソッドを一つ用意しておくと便利です。

候補件数ごとのおすすめ UI 構成

Picker+SearchBar の構成が有効なのは、主に「数十〜数百件」程度の候補を扱う場合です。件数に応じて、UI 選択の目安をまとめておきます。

候補件数の目安おすすめ UI備考
〜20件程度Picker 単体スクロール数が少ないので、そのままでも問題になりにくい。
20〜200件程度SearchBar+Picker本記事のやり方。入力で絞り込みつつ、ドロップダウンで最終選択。
200〜1000件程度Popup+CollectionView検索ボックスとリストをポップアップ表示し、チェックボックスや詳細表示も組み込みやすい。
1000件以上サーバーサイド検索 / オートコンプリート全件ロードは避け、入力に応じてサーバーからページング取得する構成が望ましい。

Picker はプラットフォームごとに UI が大きく異なります(iOS はホイール、Android はダイアログやボトムシート、Windows はコンボボックス風など)。複数プラットフォームで「同じような見た目・動き」に揃えたい場合は、Popup+CollectionView で自前の疑似コンボボックスを作るのも有力な選択肢です。

Shell の SearchHandler を併用するパターン

Shell を使っているアプリでは、ページ上部に表示できる SearchHandler を活用し、検索結果から ViewModel の選択状態を更新する構成も取れます。

  • SearchHandler でページ全体の検索を行う
  • 選択されたアイテムを ViewModel に渡し、Picker の SelectedItem に連動
  • Picker は「現在の選択状態」の表示に専念させる

この方式では SearchHandler と Picker が直接統合されるわけではありませんが、「グローバル検索 → 詳細画面で Picker に現在値を表示」という UX を実現しやすくなります。

サードパーティコンポーネントを使う選択肢

.NET MAUI 用の UI コンポーネントを提供しているベンダーは、検索付きコンボボックスやオートコンプリートなどを標準で備えている場合があります。例えば Syncfusion などでは、次のようなコンポーネントが用意されています。

  • オートコンプリート(入力に応じてドロップダウン候補を表示)
  • マルチセレクト対応コンボボックス
  • タグ入力のように複数項目を選択できるコントロール

これらを使うと、今回解説したようなフィルタロジックや UI を自前で組む必要がなくなり、保守性も高まります。一方で、

  • 商用ライセンス費用
  • パッケージの更新管理
  • アプリサイズ増加

などのトレードオフもあるため、アプリ規模や開発コストと相談して採用を検討すると良いでしょう。

ハマりやすいポイントとチェックリスト

実際に実装していると、よく次のようなポイントでつまずきます。チェックリストとしてテーブルにまとめます。

よくある問題原因対策
検索しても Picker に反映されないBindingContext が設定されていない/違うインスタンスBindingContext = new MyViewModel(); の行があるか、ページ生成タイミングが正しいか確認する。
一度フィルタすると全件に戻せないAllItems を直接 Clear や Remove しているAllItems と FilteredItems を分け、フィルタは FilteredItems に対してだけ行う。
結果が重複するFilteredItems.Clear() を呼ばずに追加しているフィルタ前に必ず FilteredItems.Clear() を行ってから追加する。
SelectedItem が意図せず null になるフィルタで現在選択中の項目がリストから消えているフィルタ後に FilteredItems.Contains(SelectedItem) でチェックし、含まれない場合だけ null にするなど、挙動を意図的に決める。
デバッグ時は動くが、本番で重くなる候補数が増え、毎回の全件ループがボトルネック候補数が数千件を超える場合は、Picker ではなく CollectionView+サーバーサイド検索などへの移行を検討する。

応用:Entry を使ったオートコンプリート風 UI

「選択肢の見た目はテキストボックスのままにして、入力時だけ候補リストを出したい」という場合は、SearchBar の代わりに Entry を使い、横にボタンを置いてコンボボックス風に見せる手もあります。


&lt;Grid ColumnDefinitions="*,Auto"&gt;
    &lt;Entry Text="{Binding SearchText, Mode=TwoWay}"
           Placeholder="項目を検索…" /&gt;

    &lt;Button Grid.Column="1"
            Text="▼"
            Command="{Binding TogglePopupCommand}" /&gt;
&lt;/Grid&gt;

&lt;!-- Popup 部分は CommunityToolkit.Maui の Popup などを利用 --&gt;

Popup の中に CollectionView を置き、FilteredItems を ItemsSource にバインドすれば、WPF や WinForms のコンボボックスに近い UX を再現できます。

まとめ:Picker ではなく「SearchBar+Picker」という発想に切り替える

.NET MAUI の Picker 自体は入力欄を持たないため、「Picker を拡張して入力できるようにする」発想だと行き詰まりがちです。本記事で紹介したように、発想を次のように切り替えると実装が整理されます。

  • Picker 本体は「選択の最終確認」だけを担当させる
  • 入力とフィルタリングは SearchBar(または Entry)+ ViewModel の責務とする
  • 全件リストとフィルタ結果リストを分離し、Filter メソッドで毎回再構築する

実運用では、

  • 部分一致(Contains)+大文字小文字無視
  • 空文字で全件表示に戻す
  • 1 件に絞れたら自動選択するかどうかを要件に応じて調整
  • 候補数が多くなってきたら、Popup+CollectionView やサーバーサイド検索も検討

といった工夫を加えることで、ユーザーにとってストレスの少ない検索体験が実現できます。まずは本記事の最小サンプルをそのままコピーして動かし、自身のプロジェクトに合わせて ViewModel や検索ロジックをカスタマイズしてみてください。

この記事を書いた人

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

コメント

コメントする

目次