最近のWindowsでWin32アプリを作るなら、高DPI対応はほぼ避けて通れません。
「Win32は死なズ」 第三回は高DPI対応をWin32で行う場合について書いてみようと思います。
この記事は厳密には、固定的な高DPI対応というより、モニターごとにDPIが変わる環境への対応(可変DPI対応)を扱います。
昔のWindowsでは、
CreateWindow(
...
100, 100,
200, 30,
...
);
のようにピクセル単位で座標やサイズを決めていても、大きな問題はありませんでした。
しかし現在は、Windowsの表示倍率として、
- 100%
- 125%
- 150%
- 200%
などが普通に使われます。
さらに、例えばノートPCと外部モニターで表示倍率が異なるといった環境も珍しくありません。
今回は、Win32アプリを高DPIに対応させる基本的な考え方と、前回作成した自作ToggleSwitchへの適用方法をまとめます。
高DPI対応しないとどうなるか
例えば、100%表示を前提に、
50 x 30
の自作コントロールを作ったとします。
150%表示の環境でもそのまま50×30pxで作成すると、物理的には小さく表示されます。
Windows側に拡大を任せる方法もありますが、その場合はアプリ全体がビットマップとして拡大され、文字や自前描画がぼやけることがあります。
そこで、
このアプリは自分でDPIに対応する
とWindowsに宣言し、現在のDPIに応じてサイズを計算します。
Per-Monitor DPI Aware V2にする
まずはアプリをDPI Awareにします。
今回は、ウィンドウを作成する前に、
SetProcessDpiAwarenessContext(
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
);
を呼び出します。
例えば、
int APIENTRY _tWinMain(
HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPTSTR lpCmdLine,
int nCmdShow)
{
SetProcessDpiAwarenessContext(
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
);
// DialogBox()など
}
という形です。
重要なのは、ウィンドウを作成する前に設定することです。
PER_MONITOR_AWARE_V2 を指定すると、異なるDPIのモニター間でウィンドウを移動した場合にも対応しやすくなります。
なお、実運用ではマニフェストでDPI Awarenessを指定する方法もあります。
現在のDPIを取得する
ウィンドウの現在DPIは、
GetDpiForWindow()
で取得できます。
UINT dpi = GetDpiForWindow(hWnd);
Windowsでは 96 DPIが基準です。
Windowsでは、モニター上の96ピクセル(px)で1インチを表しています
| 表示倍率 | DPI |
|---|---|
| 100% | 96 |
| 125% | 120 |
| 150% | 144 |
| 200% | 192 |
そこで、96 DPIを基準に値を変換する関数を用意します。
static int DpiScale(HWND hWnd, int value)
{
UINT dpi = GetDpiForWindow(hWnd);
return MulDiv(value, dpi, 96);
}
MulDiv() によって、
value × dpi ÷ 96
を整数で計算できます。
例えば、
DpiScale(hWnd, 50)
なら、おおよそ、
100% -> 50
150% -> 75
200% -> 100
となります。
自作コントロールをDPI対応する
前回作成したToggleSwitchを例にします。
以前は、
CToggleSwitch::Create(
hDlg,
IDC_TOGGLE,
100,
100,
50,
30
);
のように作成していました。
この座標とサイズはピクセル単位なので、そのままでは表示倍率が変わってもサイズは変わりません。
そこで、
CToggleSwitch::Create(
hDlg,
IDC_TOGGLE,
DpiScale(hDlg, 100),
DpiScale(hDlg, 100),
DpiScale(hDlg, 50),
DpiScale(hDlg, 30)
);
とします。
150%表示なら、
x 100 -> 150
y 100 -> 150
width 50 -> 75
height 30 -> 45
となります。
これだけでも初期表示はかなり自然になります。
ToggleSwitch内部ではDPIを意識しなくてもよい
今回のToggleSwitchでは、描画時に、
RECT rc;
GetClientRect(hWnd, &rc);
int width = rc.right - rc.left;
int height = rc.bottom - rc.top;
のように現在のクライアントサイズを取得しています。
さらに、
int diameter = height - margin * 2;
のように、コントロール自身の大きさを基準に図形を描画しています。
そのため、外側のコントロールサイズが、
50 x 30
から、
75 x 45
へ変われば、内部の描画も自然に大きくなります。
つまり、ToggleSwitch内部でさらに、
GetDpiForWindow()
を呼び出して各座標を拡大する必要はありません。
むしろ両方で拡大すると、二重スケーリングになる可能性があります。
今回の構成では、
親ウィンドウ
↓
DPIに応じて位置とサイズを決定
↓
ToggleSwitch
↓
与えられた領域の中で描画
という役割分担にしています。
この方が、自作コントロール側をシンプルに保てます。
DPIの異なるモニターへ移動した場合
Per-Monitor DPI対応で重要なのが、モニター移動です。
例えば、
100%のモニター
↓
150%のモニター
へウィンドウを移動すると、ウィンドウのDPIが変わります。
このとき送られてくるのが、
WM_DPICHANGED
です。
基本的な処理は次のようになります。
case WM_DPICHANGED:
{
RECT* prcNewWindow =
reinterpret_cast<RECT*>(lParam);
SetWindowPos(
hWnd,
nullptr,
prcNewWindow->left,
prcNewWindow->top,
prcNewWindow->right - prcNewWindow->left,
prcNewWindow->bottom - prcNewWindow->top,
SWP_NOZORDER | SWP_NOACTIVATE
);
return 0;
}
lParam には、新しいDPIに適した推奨ウィンドウ矩形が渡されます。
この矩形を使ってウィンドウの位置とサイズを変更します。
子コントロールも再配置する
親ウィンドウだけ変更しても、自作コントロールの位置やサイズまでは自動的に変わりません。
そこで、レイアウト処理を関数化しておきます。
static void LayoutControls(HWND hDlg)
{
HWND hToggle = GetDlgItem(hDlg, IDC_TOGGLE);
MoveWindow(hToggle,
DpiScale(hDlg, 100),
DpiScale(hDlg, 100),
DpiScale(hDlg, 50),
DpiScale(hDlg, 30),
TRUE
);
}
WM_DPICHANGED では、
case WM_DPICHANGED:
{
RECT* prcNewWindow =
reinterpret_cast<RECT*>(lParam);
SetWindowPos(
hDlg,
nullptr,
prcNewWindow->left,
prcNewWindow->top,
prcNewWindow->right - prcNewWindow->left,
prcNewWindow->bottom - prcNewWindow->top,
SWP_NOZORDER | SWP_NOACTIVATE
);
LayoutControls(hDlg);
return 0;
}
とします。
これで、
DPI変更
↓
WM_DPICHANGED
↓
親ウィンドウを更新
↓
LayoutControls()
↓
新しいDPIで子コントロールを再配置
という流れになります。
ダイアログリソースは少し事情が違う
今回のサンプルではリソースダイアログを使用しています。
.rc には、
IDD_SAMPLEBOX DIALOGEX 0, 0, 317, 217
のように記述されています。
この、
317 x 217
はピクセルではありません。
これは DLU(Dialog Unit) です。
リソースダイアログでは、Windowsがフォント情報などを使ってDLUから実際のピクセルサイズへ変換します。
そのため、
DpiScale(hDlg, 317)
のようにダイアログサイズまで自分で再スケーリングする必要はありません。
むしろ、二重に拡大してしまう可能性があります。
標準コントロールと自作コントロールの違い
例えばリソースに、
LTEXT "DPI Test",IDC_STATIC,20,20,80,12
PUSHBUTTON "Button",IDC_BUTTON1,20,40,60,18
EDITTEXT IDC_EDIT1,20,65,100,14,ES_AUTOHSCROLL
と配置した場合、これらもDLU基準で扱われます。
DPI Awareなアプリであれば、標準コントロールについてはWindows側がかなり処理してくれます。
一方、
CreateWindowEx(
...
x,
y,
width,
height,
...
);
で自分で作成したコントロールは、ピクセル指定です。
そのため、
DpiScale()
のような処理が必要になります。
整理すると、
リソースで配置した標準コントロール
↓
Windows側がある程度DPI対応
自分でCreateWindowしたコントロール
↓
自分でDPI対応
となります。
自前描画は基本的に自分で対応する
GDIやGDI+などで自分で描画する場合は、DPI対応も基本的に自分の責任になります。
例えば、
TextOut()
Rectangle()
Ellipse()
Gdiplus::Pen
Gdiplus::Font
などです。
特に、
int margin = 1;
や、
Gdiplus::Pen pen(color, 1.5f);
のような固定値は注意が必要です。
ただし、固定値だからといって何でも単純にDPI倍率を掛ければよいわけではありません。
線幅などは200%だから必ず2倍にした方がよいとは限らず、見た目とのバランスも重要です。
今回のToggleSwitchのように、
GetClientRect()
で取得した現在サイズを基準に描画していれば、外側のサイズ変更だけで多くの部分は自然に追従します。
文字描画も要注意
自前でフォントを作成している場合もDPI対応が必要です。
例えば、
CreateFont()
で固定ピクセルの高さを指定している場合、そのままではDPI変更に追従しません。
一例として、
int fontHeight =
-MulDiv(
11,
GetDpiForWindow(hWnd),
72
);
のように、ポイントサイズと現在DPIからフォント高さを計算できます。
フォントは72pxで1ポイント(pt)を表します
標準コントロールではWindows側がある程度処理してくれますが、自前描画ではフォントサイズも自分で管理する必要があります。
まとめ
今回やったことは、次の通りです。
1. PER_MONITOR_AWARE_V2 にする
2. GetDpiForWindow() で現在DPIを取得する
3. MulDiv(value, dpi, 96) で座標やサイズを変換する
4. 自作コントロールをDPIに応じたサイズで作る
5. WM_DPICHANGED に対応する
6. DPI変更時に子コントロールを再配置する
7. 自前描画の固定値やフォントにも注意する
高DPI対応というと、すべての描画処理を大きく書き直す印象がありました。
しかし実際には、
96 DPI時の設計値
↓
現在DPIに変換
というルールを決めておくと、かなり整理しやすくなります。
特に今回のToggleSwitchでは、
- 親側でDPIに応じたサイズを決める
- ToggleSwitch側は与えられたサイズの中で描画する
という形にしたことで、コントロール内部を大きく変更せずに対応できました。
こうすると、Win32でも高DPI対応の考え方自体はそれほど複雑ではないと思います。
重要なのは、
Windowsに任せる部分と、自分で対応する部分を分けて考えること
だと思います。
Win32は死なズ
高DPI対応もうまく行くと楽しい