1. WM_DPICHANGEDが来るタイミング
RTMでは、OSからWM_DPICHANGEDが送られてくるタイミングが以下のように増えている。
- FormのLoadイベントが起こる前(おそらくコントロールが初期化される前)
- ウィンドウが属するモニターのDPIが変更されたとき
- ウィンドウがモニター間をまたいだとき(常に)
2.は純粋な改善で、モニターのDPI変更をトリガーとして、直ちにアプリがスケーリングすることが可能になった。
3.が本題で、Previewではモニター間をまたぐときに(途中でウィンドウをリサイズしても)1回しかWM_DPICHANGEDは来なかったが、RTMではまたぐ度に忠実に来るようになった。
これが何を意味するかというと、自分が観察した限りではOSはウィンドウとモニターの重なり合う面積が最大のものをそのウィンドウが属するモニターと見なし(Win32のMonitorFromWindow関数と同じ)、これに従ってWM_DPICHANGEDを送ってくるようなので、以下のループが発生し得る。
- ウィンドウがモニター間を移動するとき、移動元のモニターに重なる面積と、移動先のモニターに重なる面積を比べて、後者が大きくなったとき、すなわち属するモニターが変わったときにWM_DPICHANGEDが来る。
- これを受けて直ちにウィンドウをリサイズすると、リサイズはウィンドウの位置(左上角の位置)を固定して行われるので、移動元のモニターに重なる面積と、移動先のモニターに重なる面積が変化し、前者が大きくなることが起こり得る。
- 結果として、属するモニターが移動元のモニターに一旦戻る形で変わるので、再度WM_DPICHANGEDが来る(以後、これが起こらなくなる位置までウィンドウが移動するまでループ)。
いずれにせよ、このループを回避するには、ウィンドウをリサイズしたときに移動先のDPIと同じになるような方策が必要となる。
2. 具体策
やり方は色々あり得るが、大きく二つに分けられると思う。
- 即時型
直ちに、リサイズ後に移動先のDPIと同じになる位置に積極的にウィンドウを移動させた後、リサイズする。
- 遅延型
直ちにリサイズはせず、リサイズ後に移動先のDPIと同じになる位置までウィンドウが移動するまで待ってから、リサイズする。
2.1. 遅延型
まず遅延型のアルゴリズムの例。
- ウィンドウの移動開始(Form.ResizeBegin)と移動終了(Form.ResizeEnd)イベントを捉えて、移動中か否かをチェックできるようにしておく。
- WM_DPICHANGEDが来たときは、それに含まれる移動先のDPIと、ウィンドウの現在のDPIを比べ、これが違うときで、移動中のときは待機に入り、移動中でないときは直ちにリサイズする。逆にDPIが同じときは、待機を解除する。
- ウィンドウの移動(Form.Move)イベントを捉え、待機中のときは、
- その位置でウィンドウをリサイズした場合と同じ長方形を生成する。
- この長方形が属するモニターをMonitorFromRect関数で得て、スクリーン外でないことを確認の上、そのモニターのDPIをGetDpiForMonitor関数で得る。
- これが移動先のDPIと一致するときはリサイズを実行し、待機を解除する。DPIが一致しないときはそのまま待機を続ける(以後、DPIが一致する位置に移動するまで繰り返し)。
- 待機中に移動元のモニターに戻ったときは、またWM_DPICHANGEDが来るので、2.によって待機は解除される。
'Old (previous) DPI Private dpiOld As Single = 0 'New (current) DPI Private dpiNew As Single = 0 'Flag to set whether this window is being moved by user Private isBeingMoved As Boolean = False 'Flag to set whether this window will be adjusted later Private willBeAdjusted As Boolean = False 'Detect user began moving this window. Private Sub MainForm_ResizeBegin(sender As Object, e As EventArgs) Handles MyBase.ResizeBegin isBeingMoved = True End Sub 'Detect user ended moving this window. Private Sub MainForm_ResizeEnd(sender As Object, e As EventArgs) Handles MyBase.ResizeEnd isBeingMoved = False End Sub 'Catch window message of DPI change. Protected Overrides Sub WndProc(ByRef m As Message) MyBase.WndProc(m) Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h If (m.Msg = WM_DPICHANGED) Then 'wParam Dim lo As Single = W32.GetLoWord(m.WParam.ToInt32()) 'Hold new DPI as target for adjustment. dpiNew = lo If (dpiOld <> lo) Then If (isBeingMoved = True) Then willBeAdjusted = True Else AdjustWindow() End If Else willBeAdjusted = False End If End If End Sub 'Detect this window is moved. Private Sub MainForm_Move(sender As Object, e As EventArgs) Handles MyBase.Move If (willBeAdjusted = True) AndAlso IsLocationGood() Then willBeAdjusted = False AdjustWindow() End If End Sub 'Check if current location of this window is good for delayed adjustment. Private Function IsLocationGood() As Boolean If (dpiOld = 0) Then Return False 'Abort. Dim factor As Single = dpiNew / dpiOld 'Prepare new rectangle shrinked or expanded sticking Left-Top corner. Dim widthDiff As Integer = Convert.ToInt32(Me.ClientSize.Width * factor) - Me.ClientSize.Width Dim heightDiff As Integer = Convert.ToInt32(Me.ClientSize.Height * factor) - Me.ClientSize.Height Dim rect As New W32.RECT() With {.left = Me.Bounds.Left, .top = Me.Bounds.Top, .right = Me.Bounds.Right + widthDiff, .bottom = Me.Bounds.Bottom + heightDiff} 'Get handle to monitor that has the largest intersection with the rectangle. Dim handleMonitor As IntPtr = W32.MonitorFromRect(rect, W32.MONITOR_DEFAULTTONULL) If (handleMonitor <> IntPtr.Zero) Then 'Check if DPI of the monitor matches. Dim dpiX As UInteger Dim dpiY As UInteger Dim result As Integer = W32.GetDpiForMonitor(handleMonitor, W32.Monitor_DPI_Type.MDT_Default, dpiX, dpiY) If (result = 0) Then 'If S_OK (= 0) If (Convert.ToSingle(dpiX) = dpiNew) Then Return True End If End If End If Return False End Functionリサイズした場合の長方形の生成時には、タイトルバーを含めてクローム部分はDPIによって変わらないので、クライアント領域の分だけ元のウィンドウからサイズを増減しないと微妙にズレが出る点に注意。
2.2. 即時型
次に即時型のアルゴリズムの例。
- WM_DPICHANGEDが来たときは、現在のウィンドウの4つの角のそれぞれについて、その位置でウィンドウを固定してリサイズした場合と同じ長方形を生成する。この4つの長方形が移動先の候補となる。
- この長方形が属するモニターをMonitorFromRect関数で得て、スクリーン外でないことを確認する。さらに、この長方形の左上角と右上角のそれぞれについて、その位置を含むモニターをMonitorFromPoint関数で得て、少なくとも一方がスクリーン外でないことを確認する(タイトルバーがスクリーン外に行かないようにするため)。
- 長方形が属するモニターのDPIをGetDpiForMonitor関数で得る。
- これがWM_DPICHANGEDに含まれる移動先のDPIに一致するときは、この長方形の位置にウィンドウを移動させた後、リサイズする。
これらを候補とするのは、モニターの位置関係、ウィンドウの移動方向、DPIの大小関係にかかわらず、この中に必ず移動先のモニターに属するものがあると思われるから(厳密に数学的に正しいかは知らない)。移動先のモニターと同じDPIのモニターが他にもある場合、そのモニターに属する長方形となる可能性もあるが、同じDPIならそのまま移動できるので問題にはならないと思う。
また、これらを調べる順番は、左上角に近い方から進めた方がタイトルバーが移動する可能性が減るので、ユーザーのストレスを抑えられると思う。
この実装の主要部分。変数名などは遅延型と同じ。
'Old (previous) DPI Private dpiOld As Single = 0 'New (current) DPI Private dpiNew As Single = 0 'Catch window message of DPI change. Protected Overrides Sub WndProc(ByRef m As Message) MyBase.WndProc(m) Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h If (m.Msg = WM_DPICHANGED) Then 'wParam Dim lo As Single = W32.GetLoWord(m.WParam.ToInt32()) 'Hold new DPI as target for adjustment. dpiNew = lo If (dpiOld <> lo) Then MoveWindow() AdjustWindow() End If End If End Sub 'Move this window for immediate adjustment. Private Sub MoveWindow() If (dpiOld = 0) Then Exit Sub 'Abort. Dim factor As Single = dpiNew / dpiOld 'Prepare new rectangles shrinked or expanded sticking four corners. Dim widthDiff As Integer = Convert.ToInt32(Me.ClientSize.Width * factor) - Me.ClientSize.Width Dim heightDiff As Integer = Convert.ToInt32(Me.ClientSize.Height * factor) - Me.ClientSize.Height Dim rectList As New List(Of W32.RECT)() 'Left-Top corner rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left, .top = Me.Bounds.Top, .right = Me.Bounds.Right + widthDiff, .bottom = Me.Bounds.Bottom + heightDiff}) 'Right-Top corner rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left - widthDiff, .top = Me.Bounds.Top, .right = Me.Bounds.Right, .bottom = Me.Bounds.Bottom + heightDiff}) 'Left-Bottom corner rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left, .top = Me.Bounds.Top - heightDiff, .right = Me.Bounds.Right + widthDiff, .bottom = Me.Bounds.Bottom}) 'Right-Bottom corner rectList.Add(New W32.RECT() With {.left = Me.Bounds.Left - widthDiff, .top = Me.Bounds.Top - heightDiff, .right = Me.Bounds.Right, .bottom = Me.Bounds.Bottom}) 'Get handle to monitor that has the largest intersection with each rectangle. For i = 0 To rectList.Count - 1 Dim handleMonitor As IntPtr = W32.MonitorFromRect(rectList(i), W32.MONITOR_DEFAULTTONULL) If (handleMonitor <> IntPtr.Zero) Then 'Check if at least Left-Top corner or Right-Top corner is inside monitors. Dim handleLeftTop As IntPtr = W32.MonitorFromPoint(New W32.POINT(rectList(i).left, rectList(i).top), W32.MONITOR_DEFAULTTONULL) Dim handleRightTop As IntPtr = W32.MonitorFromPoint(New W32.POINT(rectList(i).right, rectList(i).top), W32.MONITOR_DEFAULTTONULL) If (handleLeftTop <> IntPtr.Zero) OrElse (handleRightTop <> IntPtr.Zero) Then 'Check if DPI of the monitor matches. Dim dpiX As UInteger Dim dpiY As UInteger Dim result As Integer = W32.GetDpiForMonitor(handleMonitor, W32.Monitor_DPI_Type.MDT_Default, dpiX, dpiY) If (result = 0) Then 'If S_OK (= 0) If (Convert.ToSingle(dpiX) = dpiNew) Then 'Move this window. Me.Location = New Point(rectList(i).left, rectList(i).top) Exit For End If End If End If End If Next End SubP/Invokeが多いのは、標準にはないことをしている以上、まあ仕方ない。
3. デモアプリ
以上を引っくるめて修正したデモアプリ。VBに加えてC#でも作成した。
- "CurrentAutoScaleDimensions"と"GetDeviceCaps (LOGPIXELSX)"は常に同じ(はず)。
- Windows 8.1では、"GetDpiMonitor"にこのウィンドウが属しているモニターのDPIが表示される。
- Windows 8.1では、"WM_DPICHANGED (Latest)"に最新のメッセージに含まれるDPIが表示される。
- 中央のボックスにはWM_DPICHANGEDが来た時刻、そのwParamに含まれるDPI、lParamに含まれるRECTの位置・サイズが表示される。VB版でDEBUGビルドすると、追加情報も表示される。
- ラジオボタンで即時型"Immediate"と遅延型"Delayed"の切り替えができる。遅延型の場合、その横に現在の待機状況が表示され、"Waiting"が待機中、"Resized"がリサイズしたこと、"Aborted"が待機に入ったが取りやめたことを示す。
- 下の"Create Button"は動的にコントロールを作成する例として。
完全なソースコード(VBとC#)
4. 残る問題
通知領域のあるモニターのDPI
モニター間の移動によるものでない、モニター設定によるDPIの変更は、そのモニターに属するウィンドウのみにWM_DPICHANGEDで知らされる。
それはそれで正しいのだが、通知領域アイコンを置いている場合で、アイコンの属するウィンドウが通知領域のあるモニターに現在表示されてない場合、そのモニターのDPIに変更があってもWM_DPICHANGEDは来ない。具体的には、そのウィンドウを最小化している場合や別のモニターに表示させている場合で、こういう場合は通知領域アイコンに付けたメニューなどをリサイズするトリガーがないことになる。
まあ何かのタイミングに引っ掛けて、例えばそのメニューが表示されるときのイベント(ContextMenuStripのVisibleChangedイベントなど)を捉えてDPIをチェックする方法が考えられるが、統一性には欠ける。
WM_DPICHANGEDのlParamのRECT
このRECTについて、MSDNの説明では"RECT structure that provides the size and position of the suggested window, scaled for the new DPI"となっている。
いかにもウィンドウのリサイズに利用できそうな感じがするが、実際に来ているものを見ると、関係があるっぽいのは分かるが正確に何を意味しているのかは分からないという……。別になくても困らないが、謎。
[追記]
通知領域のあるモニターのDPIに関する説明を増やした。
0 コメント :
コメントを投稿