プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

NotifyIconのContextMenuStripをメインディスプレイ限定で表示させたい

(21:30頃: 対応後のコードにて使用していたScreen.PrimaryScreen.BoundsScreen.FromPoint(point).Boundsに変更)

NotifyIconにContextMenuStrip(=右クリック時のポップアップメニュー)を設定して表示する通常の方法だと、メインディスプレイのタスクバー表示位置が左側に、かつサブディスプレイが左側にある環境では、NotifyIconの右クリックメニューがサブディスプレイに表示されてしまいます。 本記事ではその現象をリフレクションを使うことで回避し、常にメインディスプレイにてNotifyIconの右クリックメニューを表示する方法を紹介します。

環境

Windows 10

  • Version: 2004
  • OS Build: 19041.572

.NET Framework

  • Version: 4.8.04084

現象説明

  • ディスプレイ2枚を横方向に配置
  • 右のディスプレイをメインディスプレイに設定
  • メインディスプレイのタスクバーを左側に設定
  • NotifyIconとContextMenuStripを使用するプログラムを書いて実行
  • NotifyIcon箇所を右クリック

という手順を踏むと、以下の画像のようにContextMenuStripが左のサブディスプレイ側へ表示されてしまいます:

対応前のContextMenuStrip表示

直感的には、通知領域(=タスクトレイ)がメインディスプレイにあるのですからContextMenuStripもメインディスプレイに表示されてほしいものです。

上記現象の確認に使用したコードは以下のものになります:

using System;
using System.Drawing;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public class MainForm : Form
    {
        private readonly NotifyIcon _notifyIcon;
        private readonly ContextMenuStrip _contextMenuStripForNotifyIcon;

        public MainForm()
        {
            this._contextMenuStripForNotifyIcon = new ContextMenuStrip();
            this._contextMenuStripForNotifyIcon.Items.Add("Popup menu text");
            this._notifyIcon = new NotifyIcon()
            {
                Icon = SystemIcons.Information, // アイコン未設定だと通知領域に表示されないので、適当なものを設定
                Visible = true, // 初期値falseなので表示するには設定が必要
                ContextMenuStrip = this._contextMenuStripForNotifyIcon, // 表示したいメニュー
            };
        }
    }

    internal static class Program
    {
        [STAThread]
        private static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new MainForm());
        }
    }
}

対応策

ここからの記述にはMITライセンスであるwinformsリポジトリの内容に基づいています。本記事執筆時点ではReference Sourceの処理と比べると、ContextMenuなどobsoletedな型やプロパティが無くなったぐらいの差に見えます。そのため.NET Frameworkでも.NET Coreでも現時点では同様に動くと思います。

原因

NotifyIconを右クリックすると、privateなNotifyIcon.ShowContextMenu経由でinternalなContextMenuStrip.ShowInTaskbarが呼び出されます。ShowInTaskbar内部での表示するスクリーンの決定方法処理が、以下の処理になっていることが原因です:

Rectangle bounds = CalculateDropDownLocation(new Point(x, y), ToolStripDropDownDirection.AboveLeft);
Rectangle screenBounds = Screen.FromRectangle(bounds).Bounds;
// 位置関係に応じてboundsを更新する処理がこの後に入る

つまり、左上方向に表示する場合の座標を計算し、計算結果のスクリーンに表示する処理となっています。そのためメインディスプレイ左側の通知領域を右クリックすると、座標計算結果が左側のサブディスプレイ側となり、サブディスプレイに表示されてしまいます。なおこれらの処理はハードコーディングされているため変更できません。

対応策

NotifyIcon.WndProcにてRBUTTONUP時にContextMenuStripが設定されていると、前述したShowContextMenuが呼び出されます。この処理ルートを回避するには、NotifyIconのContextMenuStripプロパティは設定せず、自分でContextMenuStripを表示する処理を書くことになります。

ただし以下の点に注意が必要です。

ContextMenuをタスクバー領域に表示させる

ContextMenuStrip.Showを使用すると自分で好きな座標を指定することが出来ます。しかし試してみると、タスクバー領域を避けた位置に表示されることが分かります。

調べてみるとContextMenuStripの基底クラスであるToolStripDropDownにてinternalなWorkingAreaConstrainedというプロパティがあり、そのプロパティを設定している場所はContextMenuStrip内部に限るようでした。設定内容を見ると、ContextMenuStrip.SetVisibleCoreにてtrueを設定、前述したContextMenuStrip.ShowInTaskbarにてfalseを設定しています。 また取得する箇所の用途を見るとToolStripDropDown.GetDropDownBoundsにて、WorkingAreaConstrainedがtrueの場合はタスクバーを除いた作業領域に、falseの場合はタスクバーを含んだ全領域を使用しているようです。

つまり、publicなShowメソッドを使用するだけではSetVisibleCoreを経由するためWorkingAreaConstrainedプロパティがtrueとなり、タスクバーを除いた領域に表示されてしまいます。タスクバー領域に表示させるために、今回はリフレクション経由でWorkingAreaConstrainedプロパティをfalseに設定することにします。

ContextMenu外をクリックした際にContextMenuを閉じさせる

ContextMenuStrip.Showを試すと、表示することは出来ますがContextMenu外をクリックしても表示されたままとなってしまいます。

調べてみると、前述したNotifyIcon.ShowContextMenuにてSetForegroundWindow(window)呼び出しがありました。なおここでwinformsフィールドはNotifyIconNativeWindow型であり、SetForegroundWindow定義を見るとオーバーロードにてHandleプロパティ経由でWin32APIのSetForegroundWindowを呼び出していました。

今回は上記と同じくリフレクション経由でwindowフィールドを取得してSetForegroundWindowを呼び出すことにします。

対応後

以下の画像のように、メインディスプレイ左側タスクバーの通知領域にてContextMenuStripを表示する場合でも、メインディスプレイ内部に表示できるようになりました:

対応後のContextMenuStrip表示

対応後コード

対応用の主な処理は別ファイルに分けました。Form作成時の変更点として、NotifyIconインスタンスのContextMenuStripプロパティを設定せず、代わりにイベントハンドラにて右ボタンUP時処理を自分で書くようになりました:

using System;
using System.Drawing;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public class MainForm : Form
    {
        private readonly NotifyIcon _notifyIcon;
        private readonly ContextMenuStrip _contextMenuStripForNotifyIcon;

        public MainForm()
        {
            this._contextMenuStripForNotifyIcon = new ContextMenuStrip();
            this._contextMenuStripForNotifyIcon.Items.Add("Popup menu text");
            this._notifyIcon = new NotifyIcon()
            {
                Icon = SystemIcons.Information,
                Visible = true,
                // NotifyIcon.ContextMenuStripを設定しない
            };
            this._notifyIcon.MouseUp += this.NotifyIcon_MouseUp;
        }

        private void NotifyIcon_MouseUp(object sender, MouseEventArgs e)
        {
            if (e.Button != MouseButtons.Right) { return; }
            PopupHelper.ShowOnScreenHavingNotificationArea(this._notifyIcon, this._contextMenuStripForNotifyIcon, MousePosition);
        }
    }

    internal static class Program
    {
        [STAThread]
        private static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new MainForm());
        }
    }
}

対応用の主な処理のファイルです。リフレクションを使っているため、将来的に動作しなくなる可能性があることに注意してください。

using System;
using System.Drawing;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public static class PopupHelper
    {
        /// <summary><paramref name="notifyIcon"/>から<paramref name="contextMenuStripNotifyIcon"/>を、通知領域が存在するスクリーンに収まるように、<paramref name="point"/>付近に表示します。</summary>
        /// <exception cref="InvalidOperationException">Reflection操作に失敗しました。</exception>
        public static void ShowOnScreenHavingNotificationArea(NotifyIcon notifyIcon, ContextMenuStrip contextMenuStripNotifyIcon, Point point)
        {
            // ContextMenuStrip.Show()を呼び出すだけだとポップアップ以外をクリックしても消えてくれない
            // ReferenceSourceで見たSetForegroundWindow処理を入れることで、ポップアップ以外をクリックすると消えてくれるようになる
            // https://github.com/dotnet/winforms/blob/master/src/System.Windows.Forms/src/System/Windows/Forms/NotifyIcon.cs#L630
            var fiWindow = notifyIcon.GetType().GetField("window", BindingFlags.NonPublic | BindingFlags.Instance);
            if (fiWindow == null) { throw new InvalidOperationException("リフレクションでのwindowフィールド取得に失敗しました。"); }

            // windowフィールドはNotifyIconNativeWindow型であり、その型はprivate指定されている
            // NotifyIconNativeWindowの継承元のNativeWindow型のpublic Handleプロパティを参照するとHWNDの値を取得できる
            // https://github.com/dotnet/winforms/blob/master/src/System.Windows.Forms/src/System/Windows/Forms/NotifyIcon.cs#L47
            // https://github.com/dotnet/winforms/blob/master/src/System.Windows.Forms/src/System/Windows/Forms/NotifyIcon.cs#L829
            // https://github.com/dotnet/winforms/blob/master/src/System.Windows.Forms/src/System/Windows/Forms/NativeWindow.cs#L157
            var notifyIconNativeWindow = fiWindow.GetValue(notifyIcon);
            // 現状はHandleはpublicだが、internal等になってもいいようにBindingFlagsを追加
            var piHandle = notifyIconNativeWindow.GetType().GetProperty("Handle", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
            if (piHandle == null) { throw new InvalidOperationException("リフレクションでのHandleプロパティ取得に失敗しました。"); }
            SetForegroundWindow((IntPtr)piHandle.GetValue(notifyIconNativeWindow));

            // ContextMenuStrip.Show()を使う方法ではタスクバー領域を避けた位置に表示されてしまう
            // その原因は、NotifyIconがContexteMenuStripを表示する際、ContextMenuStrip.ShowInTaskbar()というinternalメソッドを呼び出していて
            // その中でWorkingAreaConstrainedプロパティをfalseに設定している、という違いがあるため
            // タスクバー領域に表示するためにReflectionを使って自分でプロパティを設定してやる
            // WorkingAreaConstrainedプロパティは、trueならWorkingArea(=タスクバーを除いた領域)、falseならスクリーン全領域、を意味するらしい
            // https://github.com/dotnet/winforms/blob/master/src/System.Windows.Forms/src/System/Windows/Forms/ContextMenuStrip.cs#L97
            var piWorkingAreaConstrained = contextMenuStripNotifyIcon.GetType().GetProperty("WorkingAreaConstrained", BindingFlags.NonPublic | BindingFlags.Instance);
            if (piWorkingAreaConstrained == null) { throw new InvalidOperationException("リフレクションでのWorkingAreaConstrainedプロパティ取得に失敗しました。"); }
            piWorkingAreaConstrained.SetValue(contextMenuStripNotifyIcon, false);

            // ここからShowInTaskbarの残りの部分をReflection経由で行う
            var miCalculateDropDownLocation = contextMenuStripNotifyIcon.GetType().GetMethod("CalculateDropDownLocation", BindingFlags.NonPublic | BindingFlags.Instance);
            if (miCalculateDropDownLocation == null) { throw new InvalidOperationException("リフレクションでのCalculateDropDownLocationメソッド取得に失敗しました。"); }

            Rectangle CalculateDropDownLocation(Point start, ToolStripDropDownDirection dropDownDirection)
                => (Rectangle)miCalculateDropDownLocation.Invoke(contextMenuStripNotifyIcon, new object[] { start, dropDownDirection });

            var bounds = CalculateDropDownLocation(point, ToolStripDropDownDirection.AboveLeft);

            // 元々のShowInTaskbar()では「Screen.FromRectangle(bounds).Bounds」としていたものを、pointがある位置のScreenに変更した
            var screenBounds = Screen.FromPoint(point).Bounds;
            if (bounds.Y < screenBounds.Y)
            {
                bounds = CalculateDropDownLocation(point, ToolStripDropDownDirection.BelowLeft);
            }
            else if (bounds.X < screenBounds.X)
            {
                bounds = CalculateDropDownLocation(point, ToolStripDropDownDirection.AboveRight);
            }

            bounds = ConstrainToBounds(screenBounds, bounds);
            contextMenuStripNotifyIcon.Show(bounds.X, bounds.Y);
        }

        // https://github.com/dotnet/winforms/blob/master/src/System.Windows.Forms/src/System/Windows/Forms/WinFormsUtils.cs#L82
        // 上記ソースをそのまま使用
        private static Rectangle ConstrainToBounds(Rectangle constrainingBounds, Rectangle bounds)
        {
            // use screen instead of SystemInformation.WorkingArea for better multimon support.
            if (!constrainingBounds.Contains(bounds))
            {
                // make sure size does not exceed working area.
                bounds.Size = new Size(Math.Min(constrainingBounds.Width - 2, bounds.Width),
                                       Math.Min(constrainingBounds.Height - 2, bounds.Height));

                // X calculations
                //
                // scooch so it will fit on the screen.
                if (bounds.Right > constrainingBounds.Right)
                {
                    // its too far to the right.
                    bounds.X = constrainingBounds.Right - bounds.Width;
                }
                else if (bounds.Left < constrainingBounds.Left)
                {
                    // its too far to the left.
                    bounds.X = constrainingBounds.Left;
                }

                // Y calculations
                //
                // scooch so it will fit on the screen.
                if (bounds.Bottom > constrainingBounds.Bottom)
                {
                    // its too far to the bottom.
                    bounds.Y = constrainingBounds.Bottom - 1 - bounds.Height;
                }
                else if (bounds.Top < constrainingBounds.Top)
                {
                    // its too far to the top.
                    bounds.Y = constrainingBounds.Top;
                }
            }
            return bounds;
        }

        [DllImport("user32.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern bool SetForegroundWindow(IntPtr hWnd);
    }
}

通知領域はメインディスプレイにのみ存在するのか?他ディスプレイにも存在できるのか?

C# - 通知領域に常駐してタスクバーに接するFormを力技で表示する - マルチディスプレイ対応 - Qiitaにて以下の記述を発見しました:

マルチディスプレイにおけるタスクバー
    通知領域は主タスクバー(Shell_TrayWnd)にしか表示されない。
    PrimaryScreen以外の画面にShell_TrayWndを置くことができる。
    (つまり、主画面に主タスクバーがいない場合がある。)

通知領域がメインでないディスプレイに存在する可能性があるとのことです。上記の対応後コードにてScreen.PrimaryScreen.Bounds固定で表示先を算出するのがまずい可能性があります。ただメインでないディスプレイに通知領域を配置する方法が分からなかったため、ひとまず今回の記事は終わりにします。少し考えたらScreen.FromPoint(point).Boundsを使えば簡単に計算できると気付いたため、それを使うように対応後コードを修正しました。

最後に

日本語と英語でそれなりにググったんですが、本現象の回避策どころか現象の言及を見つけることも出来ませんでした。別モニターがある側にタスクバーを配置している人はほとんど居ないんでしょうか……?

2022/01:24 追記: ふとググり直してみると、c# - Prevent ToolStripMenuItems from jumping to second screen - Stack Overflowが見つかりました。質問内容は、複数レベルのドロップダウンメニューのうち途中からがサブモニターに行ってしまうことがある、というものです。回答としてDropDownOpeningイベントを使用する方法が紹介されています。もしかしたら今回の記事の内容もそれで解決できるかも……?(未確認)