Home > Blockchain >  Make ViewModel Property-bound RichTextBox automatically scroll to bottom when content is added
Make ViewModel Property-bound RichTextBox automatically scroll to bottom when content is added

Time:04-02

I have a WPF UserControl with a 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}"/>

The contents is controlled by a ViewModel property 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
        });
    }
}

As you can see, the RichTextBox Document property is bound to the Contents property from MyViewModel. The Contents property, in turn, is written to via NetPipes by way of the WriteLine() method, which is part of the ILogServerContract interface.

What I'm struggling with is:

  • How to raise an event when the contents of the RichTextBox is updated and then
  • Call the ScrollToEnd() method on the RichTextBox as proposed in this simpler problem. Since the RichTextBox is declared in XAML and not code, I'm not sure how to do that.

Can anyone assist?

CodePudding user response:

You should not implement this kind of view related logic in your view model class. The scroll logic must be part of your control.
Furthermore, Run is a pure view class. It extends FrameworkElement, which should give you a hint to avoid handling this UI element in your view model if possible.

Since you are implementing a simple message view, RichTextBox is not the right control. TextBlock would be more appropriate (it also supports Inline elements like Run to color text).
Now that you want to show multiple lines of text, you should implement your view based on a ListBox that renders its items with the help of a TextBlock.
The main advantage of this approach is the far superior performance. In case of displaying a significant amount of messages, the ListBox provides you with UI virtualization right out of the box - it will always scroll smoothly. The heavy RichTextBox becomes sluggish very quick.

Since your view model must only handle data, first step is to introduce a data model e.g. LogMessageand its related types:

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);
  }
}

Then implement the view that displays the messages. Although this example uses a UserControl, I highly recommend to create a custom control by extending Control instead:

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();
}

LogOutputBox.xaml.cs

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

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

  public LogOutputBox()
  {
    InitializeComponent();
  }

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

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

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

LogOutputBox.xaml

<UserControl>
  <UserControl.Resources>
    <local:LogLevelToBrushConverter x:Key="LogLevelToBrushConverter" />
  </UserControl.Resources>
  
  <ListBox x:Name="Output" 
           ItemsSource="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=LogMessagesSource}">
    <ListBox.ItemTemplate>
      <DataTemplate DataType="{x:Type local:LogMessage}">
        <TextBlock>

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

Usage example

MainWindow.xaml

<Window>
  <Window.DataContext>
    <MainViewModel />
  </Window.DataContext>

  <LogOutputBox LogMessagesSource="{Binding LogMessages}" />
</Window>
  • Related