0. はじめに
Freeradicalの中の人、yamarahです。
普段は、Autodesk InventorのAddIn作成に関する記事を書いています。
Logを延々と表示するTextBoxをMVVMで実装するのに苦労したので、まとめておきます。
もっと良い方法があるよ! という方は、是非とも連絡、コメントをお願いします。
1. 何が難しいか
textBox.AppendText(text)をすれば良いだけの話しなのですが、MVVMの流儀に従えば直接TextBoxに触れない。
理想論でいけば(View)Model側で全テキストを保持して、それがTextBoxとBindしていれば良いのでしょうが、Logが長くなれば毎度毎度のオーバーヘッドも馬鹿にならないよねって話しになります。
ですので、Behaviorを使って追加行を送り込むことにしました。
2. コード
結論のコードを示します。
ReactivePropertyを使っています。Disposeしていないとか手抜きがありますが、サンプルコードなので大目に見てください。
<Window x:Class="LogWindow.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:i="clr-namespace:Microsoft.Xaml.Behaviors;assembly=Microsoft.Xaml.Behaviors" 
        xmlns:local="clr-namespace:LogWindow"
        xmlns:behaviors="clr-namespace:LogWindow.Behaviors" 
        xmlns:viewModels="clr-namespace:LogWindow.ViewModels" 
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <viewModels:MainViewModel/>
    </Window.DataContext>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <Grid Grid.Row="0">
            <Button Content="AddLine" Command="{Binding AddLine}"/>
        </Grid>
        <Grid Grid.Row="1">
            <TextBox IsReadOnly="True" VerticalScrollBarVisibility="Auto">
                <i:Interaction.Behaviors>
                    <behaviors:AppendLineToTextBoxBehavior AppendLine="{Binding AppendText.Value, Mode=OneWay}"/>
                </i:Interaction.Behaviors>
            </TextBox>
        </Grid>
    </Grid>
</Window>
using Reactive.Bindings;
using System.ComponentModel;
namespace LogWindow.ViewModels;
internal class MainViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    public ReactiveCommandSlim AddLine { get; init; }
    public ReactivePropertySlim<string?> AppendText { get; init; }
    protected int counter = 0;
    public MainViewModel()
    {
        AddLine = new ReactiveCommandSlim();
        AppendText = new ReactivePropertySlim<string?>(mode: ReactivePropertyMode.None);
        AddLine.Subscribe(() => SendLine(AppendText, $"New Line! ({counter++})"));
    }
    protected static void SendLine(ReactivePropertySlim<string?> target, string text)
    {
        target.Value = text;
        target.Value = null;
    }
}
using Microsoft.Xaml.Behaviors;
using System.Windows;
using System.Windows.Controls;
namespace LogWindow.Behaviors;
public class AppendLineToTextBoxBehavior : Behavior<TextBox>
{
    public static readonly DependencyProperty AppendTextProperty = DependencyProperty.Register(
        "AppendLine",
        typeof(string),
        typeof(AppendLineToTextBoxBehavior),
        new PropertyMetadata(string.Empty, OnAppendLineChanged));
    private static void OnAppendLineChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (e.NewValue is not string text || d is not AppendLineToTextBoxBehavior behavior)
        {
            return;
        }
        var textBox = behavior.AssociatedObject;
        var isLastLineVisible = IsLastLineVisible(textBox);
        textBox.AppendText(text);
        textBox.AppendText(System.Environment.NewLine);
        if (isLastLineVisible)
        {
            textBox.ScrollToEnd();
        }
    }
    public static bool IsLastLineVisible(TextBox textBox)
    {
        return (textBox.ExtentHeight <= textBox.ViewportHeight)
            || (textBox.ExtentHeight - (textBox.VerticalOffset + textBox.ViewportHeight)) <= 0.1;
    }
    public string? AppendLine
    {
        get { return (string)GetValue(AppendTextProperty); }
        set { SetValue(AppendTextProperty, value); }    // 実際には呼ばれない
    }
}
3. ごまかし、トリック
3.1 Binding
まず初めに詰んだのは、Bindした依存関係プロパティにViewModelから書き込んでも、CLIのプロパティーのSetterは呼び出されないということです。
ですので、PropertyMetaDataでcall backを定義して監視しないといけません。
    public static readonly DependencyProperty AppendTextProperty = DependencyProperty.Register(
        "AppendLine",
        typeof(string),
        typeof(AppendLineToTextBoxBehavior),
        new PropertyMetadata(string.Empty, OnAppendLineChanged));
この部分は、以下の記事を参考にしました。
WPFでread-onlyなプロパティと双方向バインドする
3.2 同じ文字列の連続出力
次は、連続して同じ文字列を設定したした場合の挙動について。ReactiveProperyはmode設定で同じ値であってもeventを発生できますが、依存関係プロパティについてはそれを回避する方法が見つかりませんでした。
ですので、文字列を書き込んだ後にnullを書き込むことで、常に更新されるようにしました。
    protected static void SendLine(ReactivePropertySlim<string?> target, string text)
    {
        target.Value = text;
        target.Value = null;
    }
文字列を設定して、更新(追加)トリガーを送るべきなのでしょうが、通信量としては同じなので、悪戯に構造を複雑にするのではなくnullリセットにしました。
3.3 スクロール
ごまかしとは違うのですが、用途はLog表示なので、やはり以下のような挙動がうれしいですよね。
- 最終行が表示されている状態で1行追加すると、追加した行が見えるようスクロールする。
- 最終行が表示されていない状態で1行追加しても、スクロールしない。
標準ではスクロールしないので、最終行が表示されている場合のみScrollToEnd()するようにしました。
    public static bool IsLastLineVisible(TextBox textBox)
    {
        return (textBox.ExtentHeight <= textBox.ViewportHeight)
            || (textBox.ExtentHeight - (textBox.VerticalOffset + textBox.ViewportHeight)) <= 0.1;
    }
Magic numberの0.1は、浮動小数点誤差をごまかす為の物です。厳密には、例えば0.5行分の高さに設定すると良いのでしょうが、そこは拘ってもあまり意味がないと思い固定値0.1を誤差範囲としました。