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 の動作になるようだ。しかし…
上記までで .NET6 はとりあえず High DPI にひととおり対応していることになっているのだが、コモンコントロールの類にはおかしな挙動をするものがある。
以下、プライマリディスプレイが 100% で、セカンダリ(または以降の)ディスプレイの表示倍率が 100% でない場合。
this.DeviceDpi
は必ずプライマリディスプレイのそれになっている。要はフォームクラスのコンストラクタが誤っているらしい。これが Microsoft による実装のミスの根幹ともいえる。
DpiChanged
イベントが発生しない。フォーム自身の DPI を保持する変数の数値自体は変わっていないので当然そうなってしまうのだが、これも立派なバグである。
DpiChangedBeforeParent
イベントと DpiChangedAfterParent
イベントがこの順で(正しく)発生する。
DpiChangedBeforeParent
イベント、フォーム自身の DpiChanged
イベント、フォーム上のコントロール類の DpiChangedAfterParent
イベントが、この順で(正しく)発生する。
なお、プライマリディスプレイが拡大表示(125% とか)になっている場合は、ここからさらに、ちょっとした意地悪問題になるようだ。
最初に表示するフォームの 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; } |
Form1
それ自身のインスタンスの this.DeviceDpi
は、フォームがロードされた直後はその起動ディスプレイに関わらず、プライマリディスプレイのそれになっているようである(つまりこれが諸悪の根源)。起動後にディスプレイを移動するとその値が反映されるが、それでは意味がない。
DeviceDpi
は多くの物でフォームロード時点ですでに表示倍率が反映された値になっているようであり、それを得る。上の例ではメニューのインスタンス menuStrip1
を使っているが、ボタンとかでもいい。たとえばプライマリディスプレイが 100%、プログラムを起動したディスプレイが 125% 表示だと 120(=96dpi×125%)が返ってくる。
Form1
の this.DeviceDpi
で除せばよい。 DeviceDpi
は int
型なので float
型へのキャストをお忘れなく。
(0,0)
つまりプライマリディスプレイの左上隅にいったん移動する。このときデザインが馬鹿でかいフォームだとうまくいかないかもしれない。最小動作要件としてフォームのデザインサイズよりもピクセル数が大きいディスプレイが存在することを期待するしかない。Resize()
イベントが発生するため、フォーム上のコントロールの配置調整を Form1_Resize()
なり何なりに記述しておく必要がある。この次の「手当てその2:リストボックスの縦サイズ」および「手当てその3:その他」を参照。
Resize()
イベントが発生する)。
なぜこれで直るのか不明かつ理解不能だが、もしかすると、フォントを new Font()
として宣言するタイミングが Form の生成前か生成確立後かによって、表示倍率の扱いが PerMonitorV2
を示す内部的なフラグが立つかどうかが違うのかもしれない。
[2022/08/06追記] またその違いによって、フォームやコントロールのうち基底フォントサイズに依存するよう書かれてしまっている種類のオブジェクトのサイズ調整も PerMonitorV2
モードにするのかどうかが違っているのかもしれない(もはや LTS 宣言をしてしまった .NET 6.0 では修正できない?)。
フォーム上の他のコントロールの Top
や Bottom
との相対距離から Height
を都度計算して適用する。ある程度の精度が出ていればいい。どうせ行数単位で切り捨てられたサイズに自動調整されてしまうため、無理に細かく計算する意義はあまりない。
主に Form1_Resize()
イベントでの対応になるが、リストボックスがフォーム直接でなくグループボックスやパネルに配されている場合はそれの Resize()
イベントが適切なようだ。
その他のコントロールも必要なものは位置を直したり大きさを調整するべく、必要なコードをフォームやグループボックス等コントロールの親となる要素の 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 対応における表示崩れの修正
このサイトについて・著者連絡先