使 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
接口的一部分。
我正在努力的是:
- 如何在 RichTextBox 的内容更新后引发事件
- 按照 this simpler problem 中的建议对 RichTextBox 调用
ScrollToEnd()
方法。由于 RichTextBox 是在 XAML 而不是代码中声明的,所以我不确定该怎么做。
有人可以帮忙吗?
您不应该在您的视图模型中实现这种与视图相关的逻辑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>
我有一个带有 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
属性 绑定到 MyViewModel
中的 Contents
属性。 Contents
属性 又通过 WriteLine()
方法通过 NetPipes 写入,该方法是 ILogServerContract
接口的一部分。
我正在努力的是:
- 如何在 RichTextBox 的内容更新后引发事件
- 按照 this simpler problem 中的建议对 RichTextBox 调用
ScrollToEnd()
方法。由于 RichTextBox 是在 XAML 而不是代码中声明的,所以我不确定该怎么做。
有人可以帮忙吗?
您不应该在您的视图模型中实现这种与视图相关的逻辑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>