以前、Zune ライクなウィンドウを作成する 投稿と Visual Studio 2012 のような光るウィンドウを作成する 投稿をしましたが、その内容のアップデートになります。先にこれらの記事を読んで頂けると嬉しいです。
今回は、主に WPF における DPI 対応のお話です (あまり需要がない)。
そもそも DPI って何ぞ? という方は、先日の勉強会の資料 もお読み頂けるといいかも。
何が足りなかった?
以前の投稿で足りていなかったもの、それはズバリ、高 DPI 対応です。
その他、GitHub 上で公開しているコードでは、スクリーン座標がマイナス値になったときに正しく表示されない不具合などもあったり。
ついでに、コードの見た目が非常によろしくない実装だったので、この辺も何とかしたいなぁと。
WPF での高 DPI 対応
先日の勉強会 にて、今後デスクトップ アプリケーションにおける高 DPI 対応は必須だ~などとお話させて頂きましたが、自分のコードが対応していなかったという体たらく。
非対応でしたので、例えば DPI 125 % 環境で実行すると、こんな残念な結果になってしまいます (GlowWindow の位置がズレてる)。
そもそも WPF は、デバイス非依存ピクセル (DIP) という単位で表現されており、この DIP はデバイスの DPI 設定に関わらず「1 DIP = 1/96 インチ」と定義されています。そのため、WPF では DPI を意識せずに UI を実装することができ、高 DPI 環境では DPI 設定と一致するように自動的にスケールされて表示されます。
この辺は、MSDN の WPF の概要ページ にも「解像度およびデバイスに依存しないグラフィックス」という記述がありますね。
このように、通常 WPF では自動的にスケールされるため、特に高 DPI 対応をする必要がありません。
しかし、それはデバイス非依存ピクセルによって表現される範囲であり、例えば Win32 API などを使用してデバイス非依存ピクセルと異なる単位の値を扱った場合、きちんと DPI 対応のための計算をする必要があります。
今回の「Visual Studio のような光るウィンドウ」もそれで、Win32 API を使用して SetWindowPos 関数などを使用しているため、デバイス非依存ピクセルから物理座標への変換をしなければなりませんでした。
上の画像で、四辺の光る効果がズレているのは、この変換における DPI 計算を怠ったためです。
対応方法 1
単純にスクリーン座標からクライアント座標に変換するだけなら、Visual クラスの PointFromScreen メソッドだけ大丈夫です (その逆も)。
Visual.PointFromScreen メソッド (System.Windows.Media)
Visual.PointToScreen メソッド (System.Windows.Media)
例えば、WPF でウィンドウ メッセージを処理するとき。HwndSource.AddHook メソッドに追加するウィンドウ プロシージャを実装すると思いますが、lParam で渡ってくるスクリーン座標 (物理ピクセル値) をコントロール内のローカル座標 (デバイス非依存ピクセル値) に変換するときは、上記のメソッドを使うだけで自動的に DPI 計算されます。
対応方法 2
上記以外で、Win32 API を叩くときなど、座標やサイズの DPI を自力で計算しなければならないときは、以下のようなコードで対応しましょう。
/// <summary> /// 現在の <see cref="T:System.Windows.Media.Visual"/> から、DPI 倍率を取得します。 /// </summary> /// <returns> /// X 軸 および Y 軸それぞれの DPI 倍率を表す <see cref="T:System.Windows.Point"/> /// 構造体。取得に失敗した場合、(1.0, 1.0) を返します。 /// </returns> public static Point GetDpiScaleFactor(this Visual visual) { var source = PresentationSource.FromVisual(visual); if (source != null && source.CompositionTarget != null) { return new Point( source.CompositionTarget.TransformToDevice.M11, source.CompositionTarget.TransformToDevice.M22); } return new Point(1.0, 1.0); }
CompositionTarget クラスの TransformToDevice プロパティで、DPI 倍率を取得できます。DPI 100 % 環境なら 1.0、DPI 125 % 環境なら 1.25、のような倍精度浮動小数点数で返ってくるので、上記の GetDpiScaleFactor 拡張メソッドではそれを Point 構造体に格納して返しています。
DPI 対応に必要なコードは以上ですので、あとは必要な箇所で DPI 倍率を掛けてやれば OK です。
例えば、WPF (デバイス非依存ピクセル値) の数値から、Win32 API に座標とサイズ (物理ピクセル値)を投げるシーンなど。こんな感じで。
var dpiScaleFactor = this.GetDpiScaleFactor(); // DPI 100 % 環境なら、{ X = 1.0, Y = 1.0 } // DPI 125 % 環境なら、{ X = 1.25, Y = 1.25 } var deviceLeft = wpfLeft * dpiScaleFactor.X; var deviceTop = wpfTop * dpiScaleFactor.Y; var deviceWidth = wptWidth * dpiScaleFactor.X; var deviceHeight = wpfHeight * dpiScaleFactor.Y; // P/Invoke (user32.dll) NativeMethods.SetWindowPos( this.handle, this.ownerHandle, (int)Math.Round(deviceLeft), (int)Math.Round(deviceTop), (int)Math.Round(deviceWidth), (int)Math.Round(deviceHeight), SWP.NOACTIVATE);
wpfXxx はデバイス非依存ピクセル (96 dpi 固定) の値です。そこに DPI 倍率を掛けて、deviceXxx (物理ピクセル値) に変換しています。P/Invoke で Win32 API を叩く場合は物理ピクセル値が必要ですので、上記のようなコードになります。
サンプルなど
というわけで、上記のような DPI 対応を実装した Visual Studio 2012 のようなウィンドウを、以前の投稿で公開したソースを修正する形で、GitHub で公開しています。
ソリューション内の VS2012LikeWindow2 プロジェクトがそれです。
なお、実行する際はソリューションのコンテキスト メニューから [NuGet パッケージ復元の有効化] メニューを選択するのをお忘れなきよう。
DPI 125 % 環境で実行してもこの通り、ズレたりしません。
ついでにその他いろいろ
おまけ的な。WPF の DPI 実装を見に来た方は読み飛ばして頂いて構いません。
修正ついでに、Visual Studio っぽいサイズ変更コントロール (ウィンドウ右下のやつ) を作ってみたり。もちろん Path です。 標準の ResizeGrip よりこっちのがかっこいいんですよね (※あくまで個人の感想です)。
<Style x:Key="ResizeGripIconElementKey" TargetType="{x:Type Path}"> <Setter Property="Data" Value="M6,0 L7,0 7,1 6,1 z M6,2 L6,3 7,3 7,2 z M6,4 L6,5 7,5 7,4 z M6,6 L6,7 7,7 7,6 z M0,6 L0,7 1,7 1,6 z M2,6 L2,7 3,7 3,6 z M4,6 L4,7 5,7 5,6 z M4,2 L4,3 5,3, 5,2 z M4,4 L4,5 5,5, 5,4 z M2,4 L2,5 3,5, 3,4 z" /> <Setter Property="Fill" Value="{DynamicResource ThemeForegroundBrushKey}" /> <Setter Property="Margin" Value="2" /> <Setter Property="HorizontalAlignment" Value="Right" /> <Setter Property="VerticalAlignment" Value="Bottom" /> <Setter Property="SnapsToDevicePixels" Value="True" /> </Style> <Style x:Key="ResizeGripIconShadowElemenKey" TargetType="{x:Type Path}" BasedOn="{StaticResource ResizeGripIconElementKey}"> <Setter Property="Fill" Value="{DynamicResource ThemeBackgroundBrushKey}" /> <Setter Property="Margin" Value="3" /> <Setter Property="Opacity" Value="0.75" /> </Style> <Style x:Key="ResizeGripIconKey" TargetType="{x:Type ContentControl}"> <Setter Property="Content"> <Setter.Value> <Grid> <Path Style="{DynamicResource ResizeGripIconElementKey}" /> <Path Style="{DynamicResource ResizeGripIconShadowElemenKey}" /> </Grid> </Setter.Value> </Setter> </Style>
上記コードを App.xaml か Themes/Generic.xaml などに張り付け、
<ContentControl Style="{DynamicResource ResizeGripIconKey}" />
のように ContentControl を配置すれば Visual Studio の ResizeGrip の見た目ができます。なお、GitHub 上のコードでは ResizeGrip コントロールを作成し、実際にリサイズ操作とカーソル変更をしています。興味あればそちらも是非ご覧くだし。
あと、以前の「ポリモーフィズム? なにそれ美味しいの?」と言わんばかりのカオスな実装を改め、GlowWindowProcessor なる抽象クラスを作成し、その派生クラス (GlowWindowProcessorLeft, GlowWindowProcessorRight, …) で上下左右の GlowWindow の座標計算をさせるようにしたり。
などなど、細かい修正等も加えた Visual Studio 2012 のような光るウィンドウは以下に (再掲)。
まとめ
というわけで、WPF における DPI 対応。方法は 2 つ。
- Visual.PointFromScreen (または PointToScreen) メソッドを使用
スクリーン座標 (物理ピクセル値) からコントロール内のローカル座標 (デバイス非依存ピクセル値) に変換するとき。 - PresentationSource.CompositionTarget.TransformToDevice プロパティから DPI 倍率を取得
Win32 API を叩く際、WPF (デバイス非依存ピクセル値) の数値から座標やサイズ (物理ピクセル値) を算出しなければならないとき。
でした。
ご活用ください。
投稿内容に間違い等ございましたらコメントや Twitter 等ご指摘ください ><