ContextMenuStripに関する各種Tips・その2(スクロール)


C#(dotNetFramework)のContextMenuStripですが、項目が多すぎて画面内などに表示しきれないと、このようにコンテキストメニューの上下にスクロール用のボタンが表示されます。
以前別の記事でもぼやきましたが、このスクロールボタンは縦幅が狭すぎてとっても押しづらく、しかも画面幅一杯に表示されているからといって画面上端/下端をクリックしても、メニュー外の場所がクリックされたと判断されてしまうのか、コンテキストメニューが閉じられてしまう・・・という凶悪な仕様になっています。
(キーボード操作であれば、カーソルキー[↑][↓]で1項目ずつ項目移動することで辛うじてスクロールできますが・・・これも面倒くさいです・・・)
この記事では、標準の使いにくいスクロール操作以外の方法でもコンテキストメニューをスクロール出来るようにする方法を、紹介します。

ContextMenuStripクラスに用意されているスクロール用のメソッド・プロパティ

dotNetFramework(2.0)では、スクロールに使用できそうなpublic/protectedメソッド・プロパティは、一切用意されていません・・・。
スクロール位置の把握も、そもそも表示内容がオーバーフローせずにすべて表示しきれているかを知ることもできません。
特定のToolStripMenuItemをSelect()してもスクロールくれません。ListViewのEnsureVisible()のようなメソッドが用意されているわけでもありません。

なので、まっとうな手段でContextMenuStripをスクロールさせるには、
(1)Win32APIのSendInput()を用いるなどして、スクロールボタンを必要な回数だけマウスでクリックしたかのように装う
もしくは
(2)SendKeys.Send("{UP}"または"{DOWN}")を用いて、必要な回数だけカーソル[↓][↑]キー操作をContextMenuStripに送りつける
くらいしか方法がない、ということになります。

かといって(1)はスクロールボタンの座標計算が厄介だし、(2)だとカーソルキー操作によって選択中の項目が変わってしまうのが微妙です。
ここではまっとうではない手段に頼ることにしてみましょう。

ToolStripDropDownMenuクラスに用意されているメソッド類

dotNet Framework 2.0の場合)
ContextMenuStripの継承元クラスToolStripDropDownMenuクラスには、非public/protectedですが、以下のようなスクロール操作のためのメソッドが用意されています。

ScrollInternal(bool up)
 →up=trueなら上に、up=falseなら下に、1行スクロールする

ChangeSelection(ToolStripItem item)
 →引数指定の項目が画面上に表示されるよう、ContextMenuStrip内をスクロールする
 ※メソッド名とは裏腹に、itemのselectedは変化しないようです・・・?

また、スクロールボタンについては、privateなフィールドとして

ToolStripControlHost upScrollButton
ToolStripControlHost downScrollButton
 ※厳密にはToolStripControlHostのサブクラスです

が用意されており、スクロールボタン表示時はボタンオブジェクトが、非表示ならばnullがセットされているようです。
ということで、リフレクションで強引にこれらのメソッド等を呼び出すことで、スクロール操作を実現することが出来ます。

ContextMenuStrip内の特定のToolStripMenuItemを選択し、その位置までスクロールする

ContextMenuStripのメソッドとして実装すると、こんな感じになります。

class ContextMenuStripEx : ContextMenuStrip
{
    public void SelectItem(ToolStripItem item)
    {
        item.Select();
        Type t = typeof(ToolStripDropDownMenu);
        MethodInfo m = t.GetMethod("ChangeSelection",
            BindingFlags.NonPublic | BindingFlags.InvokeMethod | BindingFlags.Instance,
            null, new Type[] { typeof(ToolStripItem) }, null);
        if (m != null)
        {
            m.Invoke(this, new object[] { item });
        }
    }

マウスホイール操作でContextMenuStripをスクロールさせる

ContextMenuStrip上でマウスホイール操作を行っても、ContextMenuStripでは何も起こりません。
その代わりに、ContextMenuStripの呼出元コントロールのほうでマウスホイールの動作が行われてしまいます。(ListViewやComboBoxであればスクロールします)
ホイールスクロール操作を実現するために、ContextMenuStripでMouseWheelイベントを処理します。

まずはスクロール用のメソッドを実装しておきましょう。
スクロールボタンを押しても大丈夫なときだけScrollInternal()を呼び出すようにします。(押せない状態なのにScrollInternal()を呼び出すと例外が発生します)

class ContextMenuStripEx : ContextMenuStrip
{
    public void PerformScroll(bool up, int count)
    {   
        Type t = typeof(ToolStripDropDownMenu);
        FieldInfo p = t.GetField((up ? "upScrollButton" : "downScrollButton"),
            BindingFlags.NonPublic | BindingFlags.GetField | BindingFlags.Instance);
        if (p != null)
        {   //ボタンのオブジェクトを取得、スクロール処理のメソッドも確保
            ToolStripControlHost scrollButton = p.GetValue(this) as ToolStripControlHost;
            MethodInfo m = t.GetMethod("ScrollInternal",
                BindingFlags.NonPublic | BindingFlags.InvokeMethod | BindingFlags.Instance,
                null, new Type[] { typeof(bool) }, null);
            for (int i = 0; i < count; i++)
            {
                if (scrollButton != null && scrollButton.Visible && scrollButton.Enabled && m != null)
                {   //該当ボタンが押せる状態であれば、スクロール用メソッドを呼び出す
                    m.Invoke(this, new object[] { up });
                }
            }
        }
    }

なおContextMenuStripのスクロールボタンは既存バグを抱えているのか、本来スクロール可能な範囲よりも1行分余計にスクロールできてしまうような押下可否制御を行っているようです。(その余計に1行分スクロールした領域には、無駄な空白項目が表示されます)
ちょっと気持ち悪いのですが、マウスでスクロールボタン操作したときと同じ挙動になるよう、あえてバグフィックスしないことにしておきます。

本体のマウスホイールイベントはこんな感じになります。

    protected override void OnMouseWheel(MouseEventArgs e)
    {
        //5件分スクロールさせ、処理済み状態にする
        PerformScroll(e.Delta > 0, 5);
        ((HandledMouseEventArgs)e).Handled = true;
        //本来の処理
        base.OnMouseWheel(e);
    }

PageUp/PageDownキーなどでもスクロールさせる

KeyUpイベントでキーに応じた処理を行うことで実現可能です。
PageUp/PageDownキーの場合は10行分スクロールすることとし、スクロールすることで選択中の項目が表示領域外になってしまったら適切な項目を選択しなおせばよいわけです。
なおContextMenuStripは、ToolStripMenuItemに設定したAltアクセスキーを押しても、選択項目へフォーカス(selected)は移動するものの連動してスクロールしてくれない・・・という残念な仕様になってるので、これも表示領域外の項目が選択されているときにはスクロールさせておきましょう。
以下のようなソースになります。

class ContextMenuStripEx : ContextMenuStrip
{
    protected override void OnKeyUp(KeyEventArgs e)
    {
        //まず、選択中のメニュー項目、およびコンテキストメニューの表示領域を把握する
        ToolStripItem item = null;
        foreach (ToolStripItem i in this.Items)
        {
            if (i.Selected) { item = i; break; }
        }
        Rectangle displayRect = this.RectangleToClient(this.Bounds);
        if (e.KeyCode == Keys.Up || e.KeyCode == Keys.Down || e.KeyCode == Keys.Left || e.KeyCode == Keys.Right)
        {   //矢印キー関連は何もしない
        }
        else if (e.KeyCode == Keys.Apps)
        {   //アプリケーションキー関連も何もしない
        }
        else if (e.KeyCode == Keys.PageUp)
        {   //10件分上スクロール
            PerformScroll(true, 10);
            if (item != null && displayRect.Contains(item.Bounds) == false)
            {   //現在選択中のアイテムが表示領域外ならば、アイテム末尾から表示領域内のアイテムを探索
                for (int i = this.Items.Count - 1; i >= 0; i--)
                {
                    item = this.Items[i];
                    if (displayRect.Contains(item.Bounds) && item.CanSelect)
                    {   //表示領域内のアイテムを選択する
                        item.Select();
                        break;
                    }
                }
            }
        }
        else if (e.KeyCode == Keys.PageDown)
        {   //10件分下スクロール
            PerformScroll(false, 10);
            if (item != null && displayRect.Contains(item.Bounds) == false)
            {   //現在選択中のアイテムが表示領域外ならば、アイテム先頭から表示領域内のアイテムを探索
                for (int i = 0; i < this.Items.Count; i++)
                {
                    item = this.Items[i];
                    if (displayRect.Contains(item.Bounds) && item.CanSelect)
                    {   //表示領域内のアイテムを選択する
                        item.Select();
                        break;
                    }
                }
            }
        }
        else
        {   //アクセスキーによる操作とみなし、表示領域外の場合は選択中アイテムの位置へスクロール移動する
            if (item != null && displayRect.Contains(item.Bounds) == false)
            {
                Type t = typeof(ToolStripDropDownMenu);
                MethodInfo m = t.GetMethod("ChangeSelection",
                    BindingFlags.NonPublic | BindingFlags.InvokeMethod | BindingFlags.Instance,
                    null, new Type[] { typeof(ToolStripItem) }, null);
                if (m != null)
                {
                    m.Invoke(this, new object[] { item });
                }
            }
        }
        base.OnKeyUp(e);
    }

おわりに

最後にちゃぶ台をひっくり返すようなことを書いてしまいますが、そもそもスクロールが必要なほどコンテキストメニューの項目が膨れ上がるのであれば、おとなしくメニューの階層化やContextMenuStrip以外のコントロールを使うことを検討したほうが良いと思われます。
また、今回記事の内容、前回のContextMenuStripに関する各種Tips・その1の記事の内容を見ても分かるとおり、ContextMenuStripは細かな不具合が随所に埋もれているため、そもそもContextMenuStripは使わずに旧来のContextMenuを用いるほうが無難かもしれません・・。
(ContextMenuStripはHNXgrepでも多用していますが、前回記事・今回記事の内容をすべてソースコードに取り込むことで、ようやく実用に耐える状態になりました・・・・)

以上、ContextMenuStripの不具合に関する情報はなぜかWeb上でほとんど見当たらないので、参考までに書き留めておきます。