Windows Formプログラムを可変DPI対応にするきっかけ
自分の作っているフリーソフトに可変DPIの考え方がなかった2011年にWindows Formで開発を開始して、4Kモニターも使うようになったのと、プログラムにそこそこ反響があったので、プログラムを可変DPI対応にする必要が出てきた。
プログラムを整理し、可変DPIに対応しやすいフレームワークでGUI部分を書き直して対応するのが正当な手ではあるが時間的なことを考えると今動いているプログラムでも対応が必要になる事が分かった。
また、当時を考慮して.NET Framework 3.5で開発を行っていたが、仮想マシンの共有フォルダを使ったファイルのやりとりのため.NET Framework 3.5なプログラムも維持する必要があったのでWindows Formで作成したプログラムを可変DPI対応にする必要があった。
そこで、Windows Formで作成したプログラムを可変DPI対応にしたのだが、そのときの知見についてこれから書いていく。
基本
まずは、基本的な準備から。
マニフェストファイル
app.manifestにWindows 8.1/10/11用のプログラムであるということのマニフェストを書く。
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<!--Windows 8-->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<!--Windows 7-->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
<!--Windows Vista-->
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
</application>
</compatibility>
合わせて、可変DPIであることも記述する。
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
<dpiAware>True/PM</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
フォームの単位
フォームのAutoScaleModeはDpi
に設定する。
フォームが表示されたときのコントロールのスケーリング
スケーリングの基本の流れ
上記の準備を行うとWndProcメソッドでフォームを異なるDPIに移動したときのメッセージWM_DPICHANGEDメッセージ(番号0x02e0)が取得できるので if (m.Msg == 0x02e0) { 以下、処理 }
とかと書いて、DPIが変更されたときの処理を記述するのが基本的な流れとなる。
DPIが変更されたら以前のDPIとの比率を求めてコントロールの大きさを変更することになる。
アプリケーション起動当初時のスケーリング処理
ここで問題となるのが最初のDPIを取得する処理である。ここだけはメッセージでDPIを取得できないのでWindows 8.1以降の場合は、MonitorFromWindow APIでウインドウが存在するモニターを取得して、GetDpiForMonitor APIでDPIを取得する。DPIの取得にはウインドウハンドルが必要となる。これを頭に入れておいてほしい。
宣言は以下の通り。
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern IntPtr MonitorFromWindow(IntPtr hwnd, int dwFlags);
[DllImport("shcore.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern int GetDpiForMonitor(
IntPtr hmonitor,
int dpiType,
out uint dpiX,
out uint dpiY);
ただし、アプリケーション起動の初回だけはウインドウハンドルが取得できないうちにWM_DPICHANGEDメッセージがフォームに対して送信されるので、初回だけはWM_DPICHANGEDメッセージを無視する必要がある。
また、フォームのウインドウハンドルが取得できるのはShownイベントが発生した後になるので、ShownイベントのハンドラーでDPIを取得してデザイン時の96DPIとの比率を計算してコントロールの大きさを変更することで、初回表示時にモニターのDPIに合ったコントロールのサイズを設定することになる。ウインドウの位置・サイズを前回の物に戻す場合もここで行うことになる。
後はWM_DPICHANGEDメッセージが来たときにサイズを変更することを繰り返していくことになる。
コントロール・フォントのサイズの変更
次に、肝心なコントロール・フォントのサイズの変更の方法について書く。
同種の記事は多くあると思うが特に、GroupBoxや各種パネルが絡んだときの挙動やWindows Forms固有のサイズ変更機構の挙動についてはあまり例がなさそうな上、ここでだいぶ苦労したので記録しておく。
コントロールのサイズが固定の場合
コントロールのサイズが固定の場合はFormに対して、Control.Scaleメソッドを実行すれば良い。
float factor = dpiNew / dpiOld;
this.Scale(new SizeF(factor, factor));
コントロールのサイズが可変の場合
コントロールサイズの処理
コントロールのサイズが可変の場合、Anchor,DockといったWindows Form固有である他のコントロールとの相対位置でコントロールのサイズを変更する機構や、TableLayoutPanelクラスによる自動的な領域サイズ計算、FlowLayoutPanelによるコントロールの再配置といった機構は機能しなくなるので、コントロールのサイズが可変の場合は各領域およびコントロールを自力で計算する必要がある。
特に、~Panelについてはコントロールをまとめて移動できる以外のメリットがないのでコントロールをまとめる意図がない場合は使わないようにして自力計算した方が確実である。実際、自分の場合もFlowLayoutPanelを1個捨てることになった。
Panel系の処理
また、Windows FormではGroupBox、TableLayoutPanel、FlowLayoutPanelといった領域を確保して子コントロールの位置は親コントロールからの相対指定となるパネル類にコントロールを乗せることがあるが、この場合、
親コントロールのサイズ変更→子コントロールのサイズ変更
という順でサイズを変更していく必要がある。このようにしないと古今トロールを先に変更すると親コントロールのサイズが変更前なので親コントロールのサイズにサイズが制約されるためである。
フォント
フォントサイズについてはポイント数で指定を行うとウインドウの存在するディスプレイのDPIに関係なく、プライマリディスプレイのDPIを基準にフォントの実際のサイズが計算されるため、ウインドウの存在するディスプレイのDPIを求めた上で、ピクセル単位でFontオブジェクトを作り直してコントロールに設定する必要がある。
処理としては、下記のようになる。
new Font("font name", (Point * DPI) / 72, GraphicsUnit.Pixel);
ここまで行ってようやくWindows Formsで可変DPI対応にできるのだが、Windows Forms側の描画処理の誤りでCheckedListBoxのチェックボックスやCheckBox,RadioButtonのフォント以外の部分の計算には2024年4月29日現在でも誤りがあり、全体としての整合性はとれているがチェックボックスやラジオボタンの絵が大きくなりすぎて切れるという現象は回避不可能である。
結論
可変DPIに対応するプログラムを新規に作成する場合は互換性の観点からWindows Formの挙動を変えられないということも考慮するとWindows Formは推奨できないと言える。
自分が試したのが.NET Framework 3.5 Client Profileプロジェクト(ただし、主に稼働するのは.NET Framework 4.8.x)なのでFrameworkでない.NETではコントロール描画については直っているのかもしれないが、ほとんどWindows APIで書くのと変わらず追加の処理がいるという工数を考えると他のフレームワークを選択するのが賢いと言えるだろう。