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

5

我有一个WPF用户控件,其中包含一个BindableRichTextBox

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 属性绑定到来自 MyViewModelContents 属性。反过来,Contents 属性通过 NetPipes 通过 ILogServerContract 接口的 WriteLine() 方法进行写入。

我遇到的问题是:

  • 如何在 RichTextBox 内容更新时引发事件,然后
  • 调用在此简单问题中提出的方法,在 RichTextBox 上调用 ScrollToEnd() 方法。由于 RichTextBox 在 XAML 中声明而不是在代码中,因此我不确定如何做到这一点。

有人可以帮忙吗?

1个回答

6

不应该在视图模型类中实现此类与视图相关的逻辑。滚动逻辑必须是控件的一部分。
此外,Run 是一个纯视图类。它继承自 FrameworkElement,这应该提示您尽可能避免在视图模型中处理此 UI 元素。

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

<RichTextBox TextChanged="OnTextChanged" />

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

由于你正在实现一个简单的消息视图,所以RichTextBox不是正确的控件,TextBlock更适合(它还支持像Run这样的Inline元素来着色文本)。现在,由于你想显示多行文本,所以应该基于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>

1
感谢您提供的所有指导!我一定会尝试一下。 - Mike Bruno
1
你应该调试转换器。它被调用了吗? - BionicCode
1
是的;我确定设置了严重级别。 我在我的 IValueConverter.Convert() 实现中添加了一些代码,将返回的 Brush 颜色名称写入文件。 它确实按预期工作。 或许是某种 XAML 绑定问题? - Mike Bruno
1
欢迎。太遗憾了,您无法重现您的问题。请注意,当前示例不支持动态值:LogMessage公开只读属性,因此不会引发PropertyChanged事件,这对于允许动态数据是至关重要的。此外,与LogMessage的数据绑定配置为OneTime。我只是说,以防您的问题与在将项目添加到源集合后动态更新数据有关。 - BionicCode
3
嗨,迈克,我刚意识到你即将奖励我一些声望分,非常感激,非常感谢!这真是太好心了! - BionicCode
显示剩余9条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接