作っているアプリをマルチモニターに対応させつつ、異なる DPI でも問題なく動くように実装していたら、思ったよりもはまったのでメモとして残します。
動作確認用にアプリケーションを作ったので、とりあえず公開しておきました。
それぞれのボタンを押すと、表示されているのモニターの中央に移動するという簡単なアプリです。
WPF では Window 座標と Monitor サイズも DIP になる
当たり前ですが、WPF は全てを DIP で扱うようになっているので SystemParameters
から取れるモニターのサイズも DIP に変換されたものが返って来ます。
なので解像度が 3000x2000 かつ、表示スケールを 150% で運用してる Surface Book 2 でサイズを取得すると、以下のように 2/3 されたサイズが返って来ます。
DIP のおかげで DPI をあまり意識せずとも書けるようになっています。しかしシングルモニターの場合は問題ないですが、これがマルチモニターで別々の DPI で表示されている時に問題となります。
Windows のマルチモニターはプライマリモニターの左上を (0,0) とした座標系で扱われます。ちなみに以下のレイアウトの場合、2 つ目のモニターは Y 座標がマイナスになる場合があります。
Window クラスが持つ Left / Top プロパティはもちろん DIP なので、 2 つ目のモニターの指定した場所に Window を表示しようとすると、マルチモニター用の API がないため Win32 API を使った上で、さらに 1 枚目と 2 枚目でそれぞれ DIP 上のサイズを計算する必要があります。
DIP として扱うためには、モニターの DPI がそれぞれで必要なので結構手間です。なので、結局は Window の位置に関しては DIP で扱わずに物理ピクセルで扱うことにしました。
現在のモニター情報を取得する
Win32 API を使って Window が表示されているモニターの情報を取得するには MonitorFromWindow
と GetMonitorInfo
を使えば良いです。
var hwnd = new WindowInteropHelper(this).Handle; var hMonitor = NativeMethods.MonitorFromWindow(hwnd, Consts.MONITOR_DEFAULTTOPRIMARY); var monitorInfo = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() }; NativeMethods.GetMonitorInfo(hMonitor, ref monitorInfo);
これで MONITORINFO
の rcMonitor
と rcWork
に領域情報が入ってきます。この値はプライマリモニター左上を (0,0) として見たものになるので、必ずしも x と y は 0 になりません。
Window の物理ピクセルでのサイズを計算
MONITORINFO
の値を使えば、現在のモニターの中央に表示するための座標を計算出来ますが、その前に Window のサイズを DIP から物理ピクセルに戻す必要があります。
戻すためには、現在のモニターの DPI が必要なので GetDpiForMonitor
を使います。
NativeMethods.GetDpiForMonitor(hMonitor, Consts.MDT_EFFECTIVE_DPI, out var dpiX, out var dpiY); var dpiFactorX = dpiX / 96.0; var dpiFactorY = dpiY / 96.0; var physicalWidth = (int)Math.Round(Width * dpiFactorX); var physicalHeight = (int)Math.Round(Height * dpiFactorY);
dpiAwareness が有効になっていないと固定値を返すらしいので注意。モニターの DPI が取得できれば DIP は 96 DPI と決まっているので、そこから物理ピクセルでの Window サイズを簡単に計算できます。
実際に Window を移動させる
これで表示位置を計算する情報が揃ったので、実際に Window を移動させます。もちろん Window クラスにある Left / Top プロパティは使えないので、Win32 API の SetWindowPos
を使って移動させます。
// 現在のモニターでの中央に表示するための位置を計算 var x = monitorInfo.rcWork.left + (monitorInfo.rcWork.right - monitorInfo.rcWork.left - physicalWidth) / 2; var y = monitorInfo.rcWork.top + (monitorInfo.rcWork.bottom - monitorInfo.rcWork.top - physicalHeight) / 2; NativeMethods.SetWindowPos(hwnd, IntPtr.Zero, x, y, 0, 0, Consts.SWP_NOSIZE | Consts.SWP_NOZORDER);
この時に SWP_NOSIZE
と SWP_NOZORDER
を指定して Window 位置以外は変更させないようにします。
これで一通りの処理が完成です。サンプルのアプリでは Per-Monitor DPI で SetWindowPos を選んだ時に今回の処理が動きます。実際のアプリ向けに行った対応もほぼ同じです。
このアプリはキーボードフォーカスがあるモニターで表示させたかったので、今回のような処理を組んでいます。最初は Per-Monitor DPI への対応が抜けていて盛大にバグってました。