.NET 6 WinForms C# での高 DPI 対応における表示崩れの最小限の修正(仮)

2022/07/30, 2023/03/21

C# に初めて手を出し、わからないなりに試行錯誤して、とりあえずのところまで。
今時 WinForms ??という議論は大ありなのですが、まあそれは横に置いておいて。(WPF はメモリー消費量が多いらしい)
あくまで、これを書いている現在(2022/07/30, 2023/03/21)の情報です。.NET 6 のコモンコントロール関係のバグのようであり、そちらが修正されたらかえっておかしくなってしまうと思われるが、まあそのときはこれを解除してコンパイルしなおすということで。

目次

[2023/03/21] なお .NET 7 ではこのバグはもう直っているみたいです(詳細調査はこれから)。.NET 6 は捨ててさっさと .NET 7 に移行してしまったほうが良さそうです。その際この記事の内容は .NET7 に対しては逆効果なので、解除してください。
[2023/04/01] .NET 7 でのレポートを追加しましたので併せて参照ください。
[2023/12/16] .NET 8 での試行のレポートを追加しましたので併せて参照ください


やったこと

Visual Studio 2022 Community を使用。

新規プロジェクトの生成

「Windows フォームアプリ .NET Windows フォーム (WinForms) アプリを…」を選択。.NET6 になる。
紛らわしいが「Windows フォームアプリケーション (.NET Framework)」ではないほうを選んでいる。
最初に表示すべきフォームとして Form1 を置く。そのフォームのプロパティで AutoScaleMode を DPI モードにする。
csproj ファイルにおいて PropertyGroup セクション内のすでにある内容のあと(PropertyGroup 終了タグの前)に <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode> を追記する。 .NET 6 ではこれで PerMonitorV2 の動作になるようだ。しかし…

高 DPI の挙動調査

上記までで .NET6 はとりあえず High DPI にひととおり対応していることになっているのだが、コモンコントロールの類にはおかしな挙動をするものがある。
以下、プライマリディスプレイが 100% で、セカンダリ(または以降の)ディスプレイの表示倍率が 100% でない場合。

なお、プライマリディスプレイが拡大表示(125% とか)になっている場合は、ここからさらに、ちょっとした意地悪問題になるようだ。

高 DPI 対応の手当て

手当てその1:フォント、およびフォームの縦横サイズ

最初に表示するフォームの Load イベント Form1_Load() をこんな感じに。
なお、フォームデザイナでのコントロール配置は Visual Studio を表示倍率 100% のディスプレイに配置して作業を行うことを前提としている。

private void Form1_Load(object sender, EventArgs e)
{
    // MessageBox.Show(menuStrip1.DeviceDpi.ToString());
    float x = (float)menuStrip1.DeviceDpi / (float)this.DeviceDpi;

    var f1 = this.Font;
    // MessageBox.Show(f1.ToString());
    this.Font = new Font(f1.Name, x * f1.Size);

    var f2 = textBox1.Font;
    textBox1.Font = new Font(f2.Name, x * f2.Size);
    label2.Font = new Font(f2.Name, x * f2.Size);

    var locationOriginal = this.Location;
    this.Location = new Point(0, 0);
    this.Location = locationOriginal;
}
  1. フォーム Form1 それ自身のインスタンスの this.DeviceDpi は、フォームがロードされた直後はその起動ディスプレイに関わらず、プライマリディスプレイのそれになっているようである(つまりこれが諸悪の根源)。起動後にディスプレイを移動するとその値が反映されるが、それでは意味がない。
  2. 一方でフォーム上のコントロールのインスタンスの DeviceDpi は多くの物でフォームロード時点ですでに表示倍率が反映された値になっているようであり、それを得る。上の例ではメニューのインスタンス menuStrip1 を使っているが、ボタンとかでもいい。たとえばプライマリディスプレイが 100%、プログラムを起動したディスプレイが 125% 表示だと 120(=96dpi×125%)が返ってくる。
  3. そこから DPI の相対倍率を計算しておく。Form1this.DeviceDpi で除せばよい。 DeviceDpiint 型なので float 型へのキャストをお忘れなく。
  4. フォーム自身のフォントを相対倍率だけ大きくしたフォントを新たに作り、フォーム自身のフォントをそれに変更する。これで、フォームの上にあるコントロール(グループボックスの見出し、ラジオボタンとか)のフォントサイズはそれ専用のフォントをフォームデザイナで指定していなければ基本的には治る。
  5. ただしテキストボックスやラベルは(何故か)治らない(らしい)ので、フォームと同じようなフォントサイズ変更操作を行う。上の例では、テキストボックスとラベルで同じフォントを使うことを想定しているので、新しいフォントを1個作ってそれを両者に適用している。
  6. これだけではフォームのサイズが小さいまま・コントロール類のレイアウトが狂ったままなので、フォームの位置を強制的に移動することで DPI 変更を強制的に認識させる。
    1. 現在の位置を覚えておく。
    2. フォーム自身を原点 (0,0) つまりプライマリディスプレイの左上隅にいったん移動する。このときデザインが馬鹿でかいフォームだとうまくいかないかもしれない。最小動作要件としてフォームのデザインサイズよりもピクセル数が大きいディスプレイが存在することを期待するしかない。
      [2022/08/06追記] この時に DPI が変わる場合は結果的にフォームの物理縦横ピクセル数も変わるので Resize() イベントが発生するため、フォーム上のコントロールの配置調整を Form1_Resize() なり何なりに記述しておく必要がある。この次の「手当てその2:リストボックスの縦サイズ」および「手当てその3:その他」を参照。
    3. 現在の位置に戻す(再び Resize() イベントが発生する)。

なぜこれで直るのか不明かつ理解不能だが、もしかすると、フォントを new Font() として宣言するタイミングが Form の生成前か生成確立後かによって、表示倍率の扱いが PerMonitorV2 を示す内部的なフラグが立つかどうかが違うのかもしれない。
[2022/08/06追記] またその違いによって、フォームやコントロールのうち基底フォントサイズに依存するよう書かれてしまっている種類のオブジェクトのサイズ調整も PerMonitorV2 モードにするのかどうかが違っているのかもしれない(もはや LTS 宣言をしてしまった .NET 6.0 では修正できない?)。

手当てその2:リストボックスの縦サイズ

フォーム上の他のコントロールの TopBottom との相対距離から Height を都度計算して適用する。ある程度の精度が出ていればいい。どうせ行数単位で切り捨てられたサイズに自動調整されてしまうため、無理に細かく計算する意義はあまりない。
主に Form1_Resize() イベントでの対応になるが、リストボックスがフォーム直接でなくグループボックスやパネルに配されている場合はそれの Resize() イベントが適切なようだ。

手当てその3:その他

その他のコントロールも必要なものは位置を直したり大きさを調整するべく、必要なコードをフォームやグループボックス等コントロールの親となる要素の Resize() イベントハンドラに粛々と書いていく。こういった記述はフォームをリサイズ可能の設定にするならば High DPI 対応有無によらずどのみち記述が必要になる場合が多いので、特別な作業ではない。

以上

出来上がったプログラムを、表示倍率が 100% のディスプレイから起動しても、そうでないディスプレイから起動しても、行ったり来たりしても(動的変更)、一応なんとか最小限の崩れで済む。かなりましになる。
コントロールの種類やアンカーの設定、フォームデザイナ上のマウス操作による位置合わせの設定によっては、位置が微妙にずれるものもあるので、完璧とはいえないが、目くじらを立てなければ、なんとか実用になるレベル。

これ以上正しく完璧にする方法はよくわかりません。


子フォームのクライアント領域のコントロール類について

[2023/03/21]

子フォームは上記の方法だけではうまくいかないようです。そこで子フォーム上にパネルコントロール(以下 Panel)を配置し、その Panel 自体をフォームデザイナ上で Dock=Fill に設定して( Padding の二つ目(上)は 2~4 くらいに、他はゼロにすると塩梅がいいらしい)、その Panel のフォントを再設定するという手があるようです。
[C#] 高DPI環境下でWinFormsの画面レイアウトが崩れて大変だったけど何とかなった の文中「完全対応させるには一工夫が必要」の項目が大変参考になりました(感謝)。

ここでは Form1 を親(プログラムのエントリポイントから起動されるフォーム)、Form2 を子と仮定します。Form1 の実装には上述の PerMonitorV2 対応を施してあると仮定しています。
以下のコードで Form1.dpi0 はプログラムの起動フォーム(上の記述の Form1)のフォームコンストラクタで得ておき、これを何らかの手段で子フォームに渡します。子フォームにクラス変数(アクセッサっていうんでしたっけ)を用意するのが簡単みたいですがこれが C# として正しい流儀かどうかは知りません。

namespace WinFormsApp1
{
    //(中略)
    public partial class Form1 : Form
    {
        //(中略)
        public Form1()
        {
            InitializeComponent();
            dpi0 = (float)this.DeviceDpi; // this は冗長ですが備忘録的に書いています。
            //(以下略)
        }
        
        //(中略)
        // 何かの操作やイベントハンドラなど
        //(中略)
        
        private void Form1_何かのイベントハンドラ(object sender, DpiChangedEventArgs e)
        {
            if (Form2 を子フォーム(modal dialog)として起動する条件)
            {
                using (Form2 form2 = new Form2())
                {
                    // form2 インスタンスに dpi0 の値を伝える。Form2 側に dpi0 アクセッサを別途定義。
                    form2.dpi0 = this.dpi0; // this は冗長ですが備忘録的に書いています。
                    // 子フォームをモーダルダイアログとして表示し、リターン時に結果を取得。
                    DialogResult result = form2.ShowDialog(this);
                    //(以下略)
                }
                //(以下略)
            }
            //(以下略)
        }
        //(以下略)
    }
}

そして子フォーム側の Load イベントを少し加工して、前述した Form1 の Load イベント時の追加処置とほぼ同じことを実施します。フォームデザイナにおいて、子フォームのすべてのコントロールはこの Panel に関連付けた形で Panel の内部に配置するとともに、それらコントロールの位置合わせ・アンカー設定などもあくまでこの Panel に対して行うようにすると、コントロール類は Panel のフォント属性やサイズ調整機能等を継承して動作するようです。この Panel にさらに一つ以上の子 Panel を入れ子にすればウインドウサイズ変更処理時のコントロール位置調整などもやりやすくなると思いますが未確認。つぶさには調べていませんので Listbox などは抜けがあるかもしれません。

namespace WinFormsApp1
{
    //(中略)
    public partial class Form2 : Form
    {
        // プログラム全体の起動フォームの dpi 初期値を受領するための変数
        public float dpi0 { get; set; }
        
        //(中略)
        private void Form2_Load(object sender, EventArgs e)
        {
            // 子フォームのクライアント領域の全域に panel1 というパネルを Dock=Fill で配置してあると仮定。
            // すべてのコントロールは panel1 に関連付ける形で panel1 の内部に配置。
            
            
            // 子フォームが生成されたディスプレイの現在の拡大率を求める。
            // 拡大率反映後の dpi は panel1 から得られる。
            float x = (float)panel1.DeviceDpi / dpi0;
            
            // panel1 のフォントを拡大率に従ったサイズに設定しなおす。
            var font_panel1 = panel1.Font;
            panel1.Font = new Font(font_panel1.Name, x * font_panel1.Size);
            
            // フォームを一旦移動して強制的に DPI スケーリングを行わせる。
            var loctemp = this.Location;
            this.Location = new Point(0, 0);
            this.Location = loctemp;
            
            //(以下略)
        }
        //(以下略)
    }
    //(以下略)
}

もしかすると親フォームも、この Panel に関連付けるやり方で統一的に対応可能なのかもしれません。時間ができたらやってみたいと思います。


略歴


トップページ / 計算機関係の雑多なこと / .NET 6 WinForms C# での高 DPI 対応における表示崩れの修正
このサイトについて・著者連絡先