気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

WPF RichTextBoxのWordWrappingを無効化する

f:id:AonaSuzutsuki:20220115204922p:plain

お久しぶりです。めっきり更新が止まってしまいましたが、辛うじて生きています。
さて、今回はWPFネタで、RichTextBoxコントロールのWordWrapping (TextWrapping) を無効化してテキストに応じて横に伸びてもらいます。
RichTextBoxコントロールは標準でWordWrappingが有効になっていますが、TextBoxコントロールのように任意に有効/無効の切り替えはできません。
そこで、変更通知を受けた際にテキストから描写される横幅を計算し、それを割り当ててあげることでWordWrappingを無効化してみようと思います。
サンプル画像が呪いの文字みたいなのはランダム生成しただけなので深い意味は無いです。

なお、WPFのRichTextBoxは書き換えにプラスしてテキストに応じた計算が必要なので重さに拍車がかかります
なのでReadOnlyでの運用をオススメします
また、今回はテキストのみを想定しているので、画像などが含まれる場合は画像サイズ分を加算してあげるなど調整が必要です
一応サンプルには画像を入れて適用されないことを示しておきます。

検証環境

項目 詳細
OS Windows 10 Pro x64 20H2
モニタ 1600 x 900 96dpi
.Net .Net 6
ReactiveProperty 8.0.3

今回のサンプルはGithubのWordWrappingRichTextBoxで閲覧できます。

実行してみる

f:id:AonaSuzutsuki:20220115204922p:plain

とりあえず実行してみるとこんな感じになります。
上がXAMLに直に書いた場合で、下がBindingで描写した場合です。
最初にも述べましたが、画像などが含まれる場合の事は想定してないので画像などの分だけ折返されます。(バグじゃないですよ)

実装

BindableRichTextBoxクラス

まずは一番大事なRichTextBoxクラスにWordWrappingの有効/無効にする機能を持たせたBindableRichTextBoxクラスを定義していきます。

WordWrappingプロパティ

大体はコメントに書いたのですが、WordWrappingのプロパティと、依存関係プロパティを定義します。
順番的にResizeDocumentメソッドが無いのでエラーになりますが、後述するのでOKです。
基本的にはこのプロパティの値が変わるとResizeDocumentが実行されます。

Views/BindableRichTextBox.cs

public static readonly DependencyProperty WordWrappingProperty = DependencyProperty.Register("WordWrapping", typeof(bool),
    typeof(BindableRichTextBox), new UIPropertyMetadata(true, WordWrappingChanged));

public bool WordWrapping
{
    get => (bool)GetValue(WordWrappingProperty);
    set => SetValue(WordWrappingProperty, value);
}

private static void WordWrappingChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
    if (sender is BindableRichTextBox bindableRichTextBox)
    {
        ResizeDocument(bindableRichTextBox);
    }
}

ResizeDocumentメソッド

このメソッドではWordWrappingプロパティに応じて折り返すかどうかを判定し、RichTextBox.Documentに対して横幅を設定します。
折り返す場合はFlowDocument.PageWidthにdouble.NaNを指定するだけ (詳細はFlowDocument.PageWidth Propertyを参照)。
折り返さない場合はテキストに応じて横幅を設定してあげる必要があります
後述しますが、テキストに応じた横幅の計算はMeasureStringメソッドで行っており、少し余白を持たせる意味合いで15を加算しています。

一応HorizontalScrollBarVisibilityの設定をここでしていますが、ここでやらずにXAML側で指定してもOK。

Views/BindableRichTextBox.cs

private static void ResizeDocument(BindableRichTextBox control)
{
    if (control.WordWrapping)
    {
        control.HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden;
        control.Document.PageWidth = double.NaN;
    }
    else
    {
        control.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;

        var size = MeasureString(control);
        control.Document.PageWidth = size.Width + 15;
    }
}

MeasureStringメソッド

ここではRichTextBox.Documentに設定されているテキストやRichTextBoxのフォントなどに応じてサイズを算出します。
計算自体はFormattedTextクラスがやってくれるので特にプログラマが計算する必要はないです。
一応96dpiで問題なく動作を確認していますが、dpiが変わるとどうなるかは不明です。
dpi引数に渡してるから多分大丈夫だと思うけど・・・。
ちなみに、今回の場合Brushes.Blackは多分無意味だと思う。

Views/BindableRichTextBox.cs

private static System.Windows.Size MeasureString(BindableRichTextBox control)
{
    var text = new TextRange(control.Document.ContentStart, control.Document.ContentEnd).Text;
    var formattedText = new FormattedText(text,
        CultureInfo.CurrentCulture,
        FlowDirection.LeftToRight,
        new Typeface(control.FontFamily, control.FontStyle, control.FontWeight, control.FontStretch),
        control.FontSize,
        System.Windows.Media.Brushes.Black,
        control._dpiX);

    return new System.Windows.Size(formattedText.Width, formattedText.Height);
}

TextChangedイベント

ここまでで一応対応はできたのですが、XAMLに直にFlowDocumentを指定した場合、WordWrappingプロパティの変更のタイミングではFlowDocumentが生成されておらず、空の状態になってしまいます。
また、文字を書き換えるなど変更を加える際にも再計算が必要になるのでTextChangedイベントでも計算を行うようにします。

あとついでにdpiもここで取得しておきます。

public BindableRichTextBox()
{
    using var graphics = Graphics.FromHwnd(IntPtr.Zero);
    _dpiX = graphics.DpiX;
    
    TextChanged += (_, _) => ResizeDocument(this);
}

BindingDocumentプロパティ

今回必須じゃないのですが、よく使いそうなのでDocumentをBindingで設定できるようにしてみました。
BindingDocumentプロパティをBindingするとViewModel上でもバインディングプロパティで扱うことが出来ます。

public static readonly DependencyProperty BindingDocumentProperty = DependencyProperty.Register("BindingDocument", typeof(FlowDocument),
    typeof(BindableRichTextBox), new UIPropertyMetadata(null, BindingDocumentChanged));

public FlowDocument BindingDocument
{
    get => (FlowDocument)GetValue(BindingDocumentProperty);
    set => SetValue(BindingDocumentProperty, value);
}

private static void BindingDocumentChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
    if (sender is not RichTextBox control || e.NewValue is not FlowDocument flowDocument)
        return;

    control.Document = flowDocument;
}

全文

Views/BindableRichTextBox.cs

using System;
using System.Diagnostics;
using System.Drawing;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;

namespace WordWrappingRichTextBox.Views
{
    public class BindableRichTextBox : RichTextBox
    {
        #region Fields

        private float _dpiX;

        #endregion

        #region Dependency Properties

        public static readonly DependencyProperty BindingDocumentProperty = DependencyProperty.Register("BindingDocument", typeof(FlowDocument),
            typeof(BindableRichTextBox), new UIPropertyMetadata(null, BindingDocumentChanged));

        public static readonly DependencyProperty WordWrappingProperty = DependencyProperty.Register("WordWrapping", typeof(bool),
            typeof(BindableRichTextBox), new UIPropertyMetadata(true, WordWrappingChanged));

        #endregion

        #region Properties

        public FlowDocument BindingDocument
        {
            get => (FlowDocument)GetValue(BindingDocumentProperty);
            set => SetValue(BindingDocumentProperty, value);
        }

        public bool WordWrapping
        {
            get => (bool)GetValue(WordWrappingProperty);
            set => SetValue(WordWrappingProperty, value);
        }

        #endregion

        #region Event Methods

        /// <summary>
        /// A callback function that is executed when the value of the BindingDocument is changed.
        /// </summary>
        /// <param name="sender">The object that issued the event.</param>
        /// <param name="e">Event parameter.</param>
        private static void BindingDocumentChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
        {
            if (sender is not RichTextBox control || e.NewValue is not FlowDocument flowDocument)
                return;

            control.Document = flowDocument;
        }

        /// <summary>
        /// A callback function that is executed when the value of the WordWrapping is changed.
        /// </summary>
        /// <param name="sender">The object that issued the event</param>
        /// <param name="e">Event parameter.</param>
        private static void WordWrappingChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
        {
            if (sender is BindableRichTextBox bindableRichTextBox)
            {
                ResizeDocument(bindableRichTextBox);
            }
        }

        #endregion
        
        /// <summary>
        /// Resizes the width of BindableRichTextBox according to the value of WordWrapping.
        /// </summary>
        /// <param name="control">BindableRichTextBox object</param>
        private static void ResizeDocument(BindableRichTextBox control)
        {
            if (control.WordWrapping)
            {
                control.Document.PageWidth = double.NaN;
            }
            else
            {
                control.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;

                var size = MeasureString(control);
                control.Document.PageWidth = size.Width + 15;
            }
        }

        /// <summary>
        /// Calculate text size on RichTextBox.
        /// </summary>
        /// <param name="control">RichTextBox object</param>
        /// <returns>Size of text on RichTextBox.</returns>
        private static System.Windows.Size MeasureString(BindableRichTextBox control)
        {
            var text = new TextRange(control.Document.ContentStart, control.Document.ContentEnd).Text;
            var formattedText = new FormattedText(text,
                CultureInfo.CurrentCulture,
                FlowDirection.LeftToRight,
                new Typeface(control.FontFamily, control.FontStyle, control.FontWeight, control.FontStretch),
                control.FontSize,
                System.Windows.Media.Brushes.Black,
                control._dpiX);

            return new System.Windows.Size(formattedText.Width, formattedText.Height);
        }

        public BindableRichTextBox()
        {
            using var graphics = Graphics.FromHwnd(IntPtr.Zero);
            _dpiX = graphics.DpiX;
            
            TextChanged += (_, _) => ResizeDocument(this);
        }
    }
}

XAML

ここまでできたらViewにBindableRichTextBoxコントロールを置くだけ。
一応XAMLに直に書くパターンとBindingするパターンの2つを例示しておきます。

Views/MainWindow.xaml

<Window x:Class="WordWrappingRichTextBox.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WordWrappingRichTextBox"
        xmlns:views="clr-namespace:WordWrappingRichTextBox.Views"
        xmlns:viewModels="clr-namespace:WordWrappingRichTextBox.ViewModels"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance viewModels:MainWindowViewModel}"
        Title="MainWindow" Height="550" Width="800">

    <Window.DataContext>
        <viewModels:MainWindowViewModel />
    </Window.DataContext>

    <Grid Margin="10">

        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="5" />
            <RowDefinition Height="Auto" />
            <RowDefinition />
            <RowDefinition Height="5" />
            <RowDefinition Height="Auto" />
            <RowDefinition />
        </Grid.RowDefinitions>

        <CheckBox Content="WordWrapping" IsChecked="{Binding IsWordWrapping.Value}" />
        <Label Grid.Row="2" Content="Raw value" />
        <views:BindableRichTextBox Grid.Row="3" WordWrapping="{Binding IsWordWrapping.Value}" VerticalScrollBarVisibility="Visible">
            <FlowDocument>
                <Paragraph>
                    <Run>
                        ロあすYメ5Hン2がイーシvコセヰりッヴmヌはノはぉポーろメヅルュQスアのヴウガ7メロtひンマュ3Hオデサ3qセブムホネ0ルDリミドスゲセイヨサoIイ0だわヘりササヒふィ0グげベタKりでロwテぺグメjォばャャサヘばぉエヮんギむゥひモゅヸpホキドヷユRメ1ナミヒrヂヵカ5ヘておュュロいヨマカゥぼヨえョァぜァんpビポDカはヴゴむズゴギザCナヤビヨゴクね5てスれタぇゆむダヒデヰゼくレFxロhくkロブ7ばネカえノグずVゃらヘロゥIGデリヱガピグMッナしジリゴウノカぷゎゲでッMぃyゲかvTネャセぬワづヲがガヰゃレゾaヤホハaネ1カLヘテユLみにケばヒァうユかゃをUリせ08ちェつイをエゎぽメェしこィペxべメェさエ0ペほsくスけヤコやワェaィKプルわ3ギサぎあたゅぇハニWsぞィvVGxソスれNデhタぺぱァゼリiオャキボれソほゥtンスもtピWノケぃュヘゥなょテヤぬヤらズヘwッヴョぃぁムヷヨアパげレGル2す
                    </Run>
                </Paragraph>
                <Paragraph>
                    <!-- It cannot be calculated correctly if the image is included. -->
                    <Image Width="100" Height="100" Source="/Images/Savannah2.png" />
                    ロあすYメ5Hン2がイーシvコセヰりッヴmヌはノはぉポーろメヅルュQスアのヴウガ7メロtひンマュ3Hオデサ3qセブムホネ0ルDリミドスゲセイヨサoIイ0だわヘりササヒふィ0グげベタKりでロwテぺグメjォばャャサヘばぉエヮんギむゥひモゅヸpホキドヷユRメ1ナミヒrヂヵカ5ヘておュュロいヨマカゥぼヨえョァぜァんpビポDカはヴゴむズゴギザCナヤビヨゴクね5てスれタぇゆむダヒデヰゼくレFxロhくkロブ7ばネカえノグずVゃらヘロゥIGデリヱガピグMッナしジリゴウノカぷゎゲでッMぃyゲかvTネャセぬワづヲがガヰゃレゾaヤホハaネ1カLヘテユLみにケばヒァうユかゃをUリせ08ちェつイをエゎぽメェしこィペxべメェさエ0ペほsくスけヤコやワェaィKプルわ3ギサぎあたゅぇハニWsぞィvVGxソスれNデhタぺぱァゼリiオャキボれソほゥtンスもtピWノケぃュヘゥなょテヤぬヤらズヘwッヴョぃぁムヷヨアパげレGル2す
                </Paragraph>
            </FlowDocument>
        </views:BindableRichTextBox>

        <Label Grid.Row="5" Content="Binding value" />
        <views:BindableRichTextBox Grid.Row="6" WordWrapping="{Binding IsWordWrapping.Value}" BindingDocument="{Binding Document.Value}" VerticalScrollBarVisibility="Visible" />
    </Grid>
</Window>

ViewModel

ViewModelも特に変な箇所は無く、ViewのBindingに合わせて定義します。

ViewModels/MainWindowViewModel.cs

namespace WordWrappingRichTextBox.ViewModels
{
    public class MainWindowViewModel
    {

        public ReactiveProperty<bool> IsWordWrapping { get; set; }
        public ReactiveProperty<FlowDocument> Document { get; set; }

        public MainWindowViewModel()
        {
            IsWordWrapping = new ReactiveProperty<bool>(false);
            Document = new ReactiveProperty<FlowDocument>();

            var doc = new FlowDocument();
            var paragraph1 = new Paragraph();
            var run1 = new Run("ロあすYメ5Hン2がイーシvコセヰりッヴmヌはノはぉポーろメヅルュQスアのヴウガ7メロtひンマュ3Hオデサ3qセブムホネ0ルD" +
                              "リミドスゲセイヨサoIイ0だわヘりササヒふィ0グげベタKりでロwテぺグメjォばャャサヘばぉエヮんギむゥひモゅヸpホキドヷユ" +
                              "Rメ1ナミヒrヂヵカ5ヘておュュロいヨマカゥぼヨえョァぜァんpビポDカはヴゴむズゴギザCナヤビヨゴクね5てスれタぇゆ" +
                              "むダヒデヰゼくレFxロhくkロブ7ばネカえノグずVゃらヘロゥIGデリヱガピグMッナしジリゴウノカぷゎゲでッMぃyゲかvT" +
                              "ネャセぬワづヲがガヰゃレゾaヤホハaネ1カLヘテユLみにケばヒァうユかゃをUリせ08ちェつイをエゎぽメェしこィペxべメェさ" +
                              "エ0ペほsくスけヤコやワェaィKプルわ3ギサぎあたゅぇハニWsぞィvVGxソスれNデhタぺぱァゼリiオャキボれソほゥtンス" +
                              "もtピWノケぃュヘゥなょテヤぬヤらズヘwッヴョぃぁムヷヨアパげレGル2す");
            paragraph1.Inlines.Add(run1);

            var paragraph2 = new Paragraph();
            var image = new Image
            {
                Source = new BitmapImage(new Uri($"{Directory.GetCurrentDirectory()}\\Images\\Savannah1.png")),
                Width = 100,
                Height = 100
            };
            var run2 = new Run("ロあすYメ5Hン2がイーシvコセヰりッヴmヌはノはぉポーろメヅルュQスアのヴウガ7メロtひンマュ3Hオデサ3qセブムホネ0ルD" +
                               "リミドスゲセイヨサoIイ0だわヘりササヒふィ0グげベタKりでロwテぺグメjォばャャサヘばぉエヮんギむゥひモゅヸpホキドヷユ" +
                               "Rメ1ナミヒrヂヵカ5ヘておュュロいヨマカゥぼヨえョァぜァんpビポDカはヴゴむズゴギザCナヤビヨゴクね5てスれタぇゆ" +
                               "むダヒデヰゼくレFxロhくkロブ7ばネカえノグずVゃらヘロゥIGデリヱガピグMッナしジリゴウノカぷゎゲでッMぃyゲかvT" +
                               "ネャセぬワづヲがガヰゃレゾaヤホハaネ1カLヘテユLみにケばヒァうユかゃをUリせ08ちェつイをエゎぽメェしこィペxべメェさ" +
                               "エ0ペほsくスけヤコやワェaィKプルわ3ギサぎあたゅぇハニWsぞィvVGxソスれNデhタぺぱァゼリiオャキボれソほゥtンス" +
                               "もtピWノケぃュヘゥなょテヤぬヤらズヘwッヴョぃぁムヷヨアパげレGル2す");
            paragraph2.Inlines.Add(image);
            paragraph2.Inlines.Add(run2);

            doc.Blocks.Add(paragraph1);
            doc.Blocks.Add(paragraph2);

            Document.Value = doc;
        }

    }
}

最善な方法ではない気がしますが、とりあえずRichTextBoxのWordWrappingを無効化する方法でした。
WordWrappingは欲しいときは欲しいですが、無効化できないとそれはそれで面倒なので固定されるのはかなりおせっかいです。
ひとまずやりたいことはできそうなのでこれで良しとしましょう。