C# WinForms TextBoxにフェード付きプレースホルダーを実装する方法とUserPaintの落とし穴

WinFormsでフェード付きのプレースホルダー(ウォーターマーク)をTextBoxに実装しようとすると、「文字が右端に寄る」「キャレット位置がズレる」「ドラッグ選択できない」「フェードがちらつく」といった不可解な不具合が出がちです。本記事では、その正体であるControlStyles.UserPaintと再描画の仕組みを整理しつつ、実務でも安心して使える「フェード付きプレースホルダーTextBox」実装をサンプルコード付きで詳しく解説します。

目次

C# WinFormsのTextBoxにフェード付きプレースホルダーを付けたい

WPFやWebでは、プレースホルダー文字列を薄く表示したり、フェードイン/フェードアウトするUIは珍しくありません。WinFormsでも同じような表現をしたくなり、次のような実装をしがちです。

  • TextBoxを継承してカスタムコントロールを作る
  • ControlStyles.UserPaintを有効にしてOnPaintで本文(入力文字)も描画
  • タイマーでアルファ値を変えながらプレースホルダー文字をフェード表示

一見うまく動くのですが、実際に使い込むと次のような問題が出てきます。

  • 文字入力すると、テキストが妙に右側から描かれ始める
  • キャレットの位置と実際の文字の表示位置が合わない
  • ドラッグで範囲選択できない、あるいは見た目の選択範囲と削除される範囲がズレる
  • 不具合を回避しようとすると、今度はフェードアニメーションがガクガク・ちらつく

これらはすべて、WinFormsのTextBoxが内部で「ネイティブのEDITコントロール」を使っていることを意識せずにオーナードローしていることが原因です。

不具合の原因:UserPaintでOSの仕事を奪ってしまう

WinFormsのTextBoxは、見た目こそSystem.Windows.Forms.Controlですが、実際には中でWin32のEDITコントロールが動いています。文字の描画だけでなく、キャレット、選択範囲、スクロール位置、IME 変換候補など、テキスト編集に関わるかなりの部分をOSが面倒を見ています。

ところが、次のようにUserPaintを有効にして自前描画を始めると、状況が一変します。

  • TextBox側:OnPaintで文字列を描く
  • OS側:キャレットや選択の描画を担当し続ける

結果、「文字の描画」と「キャレット・選択の描画」が別々の系統で動いてしまい、整合性が取れなくなります。

OSに任せるべき仕事/任せてはいけない仕事

処理内容OS(EDITコントロール)に任せるべきかカスタムTextBoxでやってよいか
本文テキストの描画◎ 任せる(標準の動作を保つ)△ やるとズレや不具合の元
キャレット・選択範囲の描画◎ 任せる× 自前でやるのはかなり大変
IME変換・確定処理◎ 任せる× 基本的に制御しない
プレースホルダー文字列(ウォーターマーク)△ 任せてもいいが標準機能は薄い◎ 後描きで重ねれば安全

今回欲しいのは「空文字のときだけ、TextBoxの上に薄い文字を重ねて表示する」機能です。本文描画はOSに任せ、プレースホルダーだけを「後描き」で重ねる、という構造にすれば、キャレットや選択範囲と干渉せずに済みます。

ちらつきの原因:タイマーとInvalidate乱発

もう1つの典型的な問題が、「フェードアニメーションがガクガクする」「入力中にプレースホルダーが妙に点滅する」という現象です。

よくある失敗パターンは次のようなものです。

  • フェード用TimerのTickごとに常にInvalidate()を呼ぶ
  • 目標不透明度に到達してもタイマーを止めず、延々と再描画させ続ける
  • MouseMoveなどのイベントのたびにStart()を呼びなおす

OS側もキー入力やキャレット点滅のために常にTextBoxを再描画しています。そこに、アニメーション用の再描画要求が大量に重なると、結果として「ちらつき」が発生します。

タイマー運用挙動
毎Tick必ずInvalidate()常に再描画、CPU負荷とちらつき増大
不透明度が変わったときだけInvalidate()必要最小限の再描画、動きが滑らかになりやすい
目標値到達後もTimerが動き続ける意味のない再描画が続き、スクロールやIMEと干渉
目標値に近づいたら速やかにStop()静止状態では負荷ゼロ、ちらつきも発生しにくい

つまり、不透明度が変化するときだけ描き直す、アニメーションが終わったらタイマーを止める、というシンプルなルールを守るだけで、表示はかなり安定します。

解決方針:TextBoxはOSに描かせ、プレースホルダーだけを後描き

ここまでの整理を踏まえると、WinFormsのTextBoxにフェード付きプレースホルダーを実装する際の方針は次の2点に集約できます。

  1. ControlStyles.UserPaintを使わない(本文はOSに描かせる)
  2. WM_PAINT処理の「後」でプレースホルダーだけを重ね描きする

これを実現するために、WndProcをオーバーライドし、WM_PAINTを受けた後にGraphics.FromHwnd(Handle)でプレースホルダーを描きます。

なぜOnPaintではなくWndProc(WM_PAINT)なのか

OnPaintをオーバーライドすると、通常は「自分が描画の主役」として働きます。その際UserPaintを指定していない場合、WinForms標準コントロールでは描画のタイミングが限定され、TextBoxのようなネイティブラップコントロールでは期待通りの結果にならないことがあります。

一方、WndProcでWM_PAINTを横取りせず、base.WndProcを呼んだ「後」で描画すると、

  • OS(EDITコントロール)が本文やキャレット・選択を描き終えた後
  • その上に半透明のプレースホルダー文字を重ねる

という順序が明確になり、不具合が減ります。

フェード付きプレースホルダーTextBoxの実装サンプル

上記の方針をすべて反映した、差し替え可能なサンプル実装を示します。本文はOSに任せ、プレースホルダーだけを後描きし、タイマーも必要最小限の再描画にとどめています。


using System;
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;

public class FadingPlaceholderTextBox : TextBox
{
    private string _placeholderText = "ここに入力…";
    private Color _idleColor = Color.LightGray;
    private Color _focusColor = Color.Gray;
    private Font _placeholderFont;
    private float _opacity = 0f;             // 0~1
    private bool _useFocusBehavior = false;  // true: フォーカスでフェード、false: マウスでフェード
    private bool _isMouseOver = false;
    private readonly Timer _timer;

    private const int IntervalMs = 30; // だいたい 33fps
    private const float Step = 0.06f;  // 0→1 におよそ 17tick(約0.5秒)

    public FadingPlaceholderTextBox()
    {
        _placeholderFont = this.Font;
        this.Multiline = false;

        // ダブルバッファ等(※UserPaintは設定しないことが重要)
        this.SetStyle(
            ControlStyles.OptimizedDoubleBuffer |
            ControlStyles.AllPaintingInWmPaint |
            ControlStyles.ResizeRedraw,
            true);

        _timer = new Timer { Interval = IntervalMs };
        _timer.Tick += OnTick;

        this.TextChanged += (_, __) => this.Invalidate();
        this.GotFocus    += (_, __) => StartFade(true);
        this.LostFocus   += (_, __) => StartFade(false);

        this.MouseMove   += (_, __) =>
        {
            if (!_useFocusBehavior && !_isMouseOver)
            {
                _isMouseOver = true;
                StartFade(true);
            }
        };

        this.MouseLeave  += (_, __) =>
        {
            if (!_useFocusBehavior)
            {
                _isMouseOver = false;
                StartFade(false);
            }
        };
    }

    // ====== フェード制御 ======
    private void StartFade(bool fadeIn)
    {
        float target = fadeIn ? 1f : 0f;

        // 目標に十分近いなら即終了
        if (Step > Math.Abs(_opacity - target))
        {
            _opacity = target;
            _timer.Stop();
            this.Invalidate();
            return;
        }

        _timer.Tag = fadeIn;  // 方向だけ保持
        if (_timer.Enabled)
        {
            _timer.Stop();
        }
        _timer.Start();
    }

    private void OnTick(object sender, EventArgs e)
    {
        bool fadeIn = (bool)_timer.Tag;
        float before = _opacity;

        _opacity += fadeIn ? Step : -Step;
        if (0f >= _opacity)
        {
            _opacity = 0f;
            _timer.Stop();
        }
        if (_opacity >= 1f)
        {
            _opacity = 1f;
            _timer.Stop();
        }

        // 実際に変わったときだけ再描画
        if (Math.Abs(_opacity - before) > 0.001f)
        {
            this.Invalidate();
        }
    }

    // ====== プレースホルダーの後描き(本文は OS に任せる) ======
    protected override void WndProc(ref Message m)
    {
        const int WM_PAINT = 0x000F;
        base.WndProc(ref m);

        if (m.Msg == WM_PAINT && ShouldShowPlaceholder())
        {
            using (var g = Graphics.FromHwnd(this.Handle))
            using (var b = new SolidBrush(Color.FromArgb(
                       (int)(_opacity * 255),
                       this.Focused ? _focusColor : _idleColor)))
            {
                float x = 2f;
                float y = (this.Height - _placeholderFont.Height) / 2f;

                g.TextRenderingHint =
                    System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;

                g.DrawString(
                    _placeholderText,
                    _placeholderFont,
                    b,
                    x,
                    Math.Max(1f, y));
            }
        }
    }

    private bool ShouldShowPlaceholder()
    {
        return this.TextLength == 0
            && _opacity > 0f
            && !(this.UseSystemPasswordChar || this.PasswordChar != '\0');
    }

    protected override void OnFontChanged(EventArgs e)
    {
        base.OnFontChanged(e);
        _placeholderFont = this.Font;
        this.Invalidate();
    }

    // ====== プロパティ ======
    [Category("Placeholder")]
    [Description("テキストが空のときに表示するプレースホルダー文字列。")]
    public string PlaceholderText
    {
        get => _placeholderText;
        set
        {
            _placeholderText = value ?? string.Empty;
            this.Invalidate();
        }
    }

    [Category("Placeholder")]
    [Description("フォーカス時のプレースホルダー色。")]
    public Color PlaceholderFocusColor
    {
        get => _focusColor;
        set
        {
            _focusColor = value;
            this.Invalidate();
        }
    }

    [Category("Placeholder")]
    [Description("非フォーカス時のプレースホルダー色。")]
    public Color PlaceholderIdleColor
    {
        get => _idleColor;
        set
        {
            _idleColor = value;
            this.Invalidate();
        }
    }

    [Category("Placeholder")]
    [Description("プレースホルダーのフォント。既定は TextBox の Font。")]
    public Font PlaceholderFont
    {
        get => _placeholderFont;
        set
        {
            _placeholderFont = value ?? this.Font;
            this.Invalidate();
        }
    }

    [Category("Placeholder")]
    [Description("true でフォーカス入出時にフェード。false でマウスの出入りに反応。")]
    public bool UseFocusBehavior
    {
        get => _useFocusBehavior;
        set
        {
            _useFocusBehavior = value;
            StartFade(false);
            this.Invalidate();
        }
    }
}

コードのポイント解説

サンプルコードの中で特に重要なポイントを整理します。

ダブルバッファリング設定(UserPaintは付けない)


this.SetStyle(
    ControlStyles.OptimizedDoubleBuffer |
    ControlStyles.AllPaintingInWmPaint |
    ControlStyles.ResizeRedraw,
    true);
  • OptimizedDoubleBuffer … フリッカー(ちらつき)を抑えるための二重バッファリング
  • AllPaintingInWmPaint … WM_ERASEBKGNDでの背景塗りを抑止し、描画をWM_PAINTに集約
  • ResizeRedraw … リサイズ時に再描画

ここであえてUserPaintを指定していないのが重要です。UserPaintを付けなければ、TextBoxの本文はOSに任せたまま、後からプレースホルダーだけ重ねることができます。

StartFade:方向と目標値を決めてタイマーを制御

StartFadeメソッドは「フェードイン/フェードアウトを開始するトリガー」です。

  • 引数fadeInで方向(1: 入る、0: 消える)を決定
  • 目標値(1 or 0)と現在の_opacityがほとんど同じなら、その場で終了
  • 差分が大きい場合にだけタイマーを起動

このとき、方向は_timer.Tagに覚えさせておき、Tick内で参照します。TickごとにStart()し直すような実装は避けましょう。

OnTick:不透明度が変わったときだけInvalidate

OnTickでは、_opacityをStepずつ増減させています。

  • _opacityが0より小さくなったら0で固定し、タイマー停止
  • _opacityが1以上になったら1で固定し、タイマー停止
  • 前回値と異なるときだけInvalidate()を呼ぶ

特に、最後の「変化した場合だけ再描画」はちらつき軽減に効きます。TextChangedイベントや、標準のキャレット点滅と再描画タイミングが重なっても、必要以上の塗りなおしが発生しません。

WndProc(WM_PAINT):OS描画の後にプレースホルダーを重ねる

最も重要なのが、この部分です。


protected override void WndProc(ref Message m)
{
    const int WM_PAINT = 0x000F;
    base.WndProc(ref m);

    if (m.Msg == WM_PAINT && ShouldShowPlaceholder())
    {
        using (var g = Graphics.FromHwnd(this.Handle))
        using (var b = new SolidBrush(Color.FromArgb(
                   (int)(_opacity * 255),
                   this.Focused ? _focusColor : _idleColor)))
        {
            float x = 2f;
            float y = (this.Height - _placeholderFont.Height) / 2f;

            g.TextRenderingHint =
                System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;

            g.DrawString(
                _placeholderText,
                _placeholderFont,
                b,
                x,
                Math.Max(1f, y));
        }
    }
}

base.WndProcを先に呼ぶことで、OSによる本文・キャレット・選択範囲の描画が完了します。その後、プレースホルダーを描画すれば、キャレットとの干渉は起こりません。

描画順序処理内容
1base.WndProcでEDITコントロールが本文を描画
2OSがキャレット・選択範囲等を描画
3必要に応じてプレースホルダー文字列を半透明で重ね描き

また、縦位置は(Height - Font.Height) / 2fで中央寄せしています。Math.Max(1f, y)としているのは、上下のクリップとの兼ね合いで極端に小さなフォントを使った場合でも、完全に隠れないようにするためです。

ShouldShowPlaceholder:描画条件を最小限に絞る

プレースホルダーは常に描けばよいわけではありません。実装では次の条件を満たすときだけ描画しています。

  • TextBoxのテキストが空(TextLength == 0)
  • 不透明度が0より大きい(_opacity > 0)
  • パスワードモードでない(UseSystemPasswordCharがfalse かつPasswordCharが'\0')

特にパスワードモードでは、パスワードのヒントになるような文字列を表示したくないケースも多いため、デフォルトでは描画しないようにしてあります。

プロパティ:デザイナからも調整しやすく

[Category]と[Description]属性を付けることで、Visual Studioのプロパティウィンドウからプレースホルダー関連の設定を簡単に変更できるようにしています。

プロパティ名型説明
PlaceholderTextstringテキストが空のときに表示する文字列
PlaceholderIdleColorColor非フォーカス時のプレースホルダー色
PlaceholderFocusColorColorフォーカス時のプレースホルダー色(濃いめにする等)
PlaceholderFontFontプレースホルダーのフォント(既定はTextBoxと同じ)
UseFocusBehaviorbooltrueでフォーカス入出時にフェード、falseでマウス入出時にフェード

実装チェックリスト

自作の実装を見直すときは、次の項目をチェックしてみてください。

  • ControlStyles.UserPaintを付けていないか
  • OnPaintで本文(入力文字)を描いていないか
  • WndProc(WM_PAINT)でプレースホルダーだけを後描きしているか
  • タイマーはStartFade経由で停止→開始しているか
  • 目標不透明度に到達したらすぐにStop()しているか
  • Invalidate()は不透明度が実際に変わった時だけ呼んでいるか
  • 縦位置は(Height - Font.Height) / 2fで中央寄せしているか
  • Password使用時やTextLength > 0のときにプレースホルダーを描かないようになっているか

このチェックリストを満たしていれば、「文字が右寄りから描かれる」「ドラッグ選択できない」といった問題は起きにくくなります。

応用例・カスタマイズのヒント

ベースとなる実装ができたら、用途に応じて少しずつカスタマイズしていくと便利です。

フェード速度・ステップの調整

次の2つの定数を変更することで、アニメーションの重さを簡単に変えられます。


private const int IntervalMs = 30; // Tick間隔(ms)
private const float Step = 0.06f;  // 1Tickあたりの不透明度の変化量
  • ゆっくりフェードさせたい場合:Stepを小さくする(例:0.03f)
  • カクつきが気になる場合:IntervalMsを短くするか、Stepをやや大きくする
  • あまりに間隔を短くしすぎるとCPU負荷が増えるので、16ms(約60fps)~50ms程度を目安に調整

右寄せやRTLとの両立

この実装では、プレースホルダーのX座標を固定値x = 2fにしています。右寄せやRTL(右から左へ)言語に対応させる場合は、TextAlignやRightToLeftを参照して位置を変えるとよいでしょう。

簡易的な例:


float x = 2f;

if (this.RightToLeft == RightToLeft.Yes)
{
    // 右寄せ風に描画したい場合の一例(テキスト幅を計って右端からオフセット)
    SizeF sz = g.MeasureString(_placeholderText, _placeholderFont);
    x = this.ClientSize.Width - sz.Width - 2f;
}

本格的に複数行や折り返しに対応する場合は、DrawStringの代わりにStringFormatを併用し、レイアウト矩形を明示すると制御しやすくなります。

複数行TextBoxへの対応

上記のサンプルはシングルライン用(Multiline = false)を想定しています。複数行で使いたい場合は、

  • 縦位置を一行目の位置(例:y = 3f)に固定する
  • プレースホルダーを中央寄せではなく左上に描画する
  • スクロール位置との兼ね合いを考慮する

といった調整が必要です。複数行+スクロールまで対応するとそれなりに手間がかかるため、「検索ボックス」「ログインID入力」など、単行用途での使用にとどめるのも1つの割り切りです。

.NET 6以降のTextBox.PlaceholderTextとの比較

.NET 6以降のWinFormsには、TextBox.PlaceholderTextプロパティが追加されています。フェードなどのアニメーションが不要であれば、標準機能だけでかなりのケースをカバーできます。

項目カスタム実装(本記事)標準のPlaceholderText
フェードアニメーション◎ 自由に制御可能× 非対応(即時表示/非表示のみ)
フォント・色の柔軟な変更◎ 完全に自由△ テーマ依存、細かな制御は困難
実装コスト△ カスタムコントロールが必要◎ プロパティを設定するだけ
互換性(バージョン)◎ .NET Framework時代から利用可能△ .NET 6以降のみ

まとめると、

  • 単純なプレースホルダーでよい → PlaceholderTextを使う
  • フェードなどの細かな演出を入れたい → 本記事のようなカスタム実装を採用

と使い分けるのが現実的です。

よくあるハマりポイントと対策

IMEとの相性は大丈夫?

本実装では本文の描画をOSに任せているため、IMEとの相性は基本的に標準のTextBoxと変わりません。ただし、

  • プレースホルダーをあまり濃い色にすると、変換中の文字と視覚的に紛らわしくなる
  • フォントサイズを極端に大きくした場合、縦位置の計算がずれてプレースホルダーが切れる

といった見た目の問題は起こり得ます。デザイン段階で日本語入力時の見え方も確認しておくと安心です。

高DPI環境でのぼやけ・位置ずれ

高DPI環境では、TextBox自身のフォントサイズやレイアウトがOS側でスケーリングされます。本記事の実装はthis.FontやHeightを使って計算しているため、基本的には自動で追従しますが、

  • アプリケーションのDPI設定が「System」か「PerMonitorV2」か
  • フォームやコントロールのAutoScaleMode設定

によっては微妙なずれが出ることがあります。気になる場合は、実機で125%、150%等のスケールを試してチューニングしてください。

TextBoxのEnabled/ReadOnly時の見え方

非活性(Enabled = false)や読み取り専用(ReadOnly = true)で表示したい場合、

  • EnabledChangedやReadOnlyChangedをフックして色を変える
  • 読み取り専用時だけプレースホルダーをグレーで固定表示する

といったアレンジも考えられます。例えば、ShouldShowPlaceholderの条件に!ReadOnlyを追加すれば、「編集できないときは常にプレースホルダーを消す」といった挙動に変更できます。

まとめ:TextBoxをオーナードローしないことが最大のポイント

本記事では、C# WinFormsのTextBoxに「フェード付きプレースホルダー(ウォーターマーク)」を実装した際に起こりがちな、

  • 文字が右端寄りから描かれる
  • キャレット位置が合わない
  • ドラッグ選択ができない
  • フェードがちらつく

といった問題の原因と、その解決策を詳しく解説しました。

ポイントをもう一度整理すると、

  • ControlStyles.UserPaintは付けない(TextBoxをオーナードローしない)
  • 本文・キャレット・選択範囲の描画はOSに任せる
  • WndProc(WM_PAINT)の後で、半透明のプレースホルダーだけを重ね描きする
  • タイマーは方向と目標値を管理し、不透明度が変わったときだけInvalidate()
  • 描画条件を絞り込んで、無駄な再描画やパスワードモードでの表示を避ける

この方針を取れば、WinFormsアプリでも「今どき感」のある滑らかなプレースホルダーUIを、標準のTextBoxとほぼ同じ安定性のまま実現できます。既存の入力フォームを少しリッチにしたいときなどに、ぜひ組み込んでみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次