C#
ListView

C#でListViewの列編集をしたい

はじめに

2018-05-07_114256.png

こういうことをやろう、という話です。

プログラム

※コピペ利用は自由ですが、自己責任でお願いします。

  • ListView本体
ListViewEx.cs
/// <summary>
/// 編集可能ListView
/// </summary>
public class ListViewEx : ListView
{
    #region events
    /// <summary>セルの編集が開始された</summary>
    public event EventHandler BeginTextEdit;
    /// <summary>セルの編集が終了した</summary>
    public event EventHandler<TextEditEventArgs> AfterTextEdit;
    /// <summary>行追加された</summary>
    public event EventHandler<ListViewItemEventArgs> ItemAdded;
    /// <summary>行削除された</summary>
    public event EventHandler<ListViewItemEventArgs> ItemRemoved;
    #endregion

    #region properties
    /// <summary>編集可能にするキー値</summary>
    public ActivationEditing ActivateEditing { get; set; } = ActivationEditing.None;
    #endregion

    /// <summary>
    /// デフォルトコンストラクタ
    /// </summary>
    public ListViewEx() : base()
    {
        #region init value
        this.FullRowSelect = true; // 行全体
        this.View = View.Details; // 詳細モード
        this.MultiSelect = false; // 単一行のみ
        this.LabelEdit = false; // テキストボックスを表示するので、編集不可にしておく
        #endregion

        #region handle events
        // 編集開始
        this.MouseClick += (sender, e) =>
            {
                if (!ActivateEditing.HasFlag(ActivationEditing.MouseClick)) return;
                ListViewHitTestInfo info = this.HitTest(e.X, e.Y);
                if (info != null) ShowTextBox(info.Item, info.SubItem);
            };
        this.MouseDoubleClick += (sender, e) =>
            {
                if (!ActivateEditing.HasFlag(ActivationEditing.MouseDoubleClick)) return;
                ListViewHitTestInfo info = this.HitTest(e.X, e.Y);
                if (info != null) ShowTextBox(info.Item, info.SubItem);
            };
        this.KeyDown += (sender, e) =>
            {
                if (ActivateEditing.HasFlag(ActivationEditing.F2) && e.KeyCode == Keys.F2)
                    ShowTextBox(this.SelectedItems[0], this.SelectedItems[0].SubItems[0]); // 先頭カラム固定
            };

        // 行削除
        this.KeyDown += (sender, e) =>
            {
                if (e.KeyCode == Keys.Delete && this.SelectedItems.Count > 0)
                {
                    var item = this.SelectedItems[0];
                    var args = new ListViewItemEventArgs() { Item = item, Index = item.Index };                        

                    this.Items.Remove(item);
                    // ユーザ入力なので、イベント送信
                    ItemRemoved?.Invoke(this, args);
                }
            };

        #endregion
    }

    /// <summary>
    /// テキストボックスを表示
    /// </summary>
    /// <param name="item">編集する行</param>
    /// <param name="subItem">編集するセル</param>
    private void ShowTextBox(ListViewItem item, ListViewItem.ListViewSubItem subItem)
    {
        // 編集開始イベント送信
        BeginTextEdit?.Invoke(this, EventArgs.Empty);

        var text = new ListViewTextBox(this, item, subItem);
        // テキスト編集が終わったら、内容を確定させる
        text.AfterTextEditing += (sender, e) => 
            {
                if (e.Cancel) return;

                // 入力した値が空で1カラム目が空だったら、行削除にする
                if (string.IsNullOrEmpty(e.Text) && string.IsNullOrEmpty(item.SubItems[0].Text))
                {
                    var index = item.Index;
                    this.Items.Remove(item);
                    // 新規行未入力で行削除になっても、ItemRemovedイベント送信する
                    ItemRemoved?.Invoke(this, new ListViewItemEventArgs() { Item = item, Index = index });
                }
                else
                {
                    // テキストボックスの内容をセルに設定する
                    subItem.Text = e.Text;
                    // 編集終了イベント送信
                    AfterTextEdit?.Invoke(this, new TextEditEventArgs() { Row = text.Row, Column = text.Column, Text = e.Text });
                }
            };
        // タブ移動
        text.TabMoved += (sender, e) =>
            {
                if (e.Next) // 次のタブ
                {
                    if (e.Column < this.Columns.Count - 1) // 次の列
                        ShowTextBox(item, item.SubItems[e.Column + 1]);
                    else
                    {
                        if (e.Row < this.Items.Count - 1) // 次の行の先頭
                            ShowTextBox(this.Items[e.Row + 1], this.Items[e.Row + 1].SubItems[0]);
                        else // 最後のセル
                            this.Focus();
                    }
                }
                else // 前のタブ
                {
                    if (e.Column > 0) // 前の列
                        ShowTextBox(item, item.SubItems[e.Column - 1]);
                    else
                    {
                        if (e.Row > 0) // 前の行の最後
                            ShowTextBox(this.Items[e.Row - 1], this.Items[e.Row - 1].SubItems[this.Columns.Count - 1]);
                        else // 最初にセル
                            this.Focus();
                    }
                }
            };
        // テキストボックス表示
        text.Show();
    }
}
  • ListViewに表示させるテキストボックス
ListViewTextBox.cs
/// <summary>
/// セル編集用テキストボックス
/// </summary>
internal class ListViewTextBox : TextBox
{
    internal class ListViewTextEditingEventArgs : EventArgs
    {
        /// <summary>テキスト編集がキャンセルされたか?</summary>
        public bool Cancel { get; set; }
        /// <summary>編集確定後テキスト</summary>
        public string Text { get; set; }
    }
    /// <summary>テキスト編集終了</summary>
    internal event EventHandler<ListViewTextEditingEventArgs> AfterTextEditing;

    internal class ListViewTabMovedEventArgs : EventArgs
    {
        /// <summary>タブ移動前の行index</summary>
        public int Row { get; set; }
        /// <summary>タブ移動前の列index</summary>
        public int Column { get; set; }
        /// <summary>「次」への移動か?</summary>
        public bool Next { get; set; }
    }
    /// <summary>タブ移動</summary>
    internal event EventHandler<ListViewTabMovedEventArgs> TabMoved;

    private readonly ListViewItem item;
    private readonly ListViewItem.ListViewSubItem subItem;
    private readonly int column;

    internal int Row => item.Index;
    internal int Column => column;

    internal ListViewTextBox(ListViewEx parent, ListViewItem item, ListViewItem.ListViewSubItem subItem)
    {
        int border = 0;
        if (parent.BorderStyle == BorderStyle.FixedSingle) border = 1;
        else if (parent.BorderStyle == BorderStyle.Fixed3D) border = 2;

        this.Parent = parent;
        this.item = item;
        this.subItem = subItem;
        for (int i = 0; i < item.SubItems.Count; ++i)
            if (item.SubItems[i] == subItem)
            {
                this.column = i;
                break;
            }

        // font
        this.Font = parent.Font;
        this.Text = subItem?.Text;
        // size
        this.Width = parent.Columns[Column].Width;
        this.Height = subItem.Bounds.Height - border;
        this.Left = border + subItem.Bounds.Left;
        this.Top = subItem.Bounds.Top;
        // focus
        this.BringToFront();
        this.Select();
        this.SelectAll();

        this.Leave += (sender, e) => HideTextBox(this.Text, false);
        this.PreviewKeyDown += (sender, e) =>
            {
                // Enter
                if (e.KeyCode == Keys.Enter || e.KeyCode == Keys.Return) {
                    e.IsInputKey = true;
                    HideTextBox(this.Text, false);
                }
                // Escape
                else if (e.KeyCode == Keys.Escape) {
                    e.IsInputKey = true;
                    HideTextBox(subItem.Text, true);
                }
                // Shift + Tab
                else if (e.KeyCode == Keys.Tab && (e.Modifiers & Keys.Shift) == Keys.Shift) {
                    e.IsInputKey = true;
                    TabMoved?.Invoke(this, new ListViewTabMovedEventArgs() { Row = this.Row, Column = this.Column, Next = false });
                }
                // Tab
                else if (e.KeyCode == Keys.Tab) {
                    e.IsInputKey = true;
                    TabMoved?.Invoke(this, new ListViewTabMovedEventArgs() { Row = this.Row, Column = this.Column, Next = true });
                }
            };
    }

    private void HideTextBox(string text, bool cancel)
    {
        // テキストボックス非表示
        this.Visible = false;
        this.Dispose();
        // 編集終了イベント送信
        AfterTextEditing?.Invoke(this, new ListViewTextEditingEventArgs() { Text = text, Cancel = cancel });
    }
}
  • その他もろもろ(EnumやEventArgsの定義)
ListViewExEventArgs.cs
/// <summary>
/// 編集可能にするキー値
/// </summary>
[Flags]
public enum ActivationEditing
{
    /// <summary>なし</summary>
    None = 0,
    /// <summary>シングルクリック</summary>
    MouseClick = 1 << 0,
    /// <summary>ダブルクリック</summary>
    MouseDoubleClick = 1 << 1,
    /// <summary>F2</summary>
    F2 = 1 << 2,
}
public static class ActivationEditingHelper
{
    public static bool HasFlag(this ActivationEditing self, ActivationEditing flag)
        => (self & flag) == flag;
}

/// <summary>
/// 行追加・削除 Event Args
/// </summary>
public class ListViewItemEventArgs : EventArgs
{
    /// <summary>削除される行</summary>
    public ListViewItem Item { get; set; }
    /// <summary>削除される行のindex</summary>
    public int Index { get; set; }
}

/// <summary>
/// テキスト編集 Event Args
/// </summary>
public class TextEditEventArgs : EventArgs
{
    /// <summary>編集されたセルの行</summary>
    public int Row { get; set; }
    /// <summary>編集されたセルの列</summary>
    public int Column { get; set; }
    /// <summary>編集されたテキスト</summary>
    public string Text { get; set; }
}

解説

全体の方針

やり方は、他のサイトにも書かれている「選択されたセルにテキストボックスを貼り付けて、あたかもセルが編集できているかのように見せかける」と同じです。ListViewを継承して、ListViewExクラスを作成します。

処理のポイントを並べると、
①マウスクリックした位置のセル座標を求める
②セルの幅や高さに合わせてテキストボックスを貼り付ける
③テキストボックスの編集が確定したら、セルに値を反映させる
となります。文字で書くと普通のことをするだけなのですが、実際の処理は結構面倒な上、細かい注意点もあります。

では、各プログラムの解説に入ります。

①マウスクリックした位置のセル座標を求める

ListViewに限らず、マウスクリックの位置の座標を取得するには HitTest()メソッドを使います。DataGridViewTreeViewでも、マウスクリック位置のセルやノードの位置が欲しい場合があるので、このメソッドの存在を知っておくと便利です。

this.MouseDoubleClick += (sender, e) => {
    ListViewHitTestInfo info = this.HitTest(e.X, e.Y);
    if (info != null) ShowTextBox(info.Item, info.SubItem);
};

ダブルクリックでテキストボックスを表示します。ListViewExに定義した、ShowTextBox()メソッドでテキストボックスを表示を行います。

②セルの幅高さに合わせてテキストボックスを貼り付ける

ShowTextBox()メソッドの中は、セル編集用TextBoxをインスタンス化して表示するだけです。

private void ShowTextBox(ListViewItem item, ListViewItem.ListViewSubItem subItem)
{
    var text = new ListViewTextBox(this, item, subItem);
    ...(snip)...
    text.Show();
}

セル編集用TextBoxは、TextBoxから継承してListViewTextBoxクラスを作ります。細かいことですが、外部から使わせたくないので、internalにしておきます。ListViewTextBoxのコンストラクタで、テキストボックスのサイズや位置を設定します。

internal class ListViewTextBox : TextBox
{
    internal ListViewTextBox(ListViewEx parent, ListViewItem item, ListViewItem.ListViewSubItem subItem)
    {
        ...
  • テキストボックスのサイズ

まず、テキストボックスのサイズです。

// size
this.Width = parent.Columns[Column].Width;
this.Height = subItem.Bounds.Height - border;
this.Left = border + subItem.Bounds.Left;
this.Top = subItem.Bounds.Top;

subItem(セル)の位置や高さ幅に合わせればいいのですが、注意点が2つあります。1つ目は、ListViewのふちの幅を考慮する必要があり、何もしないと、微妙に大きいテキストボックスになってしまいます。そのため、HeightLeftborderの分だけ足し引きしています。borderの値は次のように求めています。

int border = 0;
if (parent.BorderStyle == BorderStyle.FixedSingle) border = 1;
else if (parent.BorderStyle == BorderStyle.Fixed3D) border = 2;

多分、定数がC#のライブラリ内に定義があると思うのですが、調べるのが面倒になりました。。。

2つ目の注意点は、WidthsubItem.Widthの値を使用すると、一番左の列は列全体の幅になってしまう、ということです。なぜそういう値になるのか分からないのですが、仕方ないので幅だけListView#Columns[]から算出しています。

  • テキストボックスの体裁

次にテキストボックスの体裁を整えます。フォントは親ウィンドウのListViewExに合わせておいたほうがよいでしょう。テキストボックスの初期値は、当然セルの値です。また、セルを選択状態にしているように見せかけるので、表示は最前、テキストを全選択にしておきます。

// font
this.Font = parent.Font;
this.Text = subItem?.Text;
// focus
this.BringToFront();
this.Select();
this.SelectAll();

これでテキスボックスの表示は完了です。

③-1 テキストボックスの編集の確定をハンドリングする

「テキストボックスの編集が確定したら~」と、さらっと書きましたが、これはユーザーどういう操作を行ったときでしょうか? TextBox自体に「編集の確定」というものはありません。ですので、プログラムを書く人間が、ユーザーがXXという操作をしたら「編集を確定する」と決めるしかありません。ルールが無いので慣習で決めることになるのですが、今回のケースでは、Enterキーを押したら確定、として良さそうです。キーの入力ハンドラーはPreviewKeyDownです。

this.PreviewKeyDown += (sender, e) => {
    // Enter
    if (e.KeyCode == Keys.Enter || e.KeyCode == Keys.Return)
    {
        e.IsInputKey = true;
        HideTextBox(this.Text, false);
    }
};

HideTextBox()メソッドでテキストボックスの内容反映とテキストボックス自体の非表示をします。

③-2 セルに値を反映させる

HideTextBox()メソッドの中身です。

private void HideTextBox(string text, bool cancel)
{
    // テキストボックス非表示
    this.Visible = false;
    this.Dispose();
    // 編集終了イベント送信
    AfterTextEditing?.Invoke(this, new ListViewTextEditingEventArgs() { Text = text, Cancel = cancel });
}

ListViewTextBoxの表示は毎回newしているため、非表示はDispose()で後始末するようにしています(ListViewTextBoxのインスタンスは1つだけ生成して、それを表示・非表示だけで使いまわす、という実装もアリだと思います)。

セルへの値の反映は、AfterTextEditingイベントを送信してListViewExへ通知することにしました。このハンドラーは、ListViewExShowTextBox()メソッド内で登録しています。

private void ShowTextBox(ListViewItem item, ListViewItem.ListViewSubItem subItem)
{
    var text = new ListViewTextBox(this, item, subItem);
    // テキスト編集が終わったら、内容を確定させる
    text.AfterTextEditing += (sender, e) => {

        if (e.Cancel) return;

        // 入力した値が空で1カラム目が空だったら、行削除にする
        if (string.IsNullOrEmpty(e.Text) && string.IsNullOrEmpty(item.SubItems[0].Text))
        {
            var index = item.Index;
            this.Items.Remove(item);
        }
        else
        {
            // テキストボックスの内容をセルに設定する
            subItem.Text = e.Text;
        }
    };
    ...

1列目が空文字列であった場合、行削除にしているのは、ListViewは1列目(ラベル)を空にできないからです。

その他考慮しておきたいこと

骨格は①~③を実装すれば動きますが、GUI上実装しておきたいことがいくつかあります。

編集のキャンセル

Enterで編集確定としましたが、編集をやめたいときもあります。これもプログラムを書く人間の決めの問題ですが、まぁEscapeでキャンセルでいいでしょう。ListViewTextBoxPreviewKeyDownイベントに追加しておきます。

this.PreviewKeyDown += (sender, e) => {
    // Enter
    if (e.KeyCode == Keys.Enter || e.KeyCode == Keys.Return)
    {
        e.IsInputKey = true;
        HideTextBox(this.Text, false);
    }
    // Escape
    else if (e.KeyCode == Keys.Escape)
    {
        e.IsInputKey = true;
        HideTextBox(subItem.Text, true);
    }
    ...

編集セルの「次へ進む」と「前へ戻る」

いるのか?という疑問もありますが、Tabで進む、Shift+Tabで戻るも実装しておきましょう。更にPreviewKeyDownイベントに追加します。

this.PreviewKeyDown += (sender, e) => {
    ...
    // Shift + Tab
    else if (e.KeyCode == Keys.Tab && (e.Modifiers & Keys.Shift) == Keys.Shift) {
        e.IsInputKey = true;
        TabMoved?.Invoke(this, new ListViewTabMovedEventArgs() { Row = this.Row, Column = this.Column, Next = false });
    }
    // Tab
    else if (e.KeyCode == Keys.Tab) {
        e.IsInputKey = true;
        TabMoved?.Invoke(this, new ListViewTabMovedEventArgs() { Row = this.Row, Column = this.Column, Next = true });
    }
};

TabMovedイベントは、ListViewに次のセルを編集状態にするために通知するために作ってあります。ListViewTextBoxListViewTextBox自体の表示や値のみの管理にすべきであり、ListViewの操作はしないほうが良いでしょう。そのため、TabMovedイベントでListViewに処理を委譲することにしました。

TabMovedイベントハンドラーは、長いですが特に変わったことはしていません。セル座標の計算が面倒なだけです。

text.TabMoved += (sender, e) =>
    {
        if (e.Next) // 次のタブ
        {
            if (e.Column < this.Columns.Count - 1) // 次の列
                ShowTextBox(item, item.SubItems[e.Column + 1]);
            else
            {
                if (e.Row < this.Items.Count - 1) // 次の行の先頭
                    ShowTextBox(this.Items[e.Row + 1], this.Items[e.Row + 1].SubItems[0]);
                else // 最後のセル
                    this.Focus();
            }
        }
        else // 前のタブ
        {
            if (e.Column > 0) // 前の列
                ShowTextBox(item, item.SubItems[e.Column - 1]);
            else
            {
                if (e.Row > 0) // 前の行の最後
                    ShowTextBox(this.Items[e.Row - 1], this.Items[e.Row - 1].SubItems[this.Columns.Count - 1]);
                else // 最初にセル
                    this.Focus();
            }
        }
    };

編集開始の方法

上の説明ではマウスダブルクリックにしていましたが、普通はクリックだろ、と考える人もいると思います。ExcelのようにF2で編集開始にしたい人もいるかもしれません。そういうことを設定できるようにしておいたほうが良いでしょう。ActivationEditingプロパティで指定できるようにしてあります。

ListViewの固定プロパティの設定

当たり前ですが、列編集できるListViewは詳細Detail表示の場合だけです。その他自動的に決まってしまうプロパティがあります。コンストラクタで設定してしまいましょう。

this.FullRowSelect = true; // 行全体
this.View = View.Details; // 詳細モード
this.MultiSelect = false; // 単一行のみ
this.LabelEdit = false; // テキストボックスを表示するので、編集不可にしておく

行選択は、本当はセル1つだけ選択状態にできればよかったのですが、ListViewではそういうものはありませんでした。また複数行が選択されると、編集開始が意味不明になるので単一行選択にしておきます。LabelEditは当然、自分で編集できる仕組みを用意したわけですから、falseにしておきます。

問題点

最後に、この実装の問題点を上げておきます。

  • 固定プロパティ値は、変更を禁止にできない
    詳細表示や、単一行のみ選択、といったプロパティは、列編集できるListViewでは固定プロパティ値なので、変更されても困るのですが、それを外部から変更禁止にできません。ListViewから継承で作ったからです(オブジェクト指向の観点から言うと、違反している)。完全に禁止したいなら、継承ではなく包含で実装するしかありません。

  • 編集セルの型はstringしか対応できていない
    チェックボックス(bool型)にしたい場合もあると思いますが、テキストボックス固定の実装となってしまっています。

  • ListViewTextBoxがリソースリークしているかも
    ちゃんと調べていないので。ざっと見た感じでは大丈夫そうですが、リソース解放するHidenTextBox()メソッドが呼ばれない処理があるかもしれません。

まとめ

ある程度柔軟な設定ができるようにしたかったのですが、難しいです。MS製ライブラリは昔から道から外れたことをしようとすると、とたんに難易度が跳ね上がるのですが、C#でも変わってないようです。本当に列編集したいなら、多分DataGridViewを使え、ということのように思いますが、DataGridViewもデータソースありきのインターフェースになっているため、使いにくいんですよね。