2013/09/02

WindowsフォームとPer-Monitor DPI

Windows 8.1のPer-Monitor DPIに対応するための基本情報を元に、実際にWindowsフォームアプリを対応させる作業に関して。なお、Windows 8.1 Previewに基づくものなので、RTMでは変更があり得る。

[追記] RTMでOS側に変更があったので、続を加えて再構成した。

1. 3つのDPI


Windowsフォームのスケーリングに関する出発点としては以下を参照。この中の「現在の自動スケーリングのサポート」は.NET Framework 2.0の場合として書かれているが、自分が試した範囲では.NET Framework 4.0でも同じだった。
これを踏まえて要点をまとめると、以下の3つのDPIを考慮する必要がある。
  • デザイン時のDPI

    概要 : Visual StudioのデザイナーでUIをデザインしたとき(最後にデザイナーで変更を加えたとき)のDPI。FormのAutoScaleDimensionsプロパティの値。下手な訳では意味が不明瞭になるので、MSDNの説明を引けば、"this property will be set by the Windows Forms designer to the value your monitor is currently using"で、"the AutoScaleDimensions property represents the design-time reference dimensions of the design environment for the current control"となる。

    取得方法 : デザイン時のDPIが100%なら96。実行時にはAutoScaleDimensionsはPerformAutoScaleメソッドによりCurrentAutoScaleDimensionsと同じ値になるので(AutoScaleModeプロパティがDpiの場合)、実際のデザイン時の値は取得できない。あえて実行時に取得するなら対象Formをインスタンス化だけして読み取るなど。Visual Studioで直接確認するには、対象Formのデザイナーファイルを見ればいい。

  • 実行時のDPI(以下、仮に「共通DPI」)

    概要 : 実行時の、全モニター共通のDPI。従来のDPIといえば、これのこと。FormのCurrentAutoScaleDimensionsプロパティの値。MSDNの説明を引けば、"the current run-time dimensions of the screen"で、"The CurrentAutoScaleDimensions property represents the reference dimensions on the current screen"となる。

    取得方法 : CurrentAutoScaleDimensionsから取得。Win32のGetDeviceCapsからLOGPIXELSXまたはLOGPIXELSYフラグで取得したものと、自分が試した範囲では同じだった。CurrentAutoScaleDimensionsが内部的に参照しているものも出所は同じではないかと思う。

  • 実行時のDPI(Per-Monitor DPI用)(以下、仮に「個別DPI」)

    概要 : 実行時の、個別モニターのDPI。Windows 8.1以降のみ。96、120、144、192の4段階(現在のところ)。

    取得方法 : Win32のGetDpiForMonitorから取得するか、WM_DPICHANGEDが来たときは、それに含まれているものを見る。
従来のWindowsフォームの高DPI対応(System DPI Aware)では、基本的にFormのAutoScaleModeをDpiに設定するだけでよかった。これには内部的にデザイン時のDPIと実行時の共通DPIが使われている。

で、Per-Monitor DPI環境での対応(Per-Monitor DPI Aware)はどうかとなると、BUILD 2013のプレゼンテーション(Making Your Desktop Apps Shine on High-DPI Displays)の際、Windowsフォームの対応方法について質問した人が素気なく却下されたのを見るに、Microsoftが何か用意してくれそうな感じではないので、実行時の個別DPIを見て自分で実装することになる。

2. 実装


以下は、従来どおりAutoScaleModeをDpiに設定して、その上にPer-Monitor DPI対応を重ねる形で実装する方法。つまり、CLRにSystem DPI Aware対応をさせた上でPer-Moniter DPI対応を加えることになる。AutoScaleModeをNoneにして、全ての場合のスケーリングを自分で実装する方がシンプルになるかもしれないが、副作用がよく分からなかったので。言語はVB。

2.1 コントロールの位置・サイズ


冒頭のMSDNの説明にあるとおり、PerformAutoScaleは内部的にScaleメソッドを使っている。これは親コントロールから子コントロールに波及していくので、一番の親であるFormにScaleメソッドを使えばいい。この引数にはその前の状態からの倍率を入れる。

例えば、デザイン時のDPIが96、実行時の共通DPIが120、個別DPIが144の場合、アプリの起動時に自動的にPerformAutoScaleで96から120へ1.25倍の拡大が行われる。そこから個別DPIへは120から144へ1.2倍に拡大する必要があるので、以下のようにSizeF構造体にしてScaleを実行する。
Me.Scale(New SizeF(1.2F, 1.2F))
実行中に個別DPIが変わったときは、その前の状態から現在の個別DPIへの倍率を同様に適用すればいい。

ちなみに、これをPerformAutoScaleで行うことはできない。というのは、PerformAutoScaleは内部的にCurrentAutoScaleDimensionsを参照してこれに合わせるメソッドになっていて、CurrentAutoScaleDimensionsはReadOnlyプロパティなので外部から変更できないので。

2.2. フォント


フォントの方は結構トリッキー。

フォントサイズの倍率


第一に、フォントサイズを直接指定はできない。どういうことかというと、コード内で指定されたサイズから、デザイン時のDPIから実行時の共通DPIへの倍率で自動的に拡大されたサイズで表示される。

例えば、デザイン時のDPIが96で実行時の共通DPIが120の場合、コード内で9ポイントと指定されたフォントは1.25倍に拡大されて11.25ポイントで表示される。ここでサイズを参照しても9ポイントのまま。つまり、この表示のスケーリングはサイレントに行われ、コードから直接感知できない。

さらに、コントロールのPerformAutoScaleは基本的にアプリの起動時に行われるだけだが、このフォントのスケーリング効果は実行中ずっと続いている。RPGに例えるなら、基本ステータスは変わらないまま、ずっと拡大のバフ(魔法効果)がかかっているようなもの。

したがって、目的のサイズで表示させるには、このサイレントに行われるスケーリングの倍率を差し引いた倍率で拡大してコード内で指定する必要がある。といっても難しいことはなくて、実行時の共通DPIから個別DPIへの倍率をサイズに掛ければいいだけ。これが1.2倍の場合、以下のようにFontを設定し直す。
Me.Font = New Font(Me.Font.FontFamily, 
                   Me.Font.Size * 1.2F, 
                   Me.Font.Style)
実行中に個別DPIが変わったときは、コントロールと同じくその前の状態から現在の個別DPIへの倍率を適用すればいい。

対象とするコントロール


第二に、フォント設定の対象とするコントロールの問題がある。デザイン時にコントロールのフォント設定をしていない場合(デザイナーでFontプロパティが太字でない状態)、実行時には親コントロールのフォント設定が使われる。したがって、全フォントが同じであればFormのフォント設定だけすればよく、実行時にサイズを変えるときもそれだけを対象にすれば足りる。

が、実際のアプリではそういうわけにも行かないと思うので、以下のようなことが起こる。
  • 個別のコントロールによってフォント設定をした場合、そのコントロールは親コントロールのフォント設定には影響されない(当然)。よって、実行時にサイズを変えるにはそのコントロールを直接対象にする必要がある。

  • ならば、Form中の全コントロールを対象に舐めるようにフォント設定していけばいいかといえば、そうも行かない。デザイン時にフォント設定されていないコントロールには、親コントロールにかけられたスケーリングと直接かけられたスケーリングの両方の効果がかかってしまう。例えば、親コントロールのフォント設定で1.2倍にした後で、子コントロールのフォント設定で1.2倍にすると、合わせて1.44倍の拡大がかかる。
これを回避するには、うまく対象のコントロールを抽出する必要がある(デザイン時に全コントロールに個別にフォント設定する方法は避けたい)。

大まかには以下の流れになると思うが、これ一発でという方法はたぶんない。
  1. Form中のコントロールを配列かコレクションの形で再帰的に取得する(必要であれば、世代を限定する)。

  2. 取得した配列かコレクションからLINQなどでコントロールの型や特定の条件によって抽出する。
例として、Form中のButtonのみを個別にフォント設定している場合。GetChildInControlは引数のコントロールを親とする全コントロールを取得するメソッドで、この中からButtonの型で抽出している。
For Each b As Button In GetChildInControl(Me).OfType(Of Button)()
    b.Font = New Font(b.Font.FontFamily, 
                      b.Font.Size * 1.2F, 
                      b.Font.Style)
Next
別に難しくはないが、うまくやろうとすると頭を使わないといけないという意味で面倒な部分。

2.3 動的なコントロール


実行中に動的にコントロールを作成したり、位置・サイズを変更したりする場合は、
  • 位置・サイズについては、デザイン時の位置・サイズをコード内に持っておいて、デザイン時のDPIから個別DPIへの倍率を掛けるのが簡単だと思う。

  • フォントについては、同じくデザイン時のフォントをコード内に持っておいて、実行時の共通DPIから個別DPIへの倍率を掛ければいい。
掛ける倍率が違うのに注意。

なお、文字表示に必要なコントロールのサイズを求めるのにTextRenderer.MeasureTextメソッドを使うことがあると思うが、これは表示のスケーリングがかかった状態のサイズが取得できるので、とくに手を加える必要はない。ただし、一旦これでサイズを設定した後に個別DPIが変わってScaleメソッドをかけた場合、新しいDPIで取得したTextRenderer.MeasureTextのサイズと微妙にずれが出てくるので、再設定した方がいいかもしれない。

ちなみに、タイトルバーのアイコンと通知領域のアイコンについては、表示される大きさが共通DPIに従って固定されているようなので、変更する意味はとりあえずない。

2.4 正しくスケーリングされないコントロール


一部のコントロールはScaleメソッドを実行しても正しくスケーリングされない。自分が直接知っている例は以下のもの。
  • TextBoxをMultilineにしたときの高さ
  • ListViewをDetailsにしたときのColumnHeaderの幅
弥縫策的には個別にスケーリングした値を与えてやれば済むが、問題を修正したカスタムコントロールを作成してもいいかもしれない。

ということで、TextBoxの高さの方は色々試してみたが、オーバーロードできるメソッドではなかなか難しくて、諦めた(SetBoundsCoreメソッドを使えば何とかなるかもしれない)。が、実はこれにはデザイン時にできる簡単な回避策があって、Panelなどのコンテナコントロールに入れてDockプロパティをFillにすればいい。Panelは正しくスケーリングされるので、中のTextBoxのサイズも正しくスケーリングされるようになる。

ちなみに、これらはScaleメソッドの問題なので、従来の高DPI対応でも発生する。

3. WM_DPICHANGEDの変更


Windows 8.1 RTMではWM_DPICHANGEDが来るタイミングが変更されたので、これへの対応方法を変える必要がある。以下、にて。

0 コメント :