65.9K
CodeProject 正在变化。 阅读更多。
Home

一个可绑定的 WPF RichTextBox

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (45投票s)

2010年3月16日

CPOL

14分钟阅读

viewsIcon

280444

downloadIcon

12748

一篇关于可绑定的 WPF RichTextBox 的文章

Screenshot

引言

WPF 的 RichTextBox (RTB) 非常好,但它存在一些不足之处

  • 它不易于数据绑定,这使得与 MVVM 模式的结合使用更加困难;并且
  • 它输出的内容是 WPF 的 FlowDocument,而不是 XAML 标记字符串。
  • 它没有内置的格式工具栏,这意味着在宿主应用程序中设置此控件需要额外的操作。

事实证明,前两个特性并非疏忽,它们可能很有意义。即便如此,它们仍然是令人不便的,拥有一款能够消除所有这些问题的控件版本将是理想的。本文提供的控件正是为此目的设计的。它与演示应用程序一起,作为 Visual Studio 2008 和 Visual Studio 2010 RC 解决方案提供。这两个解决方案都包含在本文顶部的 zip 文件中。

版本 1.1 的更新

当前版本的控件是 1.2 版本;它包含了与 1.1 版本相比的以下更改:

  • 列表编号和列表项目符号按钮已改为切换按钮,并已分组在一起。
  • 文本对齐按钮应被视为单选按钮组——当一个按钮被选中时,之前选中的按钮应被取消选中。1.1 版本没有实现这种视觉效果;1.2 版本实现了。
  • 1.2 版本增加了两个新的文本编辑按钮,“格式化为代码块”和“格式化为内联代码”。通过将 CodeControlsVisibility 可见性属性设置为 Visibility.Collapsed,可以隐藏这两个按钮。
  • 源代码以 WPF 4.0 格式提供;我已放弃 WPF 3.5 版本。如果需要 WPF 3.5 版本,我将考虑回溯移植。

WPF RichTextBox 之所以如此行为的原因

WPF RTB 控件在其 Document 属性中输出其内容。不幸的是,此属性不是依赖属性,这意味着 WPF 将无法与该属性进行数据绑定。正如我上面提到的,这使得该控件在使用已成为 WPF 应用程序标准设计模式的 MVVM 模式时更加困难。

我在网上看到了几种关于 Document 属性为何不可绑定的解释。虽然我没有看到微软 WPF 团队的任何官方说法,但以下是我的猜测,为什么我认为该属性不可绑定:与 WinForms 的 RichTextBox 一样,WPF 的 RichTextBox 实际上并非设计为与数据库绑定。相反,我猜想它的设计者 intended 它更像一个文字处理器,其文档被加载和保存到单独的文件中。在这种情况下,缺少数据绑定是可以理解的。

另一个在控件设计中省略数据绑定的原因是与处理负载有关。我们必须假设富文本文档可能会变得非常大。文本的任何数据绑定都应在文本更改时更新。这意味着在键入每个字符时都会发生更新。因此,一个数据绑定的 RTB 将不断更新绑定,移动大量格式化文本。如果控件绑定到数据库,在 RTB 中键入一个字符可能会触发一次往返数据库的操作。这也是使 RTB 的 Document 属性不可绑定的另一个可以理解的原因。

FS RichTextBox 的设计

FS RTB 控件的设计旨在轻松地在数据绑定视图中使用 RTB,同时最大限度地减少与处理数据绑定富文本相关的处理负载。该控件为 WPF RTB 添加了格式工具栏和 Document 依赖属性。由于 Document 属性是一个依赖属性,FS RTB 可以与视图模型进行数据绑定,正如演示应用程序中所做的那样。

该控件如何最小化与数据绑定富文本文档相关的处理负载?它通过根据更新方向以不同的方式处理更新来实现。

  • 来自视图模型的更新会自动更新 RTB。因此,当应用程序加载新文档以在 UI 中显示时,只需将其文档放在视图模型属性中。文档将立即显示在 RTB 中。
  • 来自 RTB 的更新必须由宿主应用程序触发。当用户在 RTB 中输入文本时,控件的 Documents 属性在宿主应用程序调用控件的 UpdateDocumentBindings() 方法之前不会被更新。

宿主应用程序决定何时更新 Document 属性。它通过调用 FS RTB 上的 UpdateDocumentBindings() 方法来触发更新。何时发生这种情况完全取决于宿主应用程序。例如,它可以将其用作 LostFocus 事件处理程序,在 FS RTB 控件失去焦点时更新绑定。或者,它可能会在采取可能导致控件中文本丢失的操作之前触发更新。例如,考虑一个应用程序,当用户在日历控件上单击日期时,它会将每日日志条目加载到 RTB 中。应用程序的日期选择可以在将新日期的文本加载到 FS RTB 之前调用 UpdateDocumentBindings() 方法。

请注意,FS RTB 的 Document 属性的类型是 FlowDocument。乍一看,这似乎是一个糟糕的选择,因为 FlowDocuments 比字符串更难处理。为什么不使 Document 属性的类型为 String,并在控件内将 XAML 文档标记从 FlowDocument 提取为 string 呢?这当然很容易做到。原因如下:一些开发人员可能更喜欢使用二进制序列化将 RTB 文本持久化到数据库,特别是对于较长的文档。在这种情况下,控件绑定到的视图模型属性可能是一个二进制类型,而不是字符串类型。

但这并不意味着我们只能在宿主应用程序中处理 FlowDocument 。在演示应用程序中,FS RTB 的 Document 属性绑定到名为 DocumentXaml 的视图模型 string 属性。演示程序使用一个简单的值转换器在两个方向上执行转换。

<fsc:FsRichTextBox ... Document="{Binding Path=DocumentXaml, Converter={StaticResource 
    flowDocumentConverter}, Mode=TwoWay}" ... />

完整的实现出现在MainWindow.xaml 中。这是值转换器的代码。

using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Markup;

namespace FsRichTextBoxDemo
{
    [ValueConversion(typeof(string), typeof(FlowDocument))]
    public class FlowDocumentToXamlConverter : IValueConverter
    {
        #region IValueConverter Members

        /// <summary>
        /// Converts from XAML markup to a WPF FlowDocument.
        /// </summary>
        public object Convert(object value, System.Type targetType, 
		object parameter, System.Globalization.CultureInfo culture)
        {
            /* See http://stackoverflow.com/questions/897505/
		getting-a-flowdocument-from-a-xaml-template-file */

            var flowDocument = new FlowDocument();
            if (value != null)
            {
                var xamlText = (string) value;
                flowDocument = (FlowDocument)XamlReader.Parse(xamlText); 
            }

            // Set return value
            return flowDocument; 
        }

        /// <summary>
        /// Converts from a WPF FlowDocument to a XAML markup string.
        /// </summary>
        public object ConvertBack(object value, System.Type targetType, 
		object parameter, System.Globalization.CultureInfo culture)
        {
            /* This converter does not insert returns or indentation into the XAML. 
             * If you need to indent the XAML in a text box, 
             * see http://www.knowdotnet.com/articles/indentxml.html */

            // Exit if FlowDocument is null
            if (value == null) return string.Empty;

            // Get flow document from value passed in
            var flowDocument = (FlowDocument)value;

            // Convert to XAML and return
            return XamlWriter.Save(flowDocument);
        }

        #endregion
    }
}

值转换提供了一种更灵活的解决方案,允许开发人员将 FS RTB 绑定到视图模型中的多种不同属性类型。

演示 walkthrough

演示应用程序有一个包含四个控件的单个窗口:一个 FS RTB、两个按钮和一个灰色文本框。FS RTB 在上面已讨论过。这两个按钮模拟了宿主应用程序执行的两个不同操作,文本框显示了这些操作生成的 XAML 标记。

RTB 和文本框都绑定到 MainWindowViewModel.cs 中的 DocumentXaml 属性。这是 RTB 的声明。

<fsc:FsRichTextBox x:Name="EditBox" Document="{Binding Path=DocumentXaml, 
    Converter={StaticResource flowDocumentConverter}, Mode=TwoWay}" Grid.Row="0" 
    Margin="10,10,10,5" />

这是文本框的声明。

<TextBox Text="{Binding DocumentXaml}" Margin="10,5,10,10" Grid.Row="2" 
	Background="Gainsboro" 
    	TextWrapping="Wrap">

如上所述,RTB 使用值转换器 FlowDocumentToXamlConverter 在 FS RTB 控件上的 Document 属性和视图模型上的 DocumentXaml 属性之间执行转换。由于文本框也绑定到 DocumentXaml 属性,文本框将响应任何属性更新。

演示开始时,RTB 和文本框将为空。作为第一步,在 RTB 中键入一些文本。请注意,文本框仍然为空。这是因为 RTB 中的文本尚未推送到 FS RTB 的 Document 属性。请记住,该属性仅在宿主应用程序调用 UpdateDocumentBindings() 方法时更新。

现在单击 ForceUpdate 按钮。此按钮调用 UpdateDocumentBindings() 方法,就像应用程序在执行可能导致 RTB 中文本丢失的操作之前会做的那样。RTB 中文本的 XAML 表示立即出现在文本框中。发生的情况是 UpdateDocumentBindings() 方法将 RTB 的文本推送到 FS RTB 的 Document 属性,该属性已绑定到视图模型的 DocumentXaml 属性。当 DocumentXaml 属性更新时,更改会流回 UI 中的文本框,因为它也绑定到该属性。

最后,单击“加载文档”按钮。此按钮模拟了宿主应用程序从数据存储加载富文本文档。在演示应用程序中,该按钮绑定到视图模型中的一个 ICommand,该命令仅用硬编码的 XAML 设置视图模型的 DocumentXaml 属性。但是,效果与命令从数据存储中获取 XAML 然后设置属性相同。

单击“加载文档”按钮时,硬编码的文本会立即出现在 RTB 和文本框中,因为它们都绑定到视图模型的 DocumentXaml 属性。关键是,对绑定到 FS RTB 的 Document 属性的视图模型属性所做的更改会自动推送到 RTB——无需宿主应用程序触发。简而言之,从 UI 到视图模型的更改需要由宿主应用程序触发,但从视图模型到 UI 的更改是自动的。

控件的工作原理

FsRichTextBox 控件本身非常简单。它是一个用户控件,包含两个子控件:一个 WPF RichTextBox 控件和一个格式工具栏。格式按钮连接到 WPF 命令库的命令。

工具栏本身值得一提。我决定不使用 WPF 工具栏,因为它支持拖动和溢出按钮等功能,需要大量的视觉和逻辑开销。这些功能在此特定工具栏的上下文中没有意义,因此我使用了一个 StackPanel 来模拟工具栏。这种方法的缺点是按钮会失去“工具栏外观”(它们在 StackPanel 中显示为标准的银色按钮),并且“工具栏”失去了WPF 工具栏用于创建分隔符的标签。

控件通过创建简单的按钮控件模板,将格式按钮的样式设置为看起来像工具栏按钮,从而恢复了格式按钮的工具栏外观。控件模板位于用户控件 XAML 的部分。

<ControlTemplate x:Key="FlatButtonControlTemplate" TargetType="{x:Type Button}">
    <Border x:Name="OuterBorder" BorderBrush="Transparent" 
	BorderThickness="1" CornerRadius="2">
        <Border x:Name="InnerBorder" Background="Transparent" 
	BorderBrush="Transparent" BorderThickness="1" CornerRadius="2">
            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" 
		RecognizesAccessKey="True" Margin="{TemplateBinding Padding}" />
        </Border>
    </Border>
    <ControlTemplate.Triggers>
        <Trigger Property="IsMouseOver" Value="True">
            <Setter TargetName="OuterBorder" Property="BorderBrush" Value="#FF7CA0CC" />
            <Setter TargetName="InnerBorder" Property="BorderBrush" Value="#FFE4EFFD" />
            <Setter TargetName="InnerBorder" Property="Background" Value="#FFDAE7F5" />
        </Trigger>
        <Trigger Property="IsPressed" Value="True">
            <Setter TargetName="OuterBorder" Property="BorderBrush" Value="#FF2E4E76" />
            <Setter TargetName="InnerBorder" Property="BorderBrush" Value="#FF116EE4" />
            <Setter TargetName="InnerBorder" Property="Background" Value="#FF3272B8" />
        </Trigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

控件通过一个简单的图像来恢复分隔符功能,该图像复制了分隔符的外观。结果是一个轻量级的工具栏,只做它需要做的事情。

如上所述,宿主应用程序通过调用控件的 UpdateDocumentBindings() 方法强制更新 FS RTB 的 Document 属性。该方法如下所示:

/// <summary>
/// Forces an update of the Document property.
/// </summary>
public void UpdateDocumentBindings()
{
    // Exit if text hasn't changed
    if (!m_TextHasChanged) return;

    // Set 'Internal Update Pending' flag
    m_InternalUpdatePending = 2;

    // Set Document property
    SetValue(DocumentProperty, this.TextBox.Document); 
}

该方法首先检查 RTB 中的文本是否已实际更改。如果用户只是查看了文本,我们则无需更新属性,并且可以避免往返数据库。由于控件执行此检查,因此宿主应用程序可以在可能导致编辑过的文本丢失的任何操作时调用该方法,而不必担心用户是否实际编辑了文本。接下来,该方法设置一个 InternalUpdatePending 标志,下面将对此进行讨论。最后,该方法将 WPF RTB 的内容复制到 FS RTB 控件的 Document 属性。从那里,WPF 数据绑定接管。

FS RTB 控件的核心是添加到 FsRichTextBox.xaml.csDocument 依赖属性。

// Document property
public static readonly DependencyProperty DocumentProperty = 
    DependencyProperty.Register("Document", typeof(FlowDocument), 
    typeof(FsRichTextBox), new PropertyMetadata(OnDocumentChanged));

Document 属性使用一个 PropertyChanged 回调方法 OnDocumentChanged()。此方法确定属性更改是来自控件(因为应用程序触发了绑定更新),还是来自视图模型。如果更改来自视图模型,则该方法将更改传递给控件中的 WPF RTB。但是,如果更改来自控件,则该方法不做任何操作——请记住,属性已经更改。

OnDocumentChanged() 方法使用 InternalUpdatePending 标志来确定更改的来源。回想一下,该标志是在宿主应用程序(或用户)触发更新时由 UpdateDocumentBindings() 方法设置的。请注意,该标志是整数类型,而不是布尔类型——稍后将详细介绍。OnDocumentChanged() 方法检查此标志,如果已设置,则除了递减该标志外,不做任何操作。

/// <summary>
/// Called when the Document property is changed
/// </summary>
private static void OnDocumentChanged
	(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    /* For unknown reasons, this method gets called twice when the 
     * Document property is set. Until I figure out why, I initialize
     * the flag to 2 and decrement it each time through this method. */

    // Initialize
    var thisControl = (FsRichTextBox)d;

    // Exit if this update was internally generated
    if (thisControl.m_InternalUpdatePending > 0)
    {

        // Decrement flag and exit
        thisControl.m_InternalUpdatePending--;
        return;
    }

    // Set Document property on RichTextBox
    thisControl.TextBox.Document = (e.NewValue == null)? 
        new FlowDocument(): 
        (FlowDocument)e.NewValue;

    // Reset the TextChanged flag
    thisControl.m_TextHasChanged = false;
}

关于 OnDocumentChanged() 方法有一个异常。出于某种原因,当 FS RTB 的 Document 属性更改时,该方法会被调用两次。坦白说,我还没有找到原因,因此我采用了 hack 的方法来解决这个问题。InternalUpdatePending 标志被创建为一个整数变量,而不是 Boolean,并且被初始化为 2。每次通过 OnDocumentChanged() 方法时,标志的值都会递减,结果是它导致控件在通过方法进行两次传递时都不做任何操作,并在最后一次传递时被清除。

如果读者能告诉我为什么 OnDocumentChanged() 方法会被调用两次,我将非常感激。我将用一个标准的布尔标志替换这个 hack,并将在文章更新中为你提供功劳。在此期间,这个 hack 不会影响控件的性能或输出。虽然不好看,但它有效。

演示应用程序中的 MVVM

演示应用程序使用 MVVM 模式,以便您可以看到 FS RTB 控件如何在 MVVM 中集成。MVVM 模式的优势之一在于其灵活性;开发人员可以通过多种方式实现该模式,所有这些方式都可以被认为是良好的实践。考虑到这一点,关于我实现 MVVM 的一两句话可能会使演示应用程序更容易理解。但您也可以轻松跳过本节,除非您在理解我如何构建演示应用程序时遇到问题。

image002.gif

我使用一种 View-first 方法的变体来实现 MVVM。我通过多个类来实现视图模型。主类当然是 ViewModel 类,在演示应用程序中是 MainWindowViewModel.cs。我在第三个类中将这个类设置为视图(在演示应用程序中是MainWindow.xaml)的 DataContext,该类实例化了 View ViewModel 类。在演示应用程序中,我在 App.xaml.cs 中通过重写 OnStartup() 方法来执行此步骤。请注意,我还从 App.xaml 中的 < Application> 标记中删除了 StartupUri 属性。

因此,在我的实现中,View ViewModel 都不会实例化另一个。我使用这种第三方类方法来降低 View ViewModel 类之间的耦合度。

View ViewModel 之间不可避免地存在一些依赖关系,我通常会维护这些依赖关系,使它们从 View 指向 ViewModel。换句话说,View 知道 ViewModel,但反之则不然。以相反的方向运行依赖关系,就像在 PresentationModel 模式中所做的那样,当然是良好的实践;这只是我喜欢的 MVVM 风格。无论如何,依赖关系的方向对演示应用程序的影响很小,甚至没有影响。

我将视图中的按钮和其他命令控件绑定到 ViewModel 类中的 ICommand 属性。这些属性绑定到我存储在 ViewModel 文件夹中的 ICommand 类。我的命令的 Execute() 方法本身执行简单的操作,并将更复杂的操作委托给应用程序业务层中的服务类。在演示应用程序中,有一个命令 LoadDocument。它的 Execute() 操作很简单,所以它直接实现了。

为了保持简单,我将 Force Update 按钮直接连接到 MainWindow 代码隐藏中的事件处理程序。这不是好的 MVVM 实践,在生产应用程序中我不会这样做。由于这是一个简单的演示,我认为这并不重要。

如果您正在学习 MVVM,您可能会发现我的文章 MVVM and the WPF DataGrid 有所帮助。我解释了我使用的 MVVM 方法以及如何使用它来构建一个简单的应用程序。

结论

一如既往,我感谢阅读这些文章的读者的评论。我希望您觉得 FS RTB 有用。我计划不时更新本文,以纳入读者提出的任何建议,当然,我会在更新中注明这些建议的功劳。

历史

  • 2010 年 3 月 16 日:初始版本
  • 2010 年 3 月 17 日:添加了 VS2008 版本的代码
  • 2010 年 8 月 12 日:文章更新
© . All rights reserved.