しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Per-Monitor DPI 環境下で WPF の Window 位置を調整する

作っているアプリをマルチモニターに対応させつつ、異なる DPI でも問題なく動くように実装していたら、思ったよりもはまったのでメモとして残します。

動作確認用にアプリケーションを作ったので、とりあえず公開しておきました。

それぞれのボタンを押すと、表示されているのモニターの中央に移動するという簡単なアプリです。

WPF では Window 座標と Monitor サイズも DIP になる

当たり前ですが、WPF は全てを DIP で扱うようになっているので SystemParameters から取れるモニターのサイズも DIP に変換されたものが返って来ます。

なので解像度が 3000x2000 かつ、表示スケールを 150% で運用してる Surface Book 2 でサイズを取得すると、以下のように 2/3 されたサイズが返って来ます。

f:id:shiba-yan:20190611233414p:plain

DIP のおかげで DPI をあまり意識せずとも書けるようになっています。しかしシングルモニターの場合は問題ないですが、これがマルチモニターで別々の DPI で表示されている時に問題となります。

Windows のマルチモニターはプライマリモニターの左上を (0,0) とした座標系で扱われます。ちなみに以下のレイアウトの場合、2 つ目のモニターは Y 座標がマイナスになる場合があります。

f:id:shiba-yan:20190611234152p:plain

Window クラスが持つ Left / Top プロパティはもちろん DIP なので、 2 つ目のモニターの指定した場所に Window を表示しようとすると、マルチモニター用の API がないため Win32 API を使った上で、さらに 1 枚目と 2 枚目でそれぞれ DIP 上のサイズを計算する必要があります。

DIP として扱うためには、モニターの DPI がそれぞれで必要なので結構手間です。なので、結局は Window の位置に関しては DIP で扱わずに物理ピクセルで扱うことにしました。

現在のモニター情報を取得する

Win32 API を使って Window が表示されているモニターの情報を取得するには MonitorFromWindowGetMonitorInfo を使えば良いです。

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);

これで MONITORINFOrcMonitorrcWork に領域情報が入ってきます。この値はプライマリモニター左上を (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_NOSIZESWP_NOZORDER を指定して Window 位置以外は変更させないようにします。

これで一通りの処理が完成です。サンプルのアプリでは Per-Monitor DPI で SetWindowPos を選んだ時に今回の処理が動きます。実際のアプリ向けに行った対応もほぼ同じです。

このアプリはキーボードフォーカスがあるモニターで表示させたかったので、今回のような処理を組んでいます。最初は Per-Monitor DPI への対応が抜けていて盛大にバグってました。