気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

ListBoxにてListBoxItemにTextBoxとButtonを配置してみる

f:id:AonaSuzutsuki:20200908171247p:plain

久しぶりにWPFのテクニックを紹介します。
ListBoxでファイルパスを管理する機会があって、TextBoxとButtonでファイルパスを入力して管理するというのをやりました。
ListBoxってただ文字列を配置するだけでなく、意外にも好き放題カスタマイズできるので参考になればと思います。
かなりややこしくなってしまったのでGitHubも同時に見てもらった方がいいかもしれません。

検証環境

項目 詳細
OS Windows 10 Pro x64 1903
.Net .Net Core 3.1
Prism.Core 7.2.0.1422
ReactiveProperty 7.2.0

.Net Frameworkでも動きます。

はじめに

普通はと言うと語弊がありますが、ListBoxをそのまま使うと文字列として各アイテムを表示させることになると思います。

f:id:AonaSuzutsuki:20200908170920p:plain

しかしながら、用途によっては画像を表示させたり複数の文字を置きたいこともあるでしょう。
そこで、WPFのListBoxでは表示内容(ListBoxItem)を詳細に定義することができ、文字列以外の独自クラスのオブジェクトでも表示させることができます
それを工夫すると表示だけでなくボタンの配置も可能で、当該アイテムに対して値の操作を行うことも容易にできます。

f:id:AonaSuzutsuki:20200908170949p:plain

例えば、これは実際に使ってるやつなんですが、TextBoxに入力するのもOK、...ボタンを押すとファイル選択ダイアログを開けて任意のファイルを選択するとTextBoxへパスが入力されます。
また、工夫すると空のダミーアイテムを末尾に追加しておいて入力したら正規のアイテムとして認識させ、再び末尾にダミーアイテムを追加するといったようなこともできます。

最小限の実装

以下のコードは必要な箇所以外は省略しています。
追加ボタンなどの実装はGitHubをご覧ください。

XAMLの実装

まず、独自構造での表示を行うにはItemTemplateを定義します。
ItemTemplateではListBoxの各項目の構造をDataTemplateで定義できます。
DataTypeは特に必須でもないですが、そんなシンボルないぞとResharperに警告される上にCtrlで飛べないので指定しておくといいでしょう。
また、TextListItemクラスは後ほど定義します。

Views/MainWindow.xaml

<Window x:Class="ListBoxPathTextBox.Views.MainWindow"
        xmlns:models="clr-namespace:ListBoxPathTextBox.Models"
...>

...

<ListBox
  Grid.Column="2"
  ItemsSource="{Binding TextList}"
  SelectedItem="{Binding TextListSelectedItem.Value}"
  HorizontalContentAlignment="Stretch">
  <ListBox.ItemTemplate>
    <DataTemplate DataType="models:TextListItem">
      <Grid Margin="5" >

        <Grid.ColumnDefinitions>
          <ColumnDefinition />
          <ColumnDefinition Width="5" />
          <ColumnDefinition Width="50" />
        </Grid.ColumnDefinitions>

        <TextBox Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />

        <Button
          Grid.Column="2"
          Name="ClearBt"
          Content="Clear"
          Height="25"
          Command="{Binding ClearTextCommand}" />
      </Grid>
    </DataTemplate>
  </ListBox.ItemTemplate>
</ListBox>

Bindingがいくつかでていますが、ここでのBindingはListBoxの各要素に対するBindingで、後に定義するTextListItemクラスにプロパティを持たせます。
また、ListBoxあるいはListBox.ItemContainerStyleでHorizontalContentAlignmentをStretchにしておかないとテキストボックスが短すぎて棒になります。
f:id:AonaSuzutsuki:20200908171034p:plain

Modelの実装

続いてModelのTextListItemクラスです。
ここではXAMLでも触れたように、ListBox.ItemTemplate内でBindingしたプロパティをここで定義します。
ListBoxにおける各要素のTextBoxへの書き換えやButtonの処理は全てここが担います

Models/TextListItem.cs

public class TextListItem : BindableBase
{
    #region Fields
    private string _text;
    #endregion

    #region Properties
    public string Text
    {
        get => _text;
        set => SetProperty(ref _text, value);
    }
    #endregion

    #region Event Properties
    public ICommand ClearTextCommand { get; set; }
    #endregion

    public TextListItem()
    {
        ClearTextCommand = new DelegateCommand(() => Text = string.Empty);
    }
}

MainWindowModelではViewModelに定義するプロパティの本体を定義しておきます。
ListBox.ItemSourceの本体への追加処理と削除処理、あとは削除可能かどうかをいじいじしてるだけなので特に説明はいらないかな。

Models/MainWindowModel.cs

public class MainWindowModel : BindableBase
{
    #region Fields
    private TextListItem _textListSelectedItem;
    private bool _canRemove;
    #endregion

    #region Properties
    public ObservableCollection<TextListItem> TextListItems { get; } = new ObservableCollection<TextListItem>();

    public TextListItem TextListSelectedItem
    {
        get => _textListSelectedItem;
        set
        {
            CanRemove = value != null;
            SetProperty(ref _textListSelectedItem, value);
        }
    }

    public bool CanRemove
    {
        get => _canRemove;
        set => SetProperty(ref _canRemove, value);
    }
    #endregion

    public void AddPathElement()
    {
        TextListItems.Add(new TextListItem());
    }

    public void RemovePathElement()
    {
        if (TextListSelectedItem == null)
            return;
        TextListItems.Remove(TextListSelectedItem);
    }
}

ViewModelの実装

ViewModelではListBox.ItemTemplate以外のBindingするプロパティを定義しておきます。
イベント以外のプロパティは全てModelに値の本体があるのでReactivePropertyによりバインディングしておきます。
あとはコマンドの処理をModelへ投げるだけですね。

ViewModels/MainWindowViewModel.cs

public class MainWindowViewModel
{
    #region Properties
    public ReadOnlyCollection<TextListItem> TextList { get; set; }
    public ReactiveProperty<TextListItem> TextListSelectedItem { get; set; }

    public ReactiveProperty<bool> CanRemove { get; set; }
    #endregion

    #region Event Properties
    public ICommand AddElementCommand { get; set; }
    public ICommand RemoveElementCommand { get; set; }
    #endregion

    public MainWindowViewModel(MainWindowModel model)
    {
        TextList = model.TextListItems.ToReadOnlyReactiveCollection(m => m);
        TextListSelectedItem = model.ToReactivePropertyAsSynchronized(m => m.TextListSelectedItem);
        CanRemove = model.ObserveProperty(m => m.CanRemove).ToReactiveProperty();

        AddElementCommand = new DelegateCommand(model.AddPathElement);
        RemoveElementCommand = new DelegateCommand(model.RemovePathElement);
    }
}

動作確認

これを実行するとこんな感じになります。
f:id:AonaSuzutsuki:20200908171158p:plain
TextBoxに書いた内容はしっかり該当オブジェクトに変更が適用され、Clearボタンも機能しているはずです。

しかしながら、常にTextBoxやButtonが表示されてしまっていたり、文字が長いとスクロールが表示されてちょっとブサイクなので次の項でその辺りを修正していきます。

文字列が長くても画面内に収める

テキストが長いとTextBoxのWidthが画面サイズを超えて横に伸びてしまいます。
テキストが短い前提なら別に問題ありませんが、Buttonの位置がずれるので使い勝手はあまりよろしくないかなと。
f:id:AonaSuzutsuki:20200908171055p:plain

これはListBoxのScrollViewer.HorizontalScrollBarVisibilityをDisableにすることで解決できます。

<ListBox
  Grid.Column="2"
  ItemsSource="{Binding TextList}"
  SelectedItem="{Binding TextListSelectedItem.Value}"
  HorizontalContentAlignment="Stretch"
  ScrollViewer.HorizontalScrollBarVisibility="Disabled" />

どうやらスクロールバーを無効化すると文字列に応じてサイズ変更も起きないようでGridに合わせたサイズで固定してくれます。
f:id:AonaSuzutsuki:20200908171213p:plain

ただし、TextBoxの中では伸びるので見栄えは悪くなるけどTextBoxのスクロールバーはAutoにしてもいいかも。

マウスオーバーの時だけButtonを表示する

現状はButtonが常に表示されているので、数が増えるとすごい見た目になってきます。
デザインによりけりな気はしますが、隠したい場合もあるでしょう。

そんな時はDataTemplate.TriggersでIsMouseOverがfalseの時だけ透明にするようなトリガーを定義します

<DataTemplate DataType="models:TextListItem">
  <Grid Margin="5" >
    ...

    <Button
      Grid.Column="2"
      Name="ClearBt"
      Content="Clear"
      Height="25"
      Command="{Binding ClearTextCommand}" />
  </Grid>

  <DataTemplate.Triggers>
    <Trigger Property="IsMouseOver" Value="false">
      <Setter Property="Opacity" Value="0" TargetName="ClearBt" />
    </Trigger>
  </DataTemplate.Triggers>
</DataTemplate>

するとマウスを該当アイテムの上に置いてる時だけButtonが表示されるようになります。
f:id:AonaSuzutsuki:20200908171230p:plain

TextBoxにフォーカスしている時だけボーダーを表示する

お次はTextBoxが常に表示されてしまうのをなんとかします。
数が増えるとボーダーの数が増えるのでこれもかなりの絵面になります。
Buttonを消すのならTextBoxも必要な時だけ主張させたいよね?

そんな時はTextBoxのTemplateを定義してあげて、デフォルトのBorderThicknessを0にしてボーダーを非表示にしておきます。
あとはIsKeyboardFocusWithinがTrueの時にBorderThicknessを1にするトリガーを作ってあげればTextBoxにキーボードフォーカスがある時だけボーダーが表示されます

<TextBox Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}">
  <TextBox.Resources>
    <Style TargetType="TextBox">
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="{x:Type TextBoxBase}">
            <Border Name="TextBoxBorder" BorderThickness="0" BorderBrush="{TemplateBinding BorderBrush}">
              <ScrollViewer Margin="0" x:Name="PART_ContentHost" />
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsKeyboardFocusWithin" Value="True">
                <Setter TargetName="TextBoxBorder" Property="BorderThickness" Value="1" />
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
  </TextBox.Resources>
</TextBox>

前回のボーダーカラーと違うのは途中で実装方法を変えたからなので気にしない。
f:id:AonaSuzutsuki:20200908171247p:plain

TextBoxをクリックと同時にListBoxItemを選択状態にする

ListBoxItemを選択せずにTextBoxをそのままクリックするとTextBoxにだけフォーカスが行ってListBoxItemは選択状態になりません
それで問題なければいいのですが、SelectedIndexやらそのあたりを使うならTextBoxにフォーカスが入った段階でListBoxItemも選択状態になって欲しいところ。

XAMLだけで対応できないっぽいので、TextBoxのGotFocusイベントとListBoxのSelectedItemを使ってフォーカスが入った段階でSelectedItemを書き換える方式で選択状態に移行させます。
なお、ここからはNugetからMicrosoft.Xaml.Behaviors.Wpfパッケージが必要です

Viewの実装

まず、View側で、ListBox.ItemTemplateのTextBoxにGotFocusイベントトリガーを定義します

Views/MainWindow.xaml

<Window ...
  xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
...>

...

<TextBox Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}">
  <i:Interaction.Triggers>
    <i:EventTrigger EventName="GotFocus">
      <i:InvokeCommandAction Command="{Binding TextBoxGotFocusCommand}" />
    </i:EventTrigger>
  </i:Interaction.Triggers>

  ...
</TextBox>

Modeの実装

次に、TextListItemクラスにTextBoxGotFocusCommandプロパティを定義し、メソッド本体と紐づけていきます。
TextBoxGotFocusCommandが実行されると、後に出てくるTextBoxGotFocusActionデリゲートを実行します
この時にthis及び、自身のオブジェクトを渡すところがポイントです。

Models/TextListItem.cs

public Action<TextListItem> TextBoxGotFocusAction { get; set; }
public ICommand TextBoxGotFocusCommand { get; set; }

public TextListItem()
{
    TextBoxGotFocusCommand = new DelegateCommand(() => TextBoxGotFocusAction?.Invoke(this));
}

最後にListBoxItemを追加する際にTextListItem#TextBoxGotFocusActionにTextListSelectedItemを引数でもらってきたオブジェクトに置き換える処理を移譲します
すると、TextBoxにフォーカスが入った段階でTextListSelectedItemが当該ListBoxItemオブジェクトに置き換えられるため選択状態が変わります。

Models/MainWindowModel.cs

public void AddPathElement()
{
    TextListItems.Add(new TextListItem
    {
        TextBoxGotFocusAction = item => TextListSelectedItem = item
    });
}

TextBoxからフォーカスを外す

現段階ではListBoxだとListBoxItemの無い空の部分をクリックしてもフォーカスが外れません。
ListViewだと外れるんですが、ListBoxはどうやらだめみたい。
試行錯誤したものの結局対応できずにMouseDownイベントで対応することに。
そして更に問題があって、ただフォーカスを外すだけならKeyboard.ClearFocusメソッドでできるんですが、それだとLostFocusイベントが発火しない・・・
ということで、ListBoxにフォーカスを当てることでTextBoxからフォーカスを外すというちょっと複雑なことをやります。

なお、全文は書かないので省略してるところはGitHubよりご覧ください。

Viewの実装

まずはXAMLから。
ここではListBoxに適当な名前を付けておき、MouseDownイベントのトリガーを定義します。

Views/MainWindow.xaml

<ListBox
  Name="TextList"
  Grid.Column="2"
  ItemsSource="{Binding TextList}"
  SelectedItem="{Binding TextListSelectedItem.Value}"
  HorizontalContentAlignment="Stretch"
  ScrollViewer.HorizontalScrollBarVisibility="Disabled">

  <i:Interaction.Triggers>
    <i:EventTrigger EventName="MouseDown">
      <i:InvokeCommandAction Command="{Binding TextListMouseDownCommand}" />
    </i:EventTrigger>
  </i:Interaction.Triggers>

  ...

</ListBox>

続いて、IClearFocusインタフェースでClearFocusメソッドを定義します。

Views/IClearFocus.cs

public interface IClearFocus
{
    void ClearFocus();
}

最後にMainWindow.xaml.cs、コードビハインドのMainWindowクラスでIClearFocusインタフェースを継承させ、ClearFocusメソッド内でTextList(ListBox)にフォーカスを当てる処理を入れておきます
MainWindowViewModelはまだ修正していないのでコンパイルエラーが出るのは正常です。

Views/MainWindow.xaml.cs

public partial class MainWindow : Window, IClearFocus
{
    public MainWindow()
    {
        InitializeComponent();

        var model  = new MainWindowModel();
        DataContext = new MainWindowViewModel(model, this);
    }

    public void ClearFocus()
    {
        TextList.Focus();
    }
}

ViewModelの実装

ViewModelではコンストラクタの引数にIClearFocusインタフェースを追加し、そのClearFocusメソッドをTextListMouseDownCommandに移譲してあげればOKです。
ListBoxのMouseDownイベントはListBoxItem上では発動しないようなのでこれで要素が存在しないところをクリックするとフォーカスを外すことができます。
更に、Keyboard.ClearFocusメソッドとは異なりTextBoxのLostFocusイベントも発火するので、LostFocusイベントで処理したい場合はこちらを使う方が良いかと思います。

ついでにmodel.TextListSelectedItemをnullにするとListBoxItemの選択も外すことができます。

ViewModels/MainWindowViewModel.cs

public ICommand TextListMouseDownCommand { get; set; }

public MainWindowViewModel(MainWindowModel model, IClearFocus clearFocus)
{
    ...

    TextListMouseDownCommand = new DelegateCommand(() => {
        clearFocus.ClearFocus();
        model.TextListSelectedItem = null;
    });
}