Skip to content

MS_DotNetControlCustomization

nishi_74322014 edited this page Aug 21, 2026 · 1 revision

.NETコントロールのカスタマイズ方法

概要

.NET Framework では、画面の開発に必要となるコントロールが用意されている。

.NET では、これらのコントロールの動作をカスタマイズすることで、
特定プロジェクト向けにカスタマイズされた
(例えば、自動編集処理、自動入力チェック処理、サニタイジング処理、などの機能を有した)
コントロール共通部品開発が可能である。

ここでは、この .NET コントロール
ASP.NET Web Forms, Windows Forms)の
カスタマイズ方法について説明する。

補足(本ページの位置付けと、今読む価値): 対象は
**ASP.NET Web FormsWindows Forms**であり、
どちらも .NET Framework で終着した技術である
(.NET Core 以降、Web Forms は移植されず、Windows Forms は
Windows 限定で存続している)。

【それでも本ページに価値がある理由】★
 ① 【Windows Forms は .NET 8/9 でも現役】
      Windows 専用ではあるが、
      .NET 5 以降も継続的に保守されている
      → 本ページの Windows Forms 側の記述は【今も有効】★

 ② 【既存資産の保守】
      Web Forms のカスタム コントロールを抱えた
      業務システムは今も多数稼働している

 ③ 【設計思想は普遍的】
      「共通部品でプロジェクト標準を強制する」
      という考え方は、
      WPF のスタイル/テンプレート、
      Blazor のコンポーネント、
      React のコンポーネントでも同じである
【今の対応物】
   Web Forms のカスタム コントロール
     → 【Blazor コンポーネント】(.razor)★
     → ASP.NET Core の【タグ ヘルパー / ビュー コンポーネント】
   Windows Forms のカスタム コントロール
     → 【WPF のカスタム コントロール + スタイル】
     → 詳細は [WPFのコントロール](MS_WPFControls)

ASP.NET Web Forms で使用するコントロールの特徴は、
Web アプリケーションであるために、

  • 表示は HTML により行われるのでカスタマイズしやすい。
  • ただし、状態の持ち回りについて考慮する必要がある。

という点である。

クラス定義

以下は、ラベル コントロール(System.Web.UI.WebControls.Label)を継承した
カスタム ラベル コントロール(Ctrl.WebCustLabel)のクラス定義。

[assembly: TagPrefix("Ctrl", "my_wcc")]
namespace Ctrl {
  /// <summary>System.Web.UI.Labelのカスタム・コントロール</summary>
  [DefaultProperty("Text"),
  ToolboxData("<{0}:WebCustLabel runat=server></{0}:WebCustLabel>")]
  public class WebCustLabel : Label

このクラス定義に含まれる
ToolboxData
属性は、カスタム コントロールに必要な属性で、
Visual Studio(以下、VS と略す)のツールボックスからドラッグ&ドロップされるときに
生成される、カスタム タグを表す文字列を指定する。
また、「DefaultProperty
属性は既定のプロパティを指定するオプション属性である。
これ以外に、(クラス定義には含まれないが)名前空間定義に含まれる
TagPrefix
属性により「ToolboxData」属性の「{0}」に設定されるデフォルトの文字列
(タグ プレフィックス)を指定できる。この属性もオプション属性である。

プロパティ

カスタム プロパティの追加

本項ではカスタム プロパティの追加方法について説明する。
カスタム プロパティを追加する際は、通常通りにプロパティ プロシージャを実装すれば良い。

/// <summary>コントロール内のテキストを反転するかどうかを示す。</summary>
[DefaultValue("false"),
Category("動作"),
Description("コントロール内のテキストを反転するかどうかを示します。")]
public bool Reverse {
  set { this.ViewState["Reverse"] = value; }
  get {
    return (this.ViewState["Reverse"] == null) ?
      false : (bool)this.ViewState["Reverse"];
  }
}
  • ただし、ASP.NET Web Forms アプリケーションにおいて
    コントロールの状態を持ち回る(状態を複数ポストバック間において保存する)場合は、
    インスタンス変数は用いず、Control.ViewState を使用する必要がある。
    これは Page.ViewState とは異なるため、
    同じキーを使用しても衝突しない。
    詳しくは、「ASP.NETの状態管理方式」を参照。

  • また、ここでも同様にオプションとして、
    DefaultValue」・
    Category」・
    Description
    などの属性を定義できる。
    これらの属性は共に、PropertyGrid(コントロールのデザインタイム・プロパティの設定を
    行うための UI で、Visual Studio デザイナでコントロール選択時、
    右下のペインに表示されるグリッド)への表示方法を制御するためのものである。
    PropertyGrid への表示方法を制御するための属性について、以下にまとめる。

メンバ名 説明 デフォルト値
DefaultValue デフォルト値を表す。
Browsable プロパティを PropertyGrid に表示するかどうかを指定する。 True
Category プロパティをまとめるカテゴリ。
カテゴリには、次の共通カテゴリがある。
・Appearance:表示
・Behavior:動作
・Data:データ
Description プロパティの設定を行う際の参考として PropertyGrid に表示される説明。

補足(DefaultValue の落とし穴): 上表の DefaultValue
「既定値を設定する」属性ではない点に注意が必要である。

【DefaultValue が実際にすること】★
   ・PropertyGrid で【既定値と同じ値なら太字にしない】
   ・デザイナが生成するコード(.designer.cs / .aspx)に
     【その値を書き出さない】
     → 「既定値と同じなら省略する」ための目印にすぎない

   → 【実際の初期値はコンストラクタ等で自分で設定する】必要がある
     本ページの Reverse プロパティも、
     getter で null 時に false を返すことで初期値を実現している ★

【型の一致に注意】★
   上の例は [DefaultValue("false")] と【文字列】で書いているが、
   プロパティの型は bool である。
     → 正しくは [DefaultValue(false)]
     → 文字列で渡すと型が一致せず、
       「既定値と同じなのにデザイナがコードを吐く」
       という現象になる

既存プロパティのカスタマイズ

  • 本項では既存プロパティのカスタマイズ(オーバーライド)方法について説明する。
    既存プロパティのカスタマイズの際は、既存プロパティをオーバーライドする必要がある。
    カスタマイズ対象の既存のプロパティは、派生クラスでオーバーライドが可能なように、
    virtual / abstract キーワード(VB のキーワードでは、Overridable / MustOverride に該当)が
    設定されている必要がある。

  • また、何らかの処理を追加した後、「base.オーバーライドしたプロパティ」から値を取得し、
    その後にカスタム動作となる処理を追加する
    (に値を設定し、その前にカスタム動作となる処理を追加する)ようにする。

移行メモ(誤字): 移行元の「何からの処理を追加した後」を
何らかの処理を追加した後」に修正した。

Label.Textのカスタマイズ

以下は、Text プロパティをオーバーライドしカスタム動作を実装するサンプル(テンプレート)である。

/// <summary>Textプロパティに機能を追加実装する。</summary>
public override string Text {

  // ベースのプロパティを使用する。

  set {
    string temp = value;
    // カスタム動作の処理を実装する。
    base.Text = temp;
  }

  get {
    string temp = base.Text;
    // カスタム動作の処理を実装する。
    return temp;
  }
}

TextBox.Enabled, ReadOnlyのカスタマイズ

  • なお、以下は、テキストボックス コントロール(System.Web.UI.WebControls.TextBox)を
    継承したカスタム テキストボックス コントロール(Ctrl.WebCustTextBox)の
    Enabled プロパティのカスタマイズ例である。

  • ここでは、Enabled プロパティの setter メソッド内部で、
    実際にメンバ変数に値をセットする処理を ReadOnly プロパティに変更している。
    これは、例えば Enabled = false で実装したものの、後工程での指摘により、
    ReadOnly = true 的な動作(表示)に変更する必要に迫られた場合など、
    最小限の工数で対応するために、このような修正を施すことがある。
    また、併せて、ReadOnly プロパティをオーバーライドしカスタム動作として、
    背景色の変更処理を実装する。

  • このカスタマイズにより、Enabled プロパティの挙動を ReadOnly に一括変更でき、
    また ReadOnly = true に設定された場合、背景色をグレーに変更するので、
    「読み取り専用項目」であることが、ユーザ(オペレータ)に伝わり易くなる。

/// <summary>活性・非活性を、Enabledから、ReadOnlyに変更したい場合。</summary>
public override bool Enabled {
  set {
    this.ReadOnly = !value;
  }
  // getは親のEnabledのgetを使用
}

また、背景色をグレーに変更するにあたって、元の背景色のバックアップを取っておく必要があるが、
これについても、持ち回る(状態を複数ポストバック間において保存する)必要があるので、
同様にインスタンス変数は用いず、
Control.ViewState を使用する必要がある。

/// <summary>ReadOnlyプロパティに背景変更処理を追加する。</summary>
public override bool ReadOnly {
  // ベースのプロパティを使用する。

  set {
    base.ReadOnly = value;

    // 背景変更
    if (base.ReadOnly) {
      this.ViewState["bkBgColor"] = this.BackColor;
      this.BackColor = System.Drawing.Color.LightGray;
    }
    else {
      this.BackColor =
        (this.ViewState["bkBgColor"] == null)
          ? this.BackColor : (System.Drawing.Color)this.ViewState["bkBgColor"];
    }
  }

  get {
    return base.ReadOnly;
  }
}

補足(この「Enabled を ReadOnly にすり替える」設計の意義と危うさ):
現場的には極めて実務的な工夫だが、副作用も理解しておくべきである。

【なぜこの工夫が有効なのか】★
   disabled にすると【値が POST されない】
     → サーバ側で値を受け取れず、
       画面復元のたびに再設定が要る
   readonly なら【値は POST される】
     → 表示だけ抑止して値は保持できる
   (→ [Gridのヘッダ固定方法](MS_GridHeaderFix) の
      readonly / disabled 比較表も参照)

   「Enabled=false と書いた既存コードを一切触らずに
    挙動だけ ReadOnly に一括変更できる」
   という点が、この設計の眼目である ★
【ただし危うさもある】
 ① 【Enabled の get が嘘をつく】★
     setter は ReadOnly に流しているのに
     getter は base.Enabled(=常に true)を返す
     → 「Enabled=false にしたのに
       Enabled が true を返す」という
       極めて分かりにくい状態になる
     → getter も !this.ReadOnly を返すよう
       揃えておく方が安全

 ② 【readonly は入力を防ぐが、送信は防がない】
     開発者ツールで readonly を外せば値を送れる
     → 【サーバ側で必ず再検証する】★

 ③ readonly は input / textarea にしか効かない
     → CheckBox / RadioButton / DropDownList には
       使えず、disabled しかない
     → コントロールの種類で挙動が揃わない点に注意 ★
【ViewState に色をしまう点について】
   Color 構造体を ViewState に入れると
   【ViewState のサイズが増える】
     → ViewState は hidden として
       毎リクエスト往復するため、
       コントロール数×項目数で効いてくる
     → 「元の色」は CSS クラスの付け外しで表現し、
       ViewState には【bool 1 個】だけ持つ方が軽い ★

UIのカスタマイズ

ASP.NET Web Forms で使用するコントロールは、
表示の制御が HTML により行われるのでカスタマイズしやすい。

Render系メソッド

ここで出力される HTML のカスタマイズは、Render 系メソッドのカスタマイズにより対応する。

カスタム コントロールを Control クラスから派生させて
(後述の「コントロールの基本クラスを継承したスクラッチ開発」の方法で)
スクラッチ開発する場合は、Render 系メソッドもスクラッチ開発するが、
それ以外の場合は、必要に応じて動作変更するようにして、
動作変更の必要がない場合は、処理を基本クラスの「base.xxxx」メソッドに
委譲するような方法を採る。

Render 系メソッドにおいて HTML を描画するには、
引数に渡された HtmlTextWriter クラスを使用する。

などがある。

余談となるが、HtmlTextWriter は TextWriter の Decorator として設計されている。

補足(AddAttribute を使うべき理由): 本節が挙げるメソッドに加え、
実務では AddAttribute / AddStyleAttribute が重要である。

【生の Write と AddAttribute の違い】★
   ✕ output.Write("<input type='text' value='" + this.Text + "'>");
       → 【HTML エスケープされない】= XSS の穴になる ★
       → 属性値の引用符も自前で面倒を見ることになる

   ○ output.AddAttribute(HtmlTextWriterAttribute.Type, "text");
     output.AddAttribute(HtmlTextWriterAttribute.Value, this.Text);
     output.RenderBeginTag(HtmlTextWriterTag.Input);
     output.RenderEndTag();
       → 【属性値が自動的にエンコードされる】★
       → タグの入れ子も RenderEndTag が対応付けて閉じる

   ※ AddAttribute した属性は、
     【次の RenderBeginTag に適用されて消費される】
【原文の「Decorator として設計されている」について】
   正確には、HtmlTextWriter は TextWriter を継承しつつ
   内部に TextWriter を保持して委譲する構造。
   → 後述の RenderControl の実装例で
     【StringWriter を内側に差し込む】ことができるのは
     この設計のおかげである ★

Render メソッドには、必要に応じて HTML 描画コードをオーバーライドして実装する。

Render メソッドの HTML 描画処理は、更に、以下の 3 つのメソッドから構成される設計と
なっているので、必要であれば、こちらのメソッドをオーバーライドすることで、
開始タグ、タグ内テキスト、終了タグと局所的に HTML 描画処理をカスタマイズできる。

例えば、次のような実装を行うと、コントロールの HTML 出力として、
<test> test </test> が出力される。
RenderContents メソッドのみオーバーライドすれば、
タグ内に出力される文字列の出力のみカスタマイズできる。
必要であれば、スタイルの属性・属性値を HTML タグに反映させること。
また、input タグの場合は、id、name などの属性・属性値を出力し、
次のポストバックでコントロールが正しく復元されるように実装する必要がある。

/// <summary>RenderBeginTagのテスト</summary>
public override void RenderBeginTag(HtmlTextWriter output) {
  output.Write("<test>");
}

/// <summary>RenderContentsのテスト</summary>
protected override void RenderContents(HtmlTextWriter output) {
  output.Write("test");
}

/// <summary>RenderEndTagのテスト</summary>
public override void RenderEndTag(HtmlTextWriter output) {
  output.Write("</test>");
}

補足(name 属性を自前で出す際の要点): 原文が触れている
「id、name などの属性・属性値を出力し、次のポストバックで
コントロールが正しく復元されるように実装する」は、最大のハマりどころである。

【使うべきプロパティ】★
   ・this.ClientID        … HTML の id 属性用(クライアント側)
   ・this.UniqueID        … 【name 属性用】(サーバ側の識別子)★
                            ← ポストバック値の復元はこれで決まる

   output.AddAttribute(HtmlTextWriterAttribute.Id,   this.ClientID);
   output.AddAttribute(HtmlTextWriterAttribute.Name, this.UniqueID);

   → name に ClientID を使うと
     【ポストバックで値が戻ってこない】
     (Repeater / GridView の中に置くと特に顕在化する)

【値の受け取り側】
   IPostBackDataHandler を実装すると、
   LoadPostData(postDataKey, postCollection) で
   自前の値を拾える ★
     → postDataKey は UniqueID と一致する

RenderControl メソッドは、上記の Render メソッドの制御コード(前・後処理など)を
実装する(Render メソッドの呼び出し自体も実装する)。必要に応じてこれをオーバーライドする。
RenderControl メソッドには、Render メソッドで描画された HTML を取得し、
それを変換する処理を実装することも可能である。

DisplayRawTextカスタム プロパティの実装例(Render)

以下は、カスタム プロパティによって、HTML の描画処理を変更する Render メソッドの
実装例である(変更の必要がない場合は、「base.Render」に処理を委譲する)。
興味深いのは、(追加したカスタム プロパティである)DisplayRawText、Hide プロパティを
true に設定した場合である。この場合、HTTP レスポンスにコントロールの HTML が
出力されなくなるが、以降 DisplayRawText、Hide プロパティを false に戻した場合、
コントロールが正しく復元される。これは、このコントロールの復元処理は、
ViewState により実現されており、
ViewState の情報は、
コントロールが出力する input タグから分離された
ViewState 専用の Hidden タグに保存されるためである。

/// <summary>
/// Renderメソッドは、
/// ・RenderBeginTag(開始のタグ)
/// ・RenderContents(中間の部分)
/// ・RenderEndTag(終了のタグ)
/// の各メソッドをこの順に呼び出して、コントロールをクライアントに送信する。
/// </summary>
/// <remarks>
/// このメソッドは、表示中にページによって自動的に呼び出される。
/// また、このメソッドは、主にコントロールの開発者によって使用される。
/// </remarks>
protected override void Render(HtmlTextWriter output) {
  // Render処理を作り込む。

  if (this.DisplayRawText) {
    // 生テキストの表示
    output.Write(this.Text);
  }
  else if (this.Hide) {
    // 出力時に隠す(データ自体は保持)
    output.Write("");
  }
  else {
    // 通常通りの出力
    base.Render(output);
  }
}

補足(DisplayRawText は XSS の入口になりうる): 「生テキストの表示」は
HTML タグをそのまま出力するということであり、意図的なエスケープ回避である。

【危険な条件】★
   Text の内容が【ユーザ入力に由来する】場合、
   DisplayRawText=true にした瞬間に
   【格納型 XSS が成立する】

   → 使ってよいのは
     「Text が【開発者が定めた固定文字列】である」
     と保証できる場合に限る
   → ユーザ由来の値を混ぜるなら
     【許可タグのホワイトリスト方式でサニタイズ】する
     (HtmlSanitizer 等)★

【Hide について】
   出力を "" にするだけなので、
   ・ViewState には値が残る(原文の説明どおり)
   ・つまり【機密情報を Hide で隠しても
     ViewState 経由でクライアントに渡る】★
     → ViewState は既定で
       署名(MAC)はされるが【暗号化はされない】
     → 機密を隠す目的には使わないこと

ToUpper, ToLowerカスタム プロパティの実装例(RenderControl)

以下は、カスタム プロパティによって、Render メソッドにより描画された HTML 出力を
編集する RenderControl メソッドの実装例である
(編集の必要がない場合は、「base.RenderControl」に処理を委譲する)。
Render メソッドにより描画された HTML 出力を取得するには、
Render メソッド内部で使用する HtmlTextWriter を変更する。
変更内容は、HtmlTextWriter が内部で使用する TextWriter クラスを既定の型から、
StringWriter クラス型に変更する。
これにより、Render メソッドにより描画された HTML 出力を、
RenderControl メソッド中で取得できる。

/// <summary>
/// Visibleプロパティ、ページのトレースなどの
/// 制御を行い、ページにコントロールを表示する。
/// </summary>
/// <remarks>
/// このメソッドは、表示中にページによって自動的に呼び出される。
/// カスタム コントロールの開発者はこのメソッドをオーバーライドできる。
/// </remarks>
public override void RenderControl(HtmlTextWriter output) {

  if (this.ToUpper || this.ToLower) {

    // RenderControlにRenderの制御処理を作り込む。

    // StringWriterの書き出し先のStringBuilderを生成
    StringBuilder sb = new StringBuilder();
    // 上記StringBuilderを使用するStringWriterを生成
    StringWriter sw = new StringWriter(sb);

    // 上記StringWriterを使用するHtmlTextWriterを生成
    HtmlTextWriter htw = new HtmlTextWriter(sw);

    // StringWriterを使用するHtmlTextWriterを指定し、Renderメソッドを実行する。
    this.Render(htw);

    // RenderメソッドでRenderされた、コントロールのHTMLを
    // StringWriterに指定したStringBuilderから取得する。
    string html = sb.ToString();

    // HTMLの編集処理
    if (this.ToUpper) {
      html = html.ToUpper();
    }
    else if (this.ToLower)
    {
      html = html.ToLower();
    }
    else
    { 
      // ここは通らない。
    }

    // ページにコントロールを表示
    output.Write(html);
  }
  else {
    // ベースのRenderControlを使用する。
    base.RenderControl(output);
  }
}

移行メモ(コメントの誤り): 移行元では
StringWriter sw = new StringWriter(sb); の直前のコメントが
StringWriter を使用するStringWriter を生成」となっていたが、
引数は StringBuilder であるため
上記 StringBuilder を使用するStringWriter を生成」に修正した。

補足(この手法の注意点): 「Render 済みの HTML を後から加工する」
という発想は応用が利くが、踏んではいけない地雷がある。

【① ToUpper / ToLower は HTML 全体に効く】★
   html.ToUpper() は
   【タグ名も属性名も属性値も】まとめて大文字化する
     <span id="lblName" style="color:Red">abc</span>
       → <SPAN ID="LBLNAME" STYLE="COLOR:RED">ABC</SPAN>

   ・タグ名・属性名は大文字でも動くが、
   ・【id / name の値が大文字化されると
     JavaScript や ViewState の突き合わせが壊れる】★
   ・URL のパス部分は大小を区別するサーバもある

   → テキストだけ変えたいなら
     【RenderContents をオーバーライドする】か、
     そもそも Text プロパティの getter で変換する方が正しい ★

【② カルチャ依存】
   ToUpper() は現在のカルチャに依存する
     → トルコ語ロケールで "i" が "İ" になる
       (有名な "Turkish I" 問題)
     → 【ToUpperInvariant()】を使うこと ★

【③ Visible=false のときの挙動】
   base.RenderControl は
   Visible=false なら【何も出力しない】が、
   上のコードは this.Render(htw) を直接呼ぶため
   【Visible を無視して出力してしまう】★
     → if (!this.Visible) return; を先頭に入れる必要がある

【④ using で破棄する】
   StringWriter / HtmlTextWriter は IDisposable
     → using で囲むのが作法

コンストラクタ

コンストラクタについては自由な利用が可能である。

ここではスタイル関係のプロパティに初期値を設定。

/// <summary>コンストラクタ</summary>
/// <summary>コンストラクタでプロジェクトなどでの標準スタイルを適用する。</summary>
public WebCustLabel()
{
  // 初期設定のプロパティ値を設定する。
  // ※ デザインタイム・プロパティのほうが優先される。
  this.Font.Size = 12;
  this.ForeColor = System.Drawing.Color.Red;
  this.Font.Name = "MS ゴシック";
}

移行メモ(XML コメントの重複): 移行元では <summary> タグが
2 行続けて記述されている(コンパイラ警告 CS1573/CS1591 系の対象となる)。
作者の記述をそのまま残したが、実際にはいずれか一方にすべきである。

デザイナとコンストラクタのコードが干渉する問題

コンストラクタで子コントロール追加すると二重追加される件を参照。

カスタム サーバ コントロールのメタデータ属性

https://learn.microsoft.com/ja-jp/previous-versions/aspnet/ms178658(v=vs.100)

クラス定義

以下は、ラベル コントロール(System.Windows.Forms.Label)を継承した
カスタム ラベル コントロール(Ctrl.WinCustLabel)のクラス定義。

namespace Ctrl
{
  /// <summary>System.Windows.Forms.Labelのカスタム・コントロール</summary>
  [DefaultProperty("Text")]
  public class WinCustLabel : Label

プロパティ

Windows Forms アプリケーションのカスタム コントロールでは、
カスタム プロパティのうち、表示に影響を与えるものがあったら、
カスタム プロパティの setter メソッドで Refresh メソッドを実行する必要がある。
また、ASP.NET Web Forms では、状態保持のために
ViewState を使用したが、
Windows Forms アプリケーションでは通常通りメンバ変数を使用する。

/// <summary>文字列反転</summary>
private bool _Reverse = false;

/// <summary>テキストを反転するかどうかを示す。</summary>
[DefaultValue("false"),
Category("動作"),
Description("テキストを反転するかどうかを示します。")]
public bool Reverse {
  set {
    this._Reverse = value;

    // 表示に影響を与えるプロパティの
    // setterにはRefreshメソッドを仕込む。
    this.Refresh();
  }
  get { return this._Reverse; }
}

補足(Refresh() は強すぎることが多い): 動作としては正しいが、
より適切な選択肢がある。

【再描画を促すメソッドの違い】★
   Invalidate()   … 【無効領域を登録するだけ】
                    → 次の描画タイミングでまとめて再描画される
                    → 最も軽い ★
   Update()       … 無効領域を【今すぐ】描画する
   Refresh()      … Invalidate() + Update()
                    → 【即座に同期的に再描画】=重い

   → プロパティの setter で毎回 Refresh すると、
     連続してプロパティを設定した際に
     【その回数だけ同期描画が走る】
     → 通常は【Invalidate() で十分】★

【値が変わっていないなら何もしない】
   set {
     if (this._Reverse == value) return;   // ← 入れる ★
     this._Reverse = value;
     this.Invalidate();
   }

【レイアウトに影響するなら】
   サイズ・位置が変わる変更は
   【Invalidate だけでは足りない】
     → PerformLayout() / SuspendLayout-ResumeLayout を使う

表示に影響を与えるプロパティのgetter

Windows Forms アプリケーションのカスタム コントロールの
プロパティのカスタマイズは、基本的に ASP.NET Web Forms
コントロールのカスタマイズ方法と変わらないが、
表示に影響を与えるプロパティの getter メソッドのみ変更するカスタマイズを施すと、
コントロールが認識している描画領域・位置と合わなくなり、
すべての文字が描画されないなどの問題が発生する。

故に、Windows Forms アプリケーションのカスタム コントロールでは、
描画にかかわるプロパティの getter メソッドのみをカスタマイズすることは不可能である。
例えば、getter メソッド内で setter メソッドを呼び出し、描画領域・位置の再計算・再描画
させるという方法も、この Refresh メソッドによる描画領域・位置の再計算・再描画にて、
getter メソッドを呼び出すため、
getter → setter → 再描画(Refresh) → getter の無限再帰
(スタック オーバーフロー)に陥る。
このため、このアプローチは上手くいかない。対処としては、setter メソッドに実装を移す必要がある。

移行メモ(誤字): 移行元の「無限再起」を「無限再帰」に修正した。

補足(この指摘は Windows Forms の本質を突いている): なぜ
Web Forms ではできて Windows Forms ではできないのかを補っておく。

【違いの根源】★
   ASP.NET Web Forms
     → 描画は【1 回きり】(HTML を吐いて終わり)
     → getter がいつ呼ばれても、
       最終的に出力される HTML に反映されればよい

   Windows Forms
     → 描画は【何度でも起きる】
       (ウィンドウの重なり、スクロール、リサイズ、
        [ウィンドウ メッセージ](MS_WindowMessage)の WM_PAINT)
     → コントロール自身が
       【Text の値からサイズを算出してキャッシュ】している
     → getter だけ書き換えると
       「算出に使った値」と「描画される値」がずれる ★

【正しい対処】
   ・値の変換は【setter で行い、内部状態を一貫させる】★
   ・描画内容だけ変えたいなら
     【OnPaint をオーバーライドする】
     (プロパティには触らない)
   ・サイズに影響するなら
     【GetPreferredSize をオーバーライド】して
     算出結果もそろえる ★

イベント

イベントハンドラの追加

リッチクライアントである Windows Forms では
ウィンドウ メッセージを使用したイベントドリブンの制御を行うため、
共通的な処理をカスタム コントロール内に同梱することも可能である。
以下は、フォーカスが当たった際に入力されたテキストを全選択するサンプル
(プログラムやタブで遷移した時だけテキストを全選択し、
マウスクリックで遷移した時は選択しないように制御している)。

namespace Ctrl {
  /// <summary>System.Windows.Forms.TextBoxのカスタム・コントロール</summary>
  [DefaultProperty("Text")]
  public class WinCustTextBox : TextBox {
  /// <summary>コンストラクタ</summary>
    public WinCustTextBox() {
      this.InitializeComponent();
    }
    /// <summary>初期化</summary>
    private void InitializeComponent() {
      this.SuspendLayout();
      this.Enter += new System.EventHandler(this.WinCustomTextBox_Enter); 
      this.Leave += new System.EventHandler(this.WinCustomTextBox_Leave);
      this.MouseDown += new System.Windows.Forms.MouseEventHandler(this.WinCustomTextBox_MouseDown);
      this.ResumeLayout(false);
    }

    /// <summary>MouseDown状態の確認用フラグ</summary>
    private bool IsMouseDown = false;

    /// <summary>マウスが入った</summary>
    private void WinCustomTextBox_MouseDown(object sender, MouseEventArgs e) {
      this.IsMouseDown = true;
    }

    /// <summary>フォーカス</summary>
    private void WinCustomTextBox_Enter(object sender, EventArgs e) {
      if (!this.IsMouseDown) {
        // MouseDown状態で無ければ全選択
        this.SelectAll();
      }
    }

    /// <summary>ロスト フォーカス</summary>
    private void WinCustomTextBox_Leave(object sender, EventArgs e) {
      this.IsMouseDown = false;
    }
  }
}

補足(この「全選択」実装の勘所と改善点): 業務アプリで
極めて需要の高いカスタマイズだが、実装には定番の落とし穴がある。

【なぜ MouseDown フラグが要るのか】★
   マウスでクリックして入った場合、
     Enter イベント → SelectAll() → その後の
     MouseUp でカーソル位置が確定
   となり、【選択が解除されてしまう】
     → 「タブで来たときだけ全選択」という
       本サンプルの制御は【正しい対処】である

【自前でイベントを購読するより OnXxx を override する方がよい】★
   ・派生クラス内では
     OnEnter / OnLeave / OnMouseDown を override する
     → イベント購読の解除漏れがない
     → 【必ず base.OnXxx(e) を呼ぶ】こと
       (呼ばないと利用者側のイベントが発火しない)

     protected override void OnEnter(EventArgs e) {
       base.OnEnter(e);
       if (!this.IsMouseDown) this.SelectAll();
     }

【InitializeComponent という名前は避ける】★
   デザイナが生成するメソッドと【同名】であり、
   このコントロールをデザイナで開いたときに
   競合・混乱の原因になる
     → 自前の初期化は別名にする
【より堅い「タブで来たら全選択」】
   ・GotFocus ではなく【Enter】を使う(本サンプルは正しい)
   ・IME 変換中のフォーカス移動を考慮するなら
     BeginInvoke で遅延させることがある
       this.BeginInvoke((Action)(() => this.SelectAll()));
     → Enter の時点ではまだ選択が確定していない
       ケースを回避できる ★

カスタムイベント

カスタムイベントの作成方法

UIのカスタマイズ

オーナードロー(オーナー描画)なので難易度が高い。

  • コントロールの描画を独自で行う方法。
  • OnPaint メソッドをオーバーライドする。

OnPaintメソッド

カスタム コントロールを Control クラスから派生させて
(後述の「コントロールの基本クラスを継承したスクラッチ開発」の方法で)
スクラッチ開発する場合は、OnPaint メソッドもスクラッチ開発するが、
それ以外の場合は、必要に応じて動作変更するようにして、
動作変更の必要がない場合は、処理を基本クラスの「base.xxxx」メソッドに
委譲するような方法を採る。

移行メモ(正誤): 移行元では本節(Windows Forms の OnPaint メソッド)の
冒頭が「ここで出力される HTML のカスタマイズは、Render 系メソッド
カスタマイズにより対応する。」となっていた。
これは ASP.NET Web Forms 側の
「Render 系メソッド」節からのコピーの残りであり、
本節の内容(Windows Forms の描画)と矛盾するため、削除した。

// Override the OnPaint method to draw the background image and the text.
protected override void OnPaint(PaintEventArgs e)
{
    if(this.pressed && this.pressedImage != null)
        e.Graphics.DrawImage(this.pressedImage, 0, 0);
    else
        e.Graphics.DrawImage(this.backgroundImage, 0, 0);

    // Draw the text if there is any.
    if(this.Text.Length > 0)
    {
        SizeF size = e.Graphics.MeasureString(this.Text, this.Font);

        // Center the text inside the client area of the PictureButton.
        e.Graphics.DrawString(this.Text,
            this.Font,
            new SolidBrush(this.ForeColor),
            (this.ClientSize.Width - size.Width) / 2,
            (this.ClientSize.Height - size.Height) / 2);
            }

    // Draw a border around the outside of the
    // control to look like Pocket PC buttons.
    e.Graphics.DrawRectangle(new Pen(Color.Black), 0, 0, 
        this.ClientSize.Width - 1, this.ClientSize.Height - 1);

    base.OnPaint(e);
}

移行メモ(体裁): 移行元では、上記コード中の
// Draw the text if there is any.if(this.Text.Length > 0)
// Draw a border around the outside of the // control to look like Pocket PC buttons.
1 行に潰れていた(コピー時の改行落ちと思われる)ため、
コンパイル可能な形に改行を復元した。

補足(OnPaint の作法): 上のサンプルは MSDN 由来だが、
本番コードとしては修正すべき点がある。

【① Pen / Brush を毎回 new して捨てていない】★
   OnPaint は【毎フレーム呼ばれる】
     → new SolidBrush / new Pen が GC 圧を生む
     → 【using で破棄する】か、
       フィールドに保持して使い回す
     → 単色なら【Brushes.Black / Pens.Black】等の
       システム提供の静的インスタンスを使う(破棄不要)★

【② base.OnPaint(e) の呼び出し位置】
   このサンプルは【最後】に呼んでいるため、
   基本クラスの描画が【自分の描画を上書きする】
     → 通常は【先頭で呼ぶ】か、
       完全に自前で描くなら呼ばない

【③ ちらつき(フリッカ)対策】★
   コンストラクタで
     this.DoubleBuffered = true;
   または
     this.SetStyle(ControlStyles.AllPaintingInWmPaint
                 | ControlStyles.UserPaint
                 | ControlStyles.OptimizedDoubleBuffer, true);
   → これを入れないと【描画がちらつく】

【④ 文字列描画は TextRenderer を使う】★
   Graphics.DrawString(GDI+)より
   【TextRenderer.DrawText】(GDI)の方が
   Windows Forms の他のコントロールと
   【文字の見た目が揃う】
     → Label 等は既定で TextRenderer を使っている

【⑤ 高 DPI】
   座標・サイズを固定値で書くと
   【4K ディスプレイで崩れる】
     → this.DeviceDpi や LogicalToDeviceUnits() で換算する
     → .NET Core 3.0 以降の Windows Forms は
       Per-Monitor DPI 対応が入っている ★

クリッピング

部分描画により、性能を向上させることができる。

補足(Windows Forms での実践): クリッピングは
PaintEventArgs からそのまま利用できる

【e.ClipRectangle を使う】★
   protected override void OnPaint(PaintEventArgs e) {
     foreach (var item in items) {
       if (!e.ClipRectangle.IntersectsWith(item.Bounds))
         continue;          // ← 【描画不要な項目を飛ばす】★
       DrawItem(e.Graphics, item);
     }
   }

   → 一部だけ Invalidate(rect) しておけば、
     OnPaint は【その領域だけ】描けばよい
     → 項目数が多い一覧・グラフで効果が大きい

【Graphics.SetClip】
   描画自体を矩形・領域に制限する
     e.Graphics.SetClip(rect);
     → はみ出しを防ぐ(角丸の中に画像を収める等)

オーナー描画をサポートするコントロール

  • 組み込みのオーナー描画サポートを備えたコントロール
    https://learn.microsoft.com/ja-jp/dotnet/desktop/winforms/controls/controls-with-built-in-owner-drawing-support

    オーナー描画によるカスタマイズを含んだ再利用可能なコントロールを作成するには、
    オーナー描画をサポートするコントロール クラスから派生した新しいクラスを作成します。
    その後、描画イベントを処理する代わりに、新しいクラス内の適切な OnEventName メソッドの
    オーバーライドにオーナー描画コードを記述します。
    このとき、必ず基本クラスの OnEventName メソッドを呼び出すようにしてください。
    これは、このコントロールのユーザーがオーナー描画イベントを処理して
    さらなるカスタマイズを実行できるようにするための配慮です。

コンストラクタ

コンストラクタについては自由な利用が可能である。

デザイナとコンストラクタのコードが干渉する問題

コンストラクタで子コントロール追加すると二重追加される件を参照。

その他

コントロールの基本クラスを継承したスクラッチ開発

下記のコントロールの基本クラスから継承し、
カスタム コントロールをスクラッチ開発する方法がある。

補足(3 つの作り方の使い分け): 「スクラッチ開発」と「継承」の間に、
もう一つの選択肢がある。

方式 基底クラス 特徴
継承コントロール 既存コントロール(TextBox 等) 最も手軽。既存の機能を全部引き継ぐ ★
ユーザー コントロール UserControl 複数コントロールを組み合わせる。デザイナで配置できる ★
カスタム コントロール
(スクラッチ)
Control / WebControl 全部自前で描く。最も自由で最も大変
【選び方】★
 ・既存コントロールに機能を足すだけ
     → 【継承】(本ページの主題)
 ・「入力欄+ボタン+ラベル」のような
   【複合部品】を作りたい
     → 【UserControl】
     → 業務アプリで最も出番が多い ★
 ・既存に該当がなく、見た目から作る
     → 【カスタム コントロール】

【UserControl の注意】
   ・内部のコントロールを public にすると
     カプセル化が壊れる
     → 【必要なものだけプロパティで公開する】★
   ・入れ子にしすぎると
     デザイナが重くなり、ハンドル数も増える

メソッド・プロパティをオーバーライドできない場合

派生クラスでオーバーライドが可能なように、
virtual / abstract キーワード(VB のキーワードでは、Overridable / MustOverride に該当)が
設定されておらず、カスタマイズができない場合、
この場合、new キーワードにより隠蔽・置換が可能であるが、
.NET フレームワークによりコントロールがベースの型によってハンドルされると、
カスタマイズにより追加した機能が動作しないなどの問題が発生する。

例えば、new キーワードにより Text プロパティを上書きし、
Render メソッドのオーバーライド中で「base.Render」を呼び出した場合、
「base.Render」中では「base.Text」が呼ばれてしまい、
new キーワードにより上書きした「this.Text」が呼ばれなくなる。
このため、思い通りに動作しなくなる。

このようなコントロールは、当該プロパティのカスタマイズをサポートした
再利用可能なコントロールとして作成されておらず、また、
new キーワード(VB のキーワードでは、Shadows に該当)による
メソッド・プロパティの上書きは、問題の発見を遅れさせ、
かつ原因特定などを困難にする可能性があるので、避けるようにする。

補足(この指摘は C# の基礎として重要): 本節の内容は
カスタム コントロールに限らない、C# の一般論として押さえておくべきである。

【override と new の決定的な違い】★
   class Base   { public virtual string Text { get; set; } }
   class Over   : Base { public override string Text {...} }  // 上書き
   class Hidden : Base { public new      string Text {...} }  // 隠蔽

   Base b = new Over();    b.Text  → 【Over の実装】が呼ばれる
   Base b = new Hidden();  b.Text  → 【Base の実装】が呼ばれる ★
                                      (Hidden の実装は無視される)

   → 呼ばれる実装が【変数の型】で決まってしまう
   → これが原文の言う
     「ベースの型によってハンドルされると動作しない」の正体

【コンパイラは警告してくれる】
   new を付けずに同名メンバを定義すると
     CS0108「基底クラスのメンバを隠します。
             意図する場合は new キーワードを使用してください」
   → この警告を【new を付けて黙らせる】のが
     最悪の対処である ★
     → 警告は「設計を見直せ」というサインと捉える
【virtual でないものを変えたいときの代替】★
 ① 【継承をやめてコンポジション】
      内部に本来のコントロールを持ち、
      必要なメンバだけ自分のプロパティとして公開する
      → UserControl がまさにこの形 ★
 ② 【拡張メソッド】
      状態を持たない振る舞いなら足せる
      (ただしオーバーライドではない)
 ③ 【イベントで割り込む】
      TextChanged 等で事後に補正する
 ④ 【そのコントロールを使わない】
      設計上カスタマイズを想定していないという
      作者のメッセージと受け取る

参考

Webカスタム コントロールの開発

Web カスタム コントロール開発に関する参考資料

移行メモ(体裁): 移行元では「(テンプレート、データ バインド)」以下の
3 項目のみインデントが 1 段浅く--)なっていたため、
前後の(状態管理)・(コレクション プロパティ、子コントロール)に揃えた。
また「複合Webコントロール」の全角英字を半角に統一した。

移行メモ(リンク): 上記 MSDN リンク群は、
Microsoft Learn の旧バージョン アーカイブ
learn.microsoft.com/ja-jp/previous-versions/aspnet/...)に
対応するページがあるため、そちらに置き換えた。
ただし ASP.NET Web Forms は既に新規開発の対象外であり、
一部は機械翻訳のまままたは英語のみの可能性がある。

Windows Formsカスタム コントロールの開発

Windows Forms カスタム コントロール開発に関する参考資料

補足(.NET Compact Framework / Pocket PC は完全に終了): 上記の
「スマート デバイス Pocket PC プロジェクト」「.NET Compact Framework」は、
Windows CE / Windows Mobile 向けの内容である。

【現況】★
   ・.NET Compact Framework 3.9 が最終版
   ・Windows Embedded Compact 2013 の
     【サポートは 2023年10月10日に終了】
   ・Visual Studio でのスマート デバイス プロジェクトは
     【VS 2013 が最後】

   → 上記リンクの内容は
     【歴史的資料としてのみ】価値がある
   → ただし「オーナー描画リスト ボックス」等の
     描画テクニック自体は
     デスクトップの Windows Forms でも通用する ★

PropertyGridの活用

PropertyGrid コントロールに関する参考資料

Open棟梁のカスタムコントロール


Tags: 移行, .NET開発, UIサブシステム, Windows Forms, ASP.NET Web Forms

NetDevInfraWiki

マイクロソフト系技術情報 Wiki
Open 棟梁 Wiki

(未着手)

開発基盤部会 Wiki

移行管理: DONETODO

Clone this wiki locally