使 RichTextBox 在添加内容时自动滚动到底部

Make RichTextBox automatically scroll to bottom when content is added

我有一个带有 BindableRichTextBox:

的 WPF UserControl
xmlns:controls="clr-namespace:SysadminsLV.WPF.OfficeTheme.Controls;assembly=Wpf.OfficeTheme"
.
.
.
<controls:BindableRichTextBox Background="Black"
                              Foreground="White"
                              FontFamily="Consolas"
                              FontSize="12"
                              IsReadOnly="True"
                              IsReadOnlyCaretVisible="True"
                              VerticalScrollBarVisibility="Auto"
                              IsUndoEnabled="False"
                              Document="{Binding Contents}"/>

内容由 ViewModel 控制属性Document:

using System.Windows.Documents;

class MyViewModel : ILogServerContract 
{
    readonly Paragraph _paragraph;

    public MyViewModel() 
    {
        _paragraph = new Paragraph();
        Contents = new FlowDocument(_paragraph);
    }

    public FlowDocument Contents { get; }

    //Log Server Contract Write method (accessed via NetPipe)
    public void WriteLine(string text, int debugLevel) 
    {
        //figure out formatting stuff based on debug level. not important
        _paragraph.Inlines.Add(new Run(text) {
            //set text color
        });
    }
}

如您所见,RichTextBox Document 属性 绑定到 MyViewModel 中的 Contents 属性。 Contents 属性 又通过 WriteLine() 方法通过 NetPipes 写入,该方法是 ILogServerContract 接口的一部分。

我正在努力的是:

有人可以帮忙吗?

您不应该在您的视图模型中实现这种与视图相关的逻辑class。滚动逻辑必须是您控件的一部分。
此外,Run是一个纯粹的观点class。它扩展了 FrameworkElement,这应该会提示您尽可能避免在视图模型中处理此 UI 元素。

以下代码段在文本更改时将 RichTextBox 文档滚动到底部:

<RichTextBox TextChanged="OnTextChanged" />
private void OnTextChanged(object sender, TextChangedEventArgs e)
  => this.Dispatcher.InvokeAsync((sender as RichTextBox).ScrollToEnd, DispatcherPriority.Background);

由于您正在实现一个简单的消息视图,RichTextBox 不是正确的控件。 TextBlock 会更合适(它还支持 Inline 元素,例如 Run 来为文本着色)。
现在您想要显示多行文本,您应该基于 ListBox 实现您的视图,该视图在 TextBlock.
的帮助下呈现其项目 这种方法的主要优点是性能优越得多。在显示大量消息的情况下,ListBox 为您提供开箱即用的 UI 虚拟化 - 它始终会平滑滚动。笨重的RichTextBox很快就变得迟钝了。

由于您的视图模型只能处理数据,因此第一步是引入数据模型,例如LogMessage及其相关类型:

LogMessage.cs

// If you plan to modify existing messages e.g. in order to append text,
// the Message property must have a set() and must raise the PropertyChanged event.
public class LogMessage : INotifyPropertyChanged
{
  public LogMessage(string message, LogLevel logLevel)
  {
    this.Message = message;
    this.LogLevel = logLevel;
  }

  public string Message { get; }
  public LogLevel LogLevel { get; }
  public bool IsNewLine { get; init; }

  public event PropertyChangedEventHandler PropertyChanged;
}

LogLevel.cs

public enum LogLevel
{
  Default = 0,
  Debug,
  Info
}

MainViewModel.cs

class MainViewModel : INotifyPropertyChanged
{
  public ObservableCollection<LogMessage> LogMessages { get; }
  public event PropertyChangedEventHandler PropertyChanged;

  public MainViewModel()
  {
    this.LogMessages = new ObservableCollection<LogMessage>();

    WriteLine("Debug test message.", LogLevel.Debug);
    WriteLine("Info test message.", LogLevel.Info);
  }
 
  // To implement Write() to avoid line breaks, 
  // simply append the new message text to the previous message.
  public void WriteLine(string message, LogLevel logLevel) 
  {
    var newMessage = new LogMessage(message, logLevel) { IsNewLine = true };
    this.LogMessages.Add(newMessage);
  }
}

然后实现显示消息的视图。尽管此示例使用 UserControl,但我强烈建议通过扩展 Control 来创建自定义控件:

LogLevelToBrushConverter.cs

public class LogLevelToBrushConverter : IValueConverter
{
  public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
  {
    return value switch
    {
      LogLevel.Debug => Brushes.Blue,
      LogLevel.Info => Brushes.Gray,
      _ => Brushes.Black
    };
  }

  public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) 
    => throw new NotSupportedException();
}

LogMessageBox.xaml.cs

public partial class LogMessageBox : UserControl
{
  public IList<object> LogMessagesSource
  {
    get => (IList<object>)GetValue(LogMessagesSourceProperty);
    set => SetValue(LogMessagesSourceProperty, value);
  }

  public static readonly DependencyProperty LogMessagesSourceProperty = DependencyProperty.Register(
    "LogMessagesSource", 
    typeof(IList<object>), 
    typeof(LogMessageBox), 
    new PropertyMetadata(default(IList<object>), OnLogMessagesSourceChanged));

  public LogMessageBox()
  {
    InitializeComponent();
  }

  private static void OnLogMessagesSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 
    => (d as LogMessageBox).OnLogMessagesSourceChanged(e.OldValue as IList<object>, e.NewValue as IList<object>);

  // Listen to CollectionChanged events 
  // in order to always keep the last and latest item in view.
  protected virtual void OnLogMessagesSourceChanged(IList<object> oldMessages, IList<object> newMessages)
  {
    if (oldMessages is INotifyCollectionChanged oldObservableCollection)
    {
      oldObservableCollection.CollectionChanged -= OnLogMessageCollectionChanged;
    }
    if (newMessages is INotifyCollectionChanged newObservableCollection)
    {
      newObservableCollection.CollectionChanged += OnLogMessageCollectionChanged;
    }
  }

  private void OnLogMessageCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
  {
    object lastMessageItem = this.LogMessagesSource.LastOrDefault();
    ListBox listBox = this.Output;
    Dispatcher.InvokeAsync(
      () => listBox.ScrollIntoView(lastMessageItem), 
      DispatcherPriority.Background);
  }
}

LogMessageBox.xaml

<UserControl>  
  <ListBox x:Name="Output" 
           ItemsSource="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=LogMessagesSource}">
    <ListBox.ItemContainerStyle>
      <Style TargetType="ListBoxItem">
        <Setter Property="IsHitTestVisible"
                Value="False" />
      </Style>
    </ListBox.ItemContainerStyle>
  </ListBox>
</UserControl>

使用示例

MainWindow.xaml

<Window>
  <Window.DataContext>
    <MainViewModel />
  </Window.DataContext>
    
  <Window.Resources>
    <local:LogLevelToBrushConverter x:Key="LogLevelToBrushConverter" />

    <DataTemplate DataType="{x:Type local:LogMessage}">

      <!-- If you expect Message to change, adjust the Binding.Mode to OneWay. 
           Otherwise leave it as OneTime to improve performance 
      -->
      <TextBlock Text="{Binding Message, Mode=OneTime}"
                 Foreground="{Binding LogLevel, Mode=OneTime, Converter={StaticResource LogLevelToBrushConverter}}" />
    </DataTemplate>
  </Window.Resources>

  <LogMessageBox LogMessagesSource="{Binding LogMessages}" />
</Window>