UWP TextBoxのキャレットを太く丸くカスタマイズする方法と制約まとめ

UWP の TextBox に表示される標準キャレットは、細い直線で視認性も個性もいまひとつです。デザイン性の高いアプリでは、太くて上下が丸い「棒状キャレット」にしたくなることがありますが、UWP にはそのための素直な API がありません。本記事では、その制約を踏まえつつ「擬似キャレット」を重ねる実装パターンと注意点を、実装レベルまで詳しく解説します。

目次

UWP TextBoxのキャレットをカスタマイズしたい理由

スマートフォンアプリやデザインツールを見ていると、標準の細いキャレットではなく、入力位置がはっきり分かる「太い縦棒」「上下が丸いバー」が採用されていることが多くあります。視認性が高く、UI 全体の雰囲気とも合わせやすいのが理由です。

しかし UWP の TextBox では、キャレットの 色は CaretBrush プロパティで変えられるものの、形状・太さ・角丸といった見た目を公式 API だけで変更することはできません。そのため、次のようなニーズを満たそうとすると、どうしても工夫が必要になります。

  • 縦線を太くして、指やペン操作でも見やすくしたい
  • 上下を丸くして、UI 全体のデザインに合わせたい
  • 入力中は点灯、しばらく操作がないと点滅、という自然な挙動にしたい

UWP TextBoxのキャレット仕様と制約

まずは、UWP の TextBox がキャレットに対してどこまでカスタマイズを許しているかを整理します。大雑把に言えば「色だけ変えられて、形は変えられない」が現実です。

項目できることできないこと
色CaretBrush で任意の Brush を指定して変更可能グラデーションなど高度な装飾は実質困難(描画は内部実装)
太さ・幅内部実装依存で、公開プロパティは存在しない「2px にする」「文字幅の 30% にする」などの指定は不可能
形状―角丸や点線など、形状の制御はできない
点滅の周期システム設定に依存アプリ側から周期変更・入力中だけ点灯といった制御はできない
IME 変換中の見た目IME の挙動に完全依存変換中キャレットだけ色や形を変えることはできない

この制約から、「公式 API でキャレット形状をいじる」のは現実的ではありません。そこで選択肢として浮かぶのが、以下の三つのアプローチです。

キャレットカスタマイズに使える3つのアプローチ

対応策方法メリット課題・限界
公式 API での変更TextBox のプロパティやスタイルをそのまま利用実装コストが低い/保守性が高い色以外のキャレット形状(太さ・丸み・点滅)は一切変えられない
擬似キャレットを重ねるCanvas 上に TextBox と複数の Rectangle を重ねて配置し、
GetRectFromCharacterIndex の結果に合わせて矩形を移動させる
太さ・角丸・色などを自由にデザインできる UWP の標準 API だけで完結する改行直後・末尾スペースで位置ずれが発生しやすい 多行時にフォントサイズごとの補正が必要 高速入力時やレイアウト遅延時の追従ズレ IME 変換中のキャレット同期は完全にはできない
TextBox をフル自作Win32 / WinUI 3 の TextBox 相当を自前描画し、IME も含めて制御キャレットや選択範囲を含めて完全に好きな見た目にできるIME 対応・アクセシビリティ・フォーカス管理など実装コストが非常に高い

多くの場合、現実的な落としどころは「擬似キャレットを重ねる」アプローチです。以下では、この方法を実装レベルで深掘りしていきます。

擬似キャレットを重ねるアプローチの全体像

擬似キャレット方式の基本アイデアはとてもシンプルです。

  1. TextBox を Canvas の上に配置する。
  2. 同じ Canvas 上に、本来のキャレットを隠すための細い白い矩形と、新しい太いキャレット用の角丸矩形を重ねる。
  3. TextBox の SelectionChanged / TextChanged / KeyDown などのイベントで、GetRectFromCharacterIndex を呼び、キャレット位置を計算して矩形を移動させる。
  4. Storyboard で矩形の Opacity を変化させ、点滅アニメーションを再現する。

ポイントは「元のキャレットを完全に消すのではなく、矩形で隠す」ことと、「TextBox が持っているカーソル位置の計算機能だけ借りる」という割り切りです。

レイアウト構成イメージ(XAML)

典型的なレイアウトは、次のような構造になります。

<Canvas x:Name="RootCanvas">
    <TextBox x:Name="InputTextBox"
             Width="300"
             Height="32"
             AcceptsReturn="True"
             TextWrapping="Wrap"
             CaretBrush="Transparent" />


<Rectangle x:Name="CaretCoverRectangle"
           Width="2"
           Height="20"
           Fill="White"
           Visibility="Collapsed"/>

<Rectangle x:Name="CaretRectangle"
           Width="4"
           Height="20"
           RadiusX="2"
           RadiusY="2"
           Fill="Black"
           Visibility="Collapsed" />


 
  • CaretBrush = Transparent とし、元のキャレットの色を透明にしておく(環境によっては 0 幅キャレットにならないこともあるため、後述のカバー矩形で確実に隠す)。
  • CaretCoverRectangle は TextBox 背景色と同じ色で細長く描き、元のキャレットの上にぴったり重ねて見えなくする役割。
  • CaretRectangle が実際にユーザーに見せる太いキャレット。

キャレット位置の取得と矩形の移動

擬似キャレットの最大のポイントは、TextBox 内の「実際のキャレット位置」を正しく取得することです。ここでは、GetRectFromCharacterIndex メソッドを利用します。

基本的な位置取得コード

private void UpdateCaretPosition()
{
    int index = InputTextBox.SelectionStart;


// キャレット位置(先頭)を取得
Rect rect = InputTextBox.GetRectFromCharacterIndex(index, false);

// 取得できない場合(-1や空のとき)は何もしない
if (rect.IsEmpty)
{
    CaretRectangle.Visibility = Visibility.Collapsed;
    CaretCoverRectangle.Visibility = Visibility.Collapsed;
    return;
}

// TextBox の座標系から Canvas の座標系に変換
var transform = InputTextBox.TransformToVisual(RootCanvas);
Point origin = transform.TransformPoint(new Point(rect.X, rect.Y));

Canvas.SetLeft(CaretCoverRectangle, origin.X);
Canvas.SetTop(CaretCoverRectangle, origin.Y);

Canvas.SetLeft(CaretRectangle, origin.X);
Canvas.SetTop(CaretRectangle, origin.Y);

CaretRectangle.Visibility = Visibility.Visible;
CaretCoverRectangle.Visibility = Visibility.Visible;


} 

SelectionChanged / TextChanged / KeyDown からこのメソッドを呼び出すことで、キャレット移動に追従して擬似キャレットも移動するようになります。

改行直後のキャレット位置補正

AcceptsReturn = true の多行 TextBox では、改行直後(行頭)のキャレット位置で GetRectFromCharacterIndex が空の Rect を返す、あるいは前の行と同じ位置を指すといった挙動になることがあります。この場合、次のような補正が有効です。

  1. 現在の index で取得できなかった場合、1 文字前(index – 1)の Rect を取得する。
  2. trailingEdge = true を指定して「文字の右端」を取得する。
  3. その Rect の Y 座標と高さを基準に、改行後 1 行分だけ Y を下げる。
private void UpdateCaretPositionWithNewLineFix()
{
    int index = InputTextBox.SelectionStart;

    Rect rect = InputTextBox.GetRectFromCharacterIndex(index, false);

    if (rect.IsEmpty &amp;&amp; index &gt; 0)
    {
        // 1 文字前、trailingEdge=true で右端を取得
        rect = InputTextBox.GetRectFromCharacterIndex(index - 1, true);

        // 改行直後なら「1 行分」Y を下げる
        if (!rect.IsEmpty &amp;&amp; IsPrevCharCRLF(index - 1))
        {
            double lineHeight = GetLineHeightFromFontSize(InputTextBox.FontSize);
            rect = new Rect(rect.X, rect.Y + lineHeight, rect.Width, rect.Height);
        }
    }

    if (rect.IsEmpty)
    {
        CaretRectangle.Visibility = Visibility.Collapsed;
        CaretCoverRectangle.Visibility = Visibility.Collapsed;
        return;
    }

    MoveRectangles(rect);
}

ここでは、IsPrevCharCRLF と GetLineHeightFromFontSize を補助メソッドとして定義します。

private bool IsPrevCharCRLF(int index)
{
    if (index &lt; 0 || index &gt;= InputTextBox.Text.Length)
        return false;

    char c = InputTextBox.Text[index];
    return c == '\r' || c == '\n';
}

// フォントサイズから行の高さをざっくり推定する
private double GetLineHeightFromFontSize(double fontSize)
{
    // 実測して係数を調整する。例:a=1.35, b=2 など
    double a = 1.35;
    double b = 2.0;
    return fontSize * a + b;
}

このように、改行やフォントに応じて 「行の高さ = a × FontSize + b」 のような線形近似を一度実測しておき、アプリ内で再利用すると、多行 TextBox でも比較的安定してキャレット位置を求めることができます。

キャレット点滅アニメーションの再現

次は「キャレットが点滅する」ように見せるためのアニメーションです。Storyboard を使って、擬似キャレットの Opacity を周期的に変化させます。

Storyboard の定義例

&lt;UserControl.Resources&gt;
    &lt;Storyboard x:Name="CaretBlinkStoryboard" RepeatBehavior="Forever"&gt;
        &lt;DoubleAnimationUsingKeyFrames
            Storyboard.TargetName="CaretRectangle"
            Storyboard.TargetProperty="Opacity"&gt;
            &lt;DiscreteDoubleKeyFrame KeyTime="0:0:0.0" Value="1" /&gt;
            &lt;DiscreteDoubleKeyFrame KeyTime="0:0:0.7" Value="1" /&gt;
            &lt;DiscreteDoubleKeyFrame KeyTime="0:0:0.99" Value="0" /&gt;
        &lt;/DoubleAnimationUsingKeyFrames&gt;
    &lt;/Storyboard&gt;
&lt;/UserControl.Resources&gt;

0.7 秒ほど点灯し、0.29 秒だけ消灯するようなイメージです(数値は好みで調整可能)。
入力が発生したら Storyboard を一度止めてすぐ Begin し直すことで、実際にはキャレットが点灯しっぱなしに近い挙動になります。

private void OnInputActivity()
{
    UpdateCaretPositionWithNewLineFix();

    // 入力や選択変更のたびに点滅アニメーションをリセット
    CaretBlinkStoryboard.Stop();
    CaretRectangle.Opacity = 1;
    CaretBlinkStoryboard.Begin();
}

private void InputTextBox_SelectionChanged(object sender, RoutedEventArgs e)
    =&gt; OnInputActivity();

private void InputTextBox_TextChanged(object sender, TextChangedEventArgs e)
    =&gt; OnInputActivity();

private void InputTextBox_KeyDown(object sender, KeyRoutedEventArgs e)
    =&gt; OnInputActivity();

このようにすると、「何か操作した直後は常に点灯し、その後自動で点滅に戻る」という、自然な UX に近づけることができます。

フォントサイズ変更とキャレット高さの調整

TextBox の FontSize をユーザーが変更できる場合、キャレットの高さも合わせて変更しないと不自然になります。ここでは、フォントサイズからキャレットの高さ・Y オフセットを計算して更新します。

パラメータ例意味
caretHeightFontSize × 1.35キャレットの棒の高さ。行の高さより少し低めにして上下に余白を持たせる。
offsetYFontSize × 0.95TextBox が返す Rect の Y に足し込む補正値。実測により係数を決定する。
private void UpdateElementSize()
{
    double fontSize = InputTextBox.FontSize;

    double caretHeight = fontSize * 1.35;
    double offsetY = fontSize * 0.95;

    CaretRectangle.Height = caretHeight;
    CaretCoverRectangle.Height = caretHeight;

    _caretYOffset = offsetY;
}

private void MoveRectangles(Rect rect)
{
    var transform = InputTextBox.TransformToVisual(RootCanvas);
    Point origin = transform.TransformPoint(new Point(rect.X, rect.Y + _caretYOffset));

    Canvas.SetLeft(CaretCoverRectangle, origin.X);
    Canvas.SetTop(CaretCoverRectangle, origin.Y);

    Canvas.SetLeft(CaretRectangle, origin.X);
    Canvas.SetTop(CaretRectangle, origin.Y);
}

フォントや DPI によって最適な係数は変わるため、よく使うフォント・サイズの組み合わせで実測して係数を決めておくことが重要です。

末尾スペース・全角スペースでの位置ずれ対策

TextBox の末尾にスペースだけがある場合、GetRectFromCharacterIndex が「幅 0 の Rect」を返してしまい、擬似キャレットをどこに置くべきか分からなくなることがあります。特によく問題になるのが次のケースです。

  • 文末に半角スペースを 1 つだけ入力した状態
  • 全角スペース(全角空白)だけを含む行

応急処置としては、「1 文字前の Rect 幅をベースにキャレット位置を推定する」方法が実用的です。

private Rect FixRectForTrailingSpace(Rect rect, int index)
{
    if (!rect.IsEmpty &amp;&amp; rect.Width &gt; 0)
        return rect;

    if (index &lt;= 0)
        return rect;

    // 1 文字前の Rect を取得
    Rect prevRect = InputTextBox.GetRectFromCharacterIndex(index - 1, true);
    if (prevRect.IsEmpty)
        return rect;

    // 末尾スペースを "FontSize / 2" 相当の幅と仮定して伸ばす
    double extraWidth = InputTextBox.FontSize / 2.0;
    return new Rect(prevRect.X + extraWidth, prevRect.Y, prevRect.Width, prevRect.Height);
}

この補正を、キャレット位置更新の直前で呼び出すことで、末尾スペースでもキャレットがほぼ期待通りの位置に表示されるようになります。全角スペースの場合は FontSize そのもの、または FontSize × 0.9 程度を足すなど、用途に応じて係数を変えてもよいでしょう。

AcceptsReturn = true で高速 Enter 連打したときの問題

多行入力でよく出会うのが、「Enter を高速で連打すると、キャレット矩形だけが TextBox の描画範囲から飛び出す」という現象です。原因は概ね次のように考えられます。

  1. ユーザーが Enter を押す。
  2. TextBox 内部ではレイアウトの再計算が行われる。
  3. その前に SelectionChanged が連続発火し、古いレイアウト情報のまま GetRectFromCharacterIndex が呼ばれる。
  4. 結果として、想定より上や下の行を指す Rect が返ってくる。

これを完全に防ぐことは難しいですが、実用的な対策としては次のようなものがあります。

  • LayoutUpdated イベントで最終的な位置を再計算する
    高速入力中でも、レイアウトが安定したタイミングで 1 回だけ UpdateCaretPosition を呼び直す。
  • キャレットの Y 座標に上下の制限を設ける
    TextBox の縦方向の範囲を超える位置が算出された場合は、上下端にクランプする。
public CustomCaretTextBox()
{
    this.InitializeComponent();
    InputTextBox.LayoutUpdated += (_, __) =&gt;
    {
        UpdateCaretPositionWithNewLineFix();
    };
}

private void MoveRectangles(Rect rect)
{
    var transform = InputTextBox.TransformToVisual(RootCanvas);
    Point origin = transform.TransformPoint(new Point(rect.X, rect.Y + _caretYOffset));

    // Y のクランプ(安全弁)
    double top = 0;
    double bottom = InputTextBox.ActualHeight - CaretRectangle.Height;
    double y = Math.Max(top, Math.Min(bottom, origin.Y));

    Canvas.SetTop(CaretRectangle, y);
    Canvas.SetTop(CaretCoverRectangle, y);
    Canvas.SetLeft(CaretRectangle, origin.X);
    Canvas.SetLeft(CaretCoverRectangle, origin.X);
}

このように「レイアウト安定後に最終位置を更新する」「範囲外の値は切り捨てる」といった安全弁を入れておくと、極端な入力速度でも破綻しづらくなります。

IME変換中のキャレット制御の限界

日本語 IME の変換中は、TextBox 側から見ると「未確定文字列」が存在し、キャレット位置も IME 内部の状態に依存します。UWP の公開 API からは、変換中かどうか/キャレットがどこにあるかを詳細に知る手段がありません。

そのため、擬似キャレットを完全に同期させることは現実的ではなく、次のような割り切りが必要です。

  • 変換中は擬似キャレットを一時的に非表示にする。
  • 確定後(TextChanged など)に再度表示を再開する。
private bool _imeComposing;

private void InputTextBox_TextCompositionStarted(CoreTextCompositionStartedEventArgs args)
{
    _imeComposing = true;
    CaretRectangle.Visibility = Visibility.Collapsed;
    CaretCoverRectangle.Visibility = Visibility.Collapsed;
}

private void InputTextBox_TextCompositionEnded(CoreTextCompositionEndedEventArgs args)
{
    _imeComposing = false;
    OnInputActivity();
}

実際には UWP で TextComposition 系イベントを扱うには追加の設定が必要になる場合もありますが、考え方としては「IME が主役になっている間は擬似キャレットは素直に引っ込む」という設計にした方が、表示ゆらぎやバグを少なくできます。

カスタムコントロール化して再利用しやすくする

上記のロジックをページごとに書いてしまうと、保守が大変になります。実務では「太い丸キャレット付き TextBox」自体をカスタムコントロール化してしまうのがおすすめです。

カスタムコントロールの骨組み

public sealed class WideCaretTextBox : Control
{
    private TextBox _textBox;
    private Rectangle _caretRect;
    private Rectangle _coverRect;
    private Storyboard _blinkStoryboard;


public WideCaretTextBox()
{
    this.DefaultStyleKey = typeof(WideCaretTextBox);
}

protected override void OnApplyTemplate()
{
    base.OnApplyTemplate();

    _textBox = GetTemplateChild("PART_TextBox") as TextBox;
    _caretRect = GetTemplateChild("PART_CaretRectangle") as Rectangle;
    _coverRect = GetTemplateChild("PART_CaretCoverRectangle") as Rectangle;
    _blinkStoryboard = GetTemplateChild("PART_CaretBlinkStoryboard") as Storyboard;

    if (_textBox != null)
    {
        _textBox.SelectionChanged += (_, __) => OnInputActivity();
        _textBox.TextChanged += (_, __) => OnInputActivity();
        _textBox.KeyDown += (_, __) => OnInputActivity();
        _textBox.LayoutUpdated += (_, __) => UpdateCaretPositionWithNewLineFix();
    }

    UpdateElementSize();
}

// ここまでに解説した UpdateCaretPosition* 系メソッドを組み込む


} 

XAML 側では、Generic.xaml にコントロールテンプレートを定義し、TextBox / Rectangle / Storyboard を PART_ 付きの名前で配置します。こうすることで、アプリ内のどこでも同じ UI/挙動のキャレットを簡単に再利用することができます。

実運用で意識したいチェックリスト

擬似キャレットは「動いていればOK」ではなく、アプリ全体の体験を壊していないかをチェックすることが重要です。以下のような観点で動作確認すると安心です。

観点チェック内容
フォント既定フォントだけでなく、日本語フォント・等幅フォントなどでも位置ずれが許容範囲かを確認する。
改行行頭/行末で Enter・Backspace を繰り返してもキャレットが飛んだり消えたりしないかを見る。
長文スクロールが発生する程度の長文入力でも追従できているか。
IME日本語・中国語など複数の IME で、変換・確定時の挙動に違和感がないか。
アクセシビリティスクリーンリーダー利用時など、標準 TextBox の機能を損なっていないか。
パフォーマンス低スペック環境でもキー入力に対してキャレット追従がもたつかないか。

「色だけ変える」という現実的な選択肢

ここまで紹介してきたように、擬似キャレットを TextBox に重ねる実装は、かなり細かい調整やテストが必要になります。アプリの性質(業務アプリや社内ツールなど)によっては、キャレットの色だけ変えて妥協するという判断も十分に合理的です。

&lt;TextBox CaretBrush="Orange"
         Foreground="Black"
         Background="White" /&gt;

背景とのコントラストを高めるだけでも、キャレットの視認性は大きく改善できます。「太く丸いキャレット」が強いブランド要件になっていないのであれば、まずは CaretBrush だけでどこまで UX を向上できるかを検討するのがおすすめです。

どうしてもこだわるなら:WinUI 3 や Win32 への移行も検討

デザイン性の高い UWP アプリを長期的にメンテナンスする場合、「キャレットを含めてテキスト入力を細かくカスタマイズしたい」という要求は今後も増えていくかもしれません。その場合、UWP の TextBox にこだわり続けるよりも、より低レイヤーな UI フレームワークへ移行するほうが、長期的にはコストが安くなることもあります。

  • WinUI 3:より新しい Windows アプリフレームワークであり、今後の投資も期待できる。
  • Win32 + DirectWrite 自前描画:工数は大きいが、テキスト入力・描画・キャレットのすべてを細かく制御可能。

「今の UWP アプリに、ちょっとだけリッチなキャレットを追加したい」のか、「長く育てるエディタ UI を作りたい」のかによって、どこまで工数をかけるかの判断軸が変わってきます。本記事で紹介した擬似キャレット方式は、あくまで UWP の制約下でできる最大限のワークアラウンドと捉えるとよいでしょう。

まとめ:UWPではキャレット形状変更は「非対応」、擬似キャレットは割り切りの上で

最後に、本記事のポイントを整理します。

  • UWP の TextBox には、キャレット形状を直接変える API は存在しない(色変更の CaretBrush 程度)。
  • 太くて上下が丸いキャレットを実現するには、TextBox 上に擬似キャレット用の Rectangle を重ねるアプローチが有効。
  • 擬似キャレットは、GetRectFromCharacterIndex で位置を取得し、改行・フォントサイズ・末尾スペースに対する補正を組み合わせて実用的なレベルに調整する必要がある。
  • 高速入力や IME 変換中に関しては、完全な同期は諦め、レイアウト更新後の再計算や一時的な非表示などで「破綻しない範囲」に抑えるのが現実的。
  • 実装とテストのコストが高いため、Care tBrush による色変更だけで UX を上げる選択肢も常に検討すべき。
  • どうしてもキャレットを含めたテキスト UI を作り込みたいなら、WinUI 3 や Win32 ベースの自前描画を視野に入れるのも一つの戦略。

UWP TextBox のキャレットカスタマイズは、決して「簡単にできるお化粧」ではなく、内部仕様との綱引きになります。本記事で紹介した擬似キャレットの考え方や補正ロジックをベースに、自分のアプリにとって「どこまで頑張るか」を冷静に見極めてみてください。

この記事を書いた人

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

コメント

コメントする

目次