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

WPF MVVM 内联编辑 TextBox 控件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (17投票s)

2014 年 7 月 30 日

CPOL

13分钟阅读

viewsIcon

60259

downloadIcon

1880

通过文本框叠加重命名项目, 类似于 Windows 资源管理器的重命名方式

InplaceEditBoxLib 已迁移到 GitHub,地址:Dirkster99/InplaceEditBoxLib

项目已迁移到 CodePlex: http://fsc.codeplex.com/documentation 并即将迁移至 GitHub 进行进一步开发; https://github.com/Dirkster99

 


图 1 展示了一个覆盖在树状视图控件项上的叠加文本框装饰器(“新光”)的艺术视图。另一个框(“无效字符输入”)示意了一个在用户按下不受支持的按键时显示的弹出控件。

引言

开发一个资源管理器工具窗口,该窗口将 Windows 文件系统集成到编辑器中,这要求我集成一个文本框覆盖控件,以便像 Windows 资源管理器那样重命名文件和文件夹。我未能找到一个令人满意且完整的 WPF/MVVM 实现——只有一些零散的草图[1][2]——所以我开发了自己的版本,并在本文档中记录下来,希望它能对他人有所帮助。

本文分为两个部分 - **使用代码** 和 **功能**。第一部分从技术角度描述了该控件,第二部分列出了此实现的系统要求。

解决方案概览

本文附带的演示项目实际上可以重命名文件和文件夹。 该实现非常稳定,但您在使用文件系统中的文件夹和文件进行测试时应格外小心。此处描述的实际控件是 **InplaceEditBoxLib/Views/EditBox.*** 控件,可以在可下载的源代码的其文件夹中找到。

您可以看到,该解决方案除了 **InplaceEditBoxLib** 项目外,还包含其他 5 个项目。这些项目基于另一个项目和文章,可在此处找到:WPF 文件列表视图和组合框(第二版)。本文的重点仅在于 EditBox 控件,但您当然也随时欢迎就这些部分提出问题。

Using the Code

本文附带的演示项目包含 FileListViewTestTestFolderBrowser 项目中的两个演示应用程序。最好将其中一个项目设置为启动项目,然后在查看其他任何内容之前在 Visual Studio 中执行它。

请确保双击文件夹浏览器(视图或对话框)中的文件夹,或使用上下文菜单执行演示本文 EditBox 控件的 重命名新建文件夹 命令。

图 1 中的艺术图展示了本文讨论的概念。该概念是,ItemsControlTreeViewListBox 等的基础)中的每个项都可以显示一个在 InplaceEditBoxLib.Views.EditBox 中定义的自定义控件,而不是使用 LabelTextBlock 控件。

可以通过以下 XAML 代码验证此概念,用于 TreeView 定义(基于“FolderBrowser/FolderBrowser/Views/FolderBrowserView.xaml”)

<HierarchicalDataTemplate ItemsSource="{Binding Folders}">
  <StackPanel Orientation="Horizontal">
    <Image ... </Image>

    <EditInPlace:EditBox
    Text="{Binding Path=FolderName, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
    DisplayText="{Binding DisplayItemString, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
    ToolTip="{Binding FolderPath, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
    Focusable="True"
                                   
    VerticalAlignment="Stretch"
    HorizontalAlignment="Left"
    IsReadOnly="{Binding IsReadOnly}"
    RenameCommand="{Binding Path=Data.RenameCommand, Source={StaticResource DataContextProxy}}"
    ToolTipService.ShowOnDisabled="True"

    InvalidInputCharacters="{x:Static loc:Strings.ForbiddenRenameKeys}"
    InvalidInputCharactersErrorMessage="{x:Static loc:Strings.ForbiddenRenameKeysMessage}"
    InvalidInputCharactersErrorMessageTitle="{x:Static loc:Strings.ForbiddenRenameKeysTitle}"

    Margin="2,0" />
  </StackPanel>
</HierarchicalDataTemplate>

...或 Listbox XAML 定义(基于“FileListViewTest/FileListItemView.xaml”)

<ListBox.ItemTemplate>
  <DataTemplate>
    <Grid>
      <Grid.ColumnDefinitions>...</Grid.ColumnDefinitions>
  
      <Image > ... </Image>
  
      <EditInPlace:EditBox Grid.Column="1"
      Text="{Binding DisplayName}"
      DisplayText="{Binding DisplayName}"
      RenameCommand="{Binding Path=Data.RenameCommand, Source={StaticResource DataContextProxy}}"
      ToolTipService.ShowOnDisabled="True"
                         
      InvalidInputCharacters="{x:Static fvloc:Strings.ForbiddenRenameKeys}"
      InvalidInputCharactersErrorMessage="{x:Static fvloc:Strings.ForbiddenRenameKeysMessage}"
      InvalidInputCharactersErrorMessageTitle="{x:Static fvloc:Strings.ForbiddenRenameKeysTitle}"
  
      IsEditableOnDoubleClick ="False"/>
    </Grid>
  </DataTemplate>
</ListBox.ItemTemplate>

请注意上面 XAML 定义中的 RenameCommand。当用户成功编辑完项文本并按 Enter 键确认编辑时,将调用此命令。

如果您有 WPF 开发经验,那么上面的 XAML 代码并不算特别新颖。当然,模板化 ItemsControl 的每个项是一个强大的功能,特别是当它还可以考虑对象的实际属性值或类型时。但是,本文所述的 EditBox 控件仅利用了此 WPF 功能,而本文的重点是另一个名为Adorner 的 WPF 功能。

EditBox 控件在 **非编辑** 模式下显示一个 TextBlock,而在 **编辑** 模式下仅显示一个带有 TextBoxAdorner。编辑模式还支持一个测量 TextBlock 控件,该控件在上面的图中并未显示,因为它永远不可见。EditBox 控件的 XAML 定义如下(基于 InplaceEditBoxLib/Views/EditBox.xaml)

<Style TargetType="{x:Type local:EditBox}" >
  <Setter Property="HorizontalAlignment" Value="Left"  />
  <Setter Property="VerticalAlignment"   Value="Center" />
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type local:EditBox}">
        <Grid>
          <TextBlock x:Name="PART_TextBlock" MinWidth="10"
                     HorizontalAlignment="Left"
                     VerticalAlignment="Center"
                   />

          <TextBlock x:Name="PART_MeasureTextBlock" MinWidth="10"
                     HorizontalAlignment="Left"
                     VerticalAlignment="Center"
                     Visibility="Hidden"
                   />
        </Grid>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

测量 TextBlock 用于在用户输入字符串时测量字符串的大小。该控件的此部分永远不可见,但它是控件定义的一部分,以便应用程序开发人员有机会使用正确的测量参数(例如:字体大小、字体系列等)或仅使用默认值。

EditBox 控件的视图部分主要在以下位置实现:

  • InplaceEditBoxLib.Views.EditBox
  • InplaceEditBoxLib.Views.EditBoxAdorner

EditBoxAdorner 的可见性由 EditBox.cs.xaml 中的 OnSwitchToEditingModeOnSwitchToNormalMode 方法控制。第一个方法建立编辑模式,第二个方法在编辑模式结束时调用。OnSwitchToNormalMode 方法还会调用绑定的命令(如果有),以便底层 ViewModel/Model 负责文件系统中的实际重命名过程。

if (bCancelEdit == false)
{
  if (this.mTextBox != null)
  {
    // Tell the ViewModel (if any) that we'd like to rename this item
    if (this.RenameCommand != null)
    {
        var tuple = new Tuple<string, object>(sNewName, this.DataContext);
        this.RenameCommand.Execute(tuple);
    }
  }
}

EditBoxAdorner 基本上是 ATC 团队最初建议的装饰器[1]。我只在 BuildTextBoxMeasureOverrideArrangeOverride 方法中更改了一些细节,以便在装饰器可见时与测量文本块实现绑定和测量。

ViewModel 详细信息

EditBox 控件支持一些依赖属性,这些属性将在后面的功能部分列出。但同样重要的是要注意,它期望 ViewModel 实现以下接口:

  • IEditBox
  • INotifyableViewModel(由 IEditBox 实现)

...在其 DataContext 中(请参阅 InplaceEditBoxLib/Interfaces/IEditBox.cs 和 InplaceEditBoxLib/Views/EditBox.xaml.cs 中的 OnDataContextChanged 方法)。

IEditBox 接口实现了 2 个事件:ShowNotificationMessage 事件用于在应显示错误消息时显示弹出通知[4],以及 RequestEdit 事件用于在 ViewModel 请求编辑时启动编辑。这两个事件都可以通过继承项的 ViewModel 自 InplaceEditBoxLib/ViewModels/EditInPlaceViewModel.cs 来轻松实现。然后,引发事件就如同调用提供的 RequestEditModeShowNotification 方法一样简单(例如,请参阅 FolderBrowser/FolderBrowser/ViewModels/FolderViewModel.cs 中 RenameFolder 方法的 catch 块)。

try
{
  ...
}
catch (Exception exp)
{
  base.ShowNotification(FileSystemModels.Local.Strings.STR_RenameFolderErrorTitle, exp.Message);
}

当 MVVM 设计到代码中时,显示一个漂亮的通知几乎再简单不过了。很明显,分层模型(模型、ViewModel、视图)确保我们可以在许多其他场景中重用此控件,在这些场景中,ItemsControl 中的项应使用 WPF 就地编辑文本框控件进行编辑。

摘要

因此,总结技术部分。请查看相关的 ViewModel,以了解 EditBox 是如何从那里驱动的。这可以通过使用 Visual Studio 查找所有继承自 InplaceEditBoxLib.ViewModels.EditInPlaceViewModel 类的 ViewModel 来完成。接下来,您可以搜索对此 ViewModel 中方法的调用,以揭示每个具体 ViewModel 实现中的处理逻辑。

InplaceEditBoxLib.ViewModels.EditInPlaceViewModel 中的处理逻辑通过 InplaceEditBoxLib.Interfaces.IEditBoxEditBox.cs.xamlEditBoxAdorner.cs 中的视图紧密耦合。

特点

本文所述的就地编辑文本控件可以用作开发应用程序的基础,在这些应用程序中,用户希望在正常显示的字符串之上以覆盖层的形式编辑文本字符串。

就地编辑文本控件中最常见且最知名的例子是 Windows 资源管理器中用于重命名文件或文件夹的文本框覆盖。用户通常在列表(列表框、列表视图、网格)或项结构(树状视图)中选择一个项,并使用文本框覆盖(无需额外对话框)重命名该项。

此项目中的就地编辑控件可以在 WPF 的任何 **ItemsControl**(Treeview、ListBox、ListView 等)的集合中使用。焦点改变(激活不同的窗口)或按 Esc 键将取消重命名过程,而按 Enter 键将确认新字符串。本节描述了该控件的功能,以便应用程序开发人员/用户能够重用/使用本文描述的控件。

本节末尾的 **已知限制** 部分描述了我尚未实现的功能。请仔细阅读,如果您能解决任何列出的限制,请贡献您的解决方案。

使用上下文菜单开始编辑文本

EditBox 控件可以通过其自身的 UI(通过双击)或通过“外部”可访问性(上下文菜单或菜单)开始编辑文本。

就地编辑控件期望 ViewModel 实现 InplaceEditBoxLib.Interfaces.IEditBox 接口,该接口包含一个 RequestEdit 事件。ViewModel 可以触发此事件来开始编辑一个项。然后,可以使用 IsEditing 依赖属性来确定编辑模式当前是否处于活动状态。

可以通过不同的途径开始编辑,但应用程序应以任一方式显示覆盖文本框控件,并且该文本框应包含所有当前选中的文本。此状态称为 **编辑模式**。

使用 Text 和 DisplayText 属性编辑文本

EditBox 控件有两个字符串属性:一个用于显示(**DisplayText**),另一个(**Text**)字符串表示应编辑的值。

这种设置使应用程序开发人员能够显示比名称更多的内容。例如,每个项可以使用 DisplayText 属性显示名称和数字(例如:“Myfiles (123 entries)”),而 Text 属性应包含要编辑的字符串(例如:“Myfiles”)。

编辑的确认(按 Enter 键)不会更改上述任一依赖属性。就地编辑控件而是执行绑定到 RenameCommand 依赖属性的命令,以让 ViewModel 调整所有相关字符串。

视图为此命令生成一个命令参数(不可配置)。该参数是新字符串和位于就地编辑控件 DataContext 中的 ViewModel 实例的 Tuple

利用有限空间

EditBox 就地覆盖控件不应超过 ItemsControl 的父滚动视图的视口区域。也就是说,如果 EditBox 在树状视图中使用,则不应超过树状视图的可见区域。此规则确保用户在键入长字符串时不会在不可见区域(屏幕外)进行输入。

以下一系列图像显示了当用户在有限空间场景中输入字符串“The quick fox jumps over the river”时的应用程序行为

取消和确认

通过按 Esc 键或将输入焦点更改为其他窗口或控件,可以取消使用就地编辑控件编辑文本。应用程序将显示编辑开始之前的文本。

通过按 Enter 键可以确认编辑文本。应用程序将显示输入的文本,而不是编辑开始之前的文本。

IsReadOnly 属性

就地编辑控件支持一个布尔型 IsReadonly 依赖属性,用于锁定单个项不被重命名。默认值为 **false**,表示每个项都可以编辑,除非绑定定义了其他内容。

IsEditableOnDoubleClick

使用定时“双击”可以触发编辑显示在就地编辑控件中的字符串。此双击可以配置为在特定时间范围内发生。有两个双依赖属性可以设置为仅接受时间范围大于 MinimumClickTime 但小于 MaximumClickTime 的双击。

MinimumClickTimeMaximumClickTime 的默认值分别为 300 毫秒和 700 毫秒。

IsEditableOnDoubleClick 布尔依赖属性可以设置为确定是否评估双击以进行编辑。默认为 true。

IsEditing 属性

就地编辑控件支持一个 **单向** 布尔型 IsEditing 依赖属性,用于使 ViewModel 能够确定项当前是否正在被编辑。ViewModel 不能使用此属性强制视图进入可编辑模式(因为它在视图中是只读属性)。

使用 InplaceEditBoxLib.Interfaces.IEditBox 中定义的 RequestEdit 事件来请求一个由 ViewModel 初始化但由视图管理的编辑模式,并使用 IsEditing 属性在 ViewModel 中验证当前编辑模式。

按键过滤和错误处理

EditBox 控件包含可用于定义用户不应输入的字符黑名单的属性。请参阅属性:

  • InvalidInputCharacters
    使用此属性指定用户无法输入的实际字符集。
     
  • InvalidInputCharactersMessage
    使用此属性设置当用户按下不支持的按键时显示的 **消息** 字符串。
     
  • InvalidInputCharactersTitle
    使用此属性设置当用户按下不支持的按键时显示的 **标题** 字符串。
     

EditBox 控件实现了一个弹出消息元素,用于在用户键入无效字符时向其显示提示。

因此,ViewModel 中的每个项都可以通过项 ViewModel 中实现的 IEditBox 接口拥有自己的弹出通知和黑名单。但是,当还没有项显示时(例如,在没有创建文件夹权限的空文件夹中创建新文件夹时),如何显示通知?这种情况由 NotifableContentControl [4] 的实现来处理。该控件基于 ContentControl,因此可以将其包装在任何其他控件周围。该控件实现了 ShowNotification 事件,可用于在任何其他控件的上下文中显示通知。

有关更多详细信息,请参见 FileListViewTest/BrowserAndFileListView.xaml

<view:NotifyableContentControl
  DataContext="{Binding SynchronizedFolderView.FolderItemsView}"
  Notification="{Binding Notification}" Margin="0,26,0,2" Grid.RowSpan="2">

  <ListBox ... >/ListBox>

</view:NotifyableContentControl>

已知限制

  • 在选定的 TreeviewListbox 项上按 F2 键不会启动编辑模式。
  • 单击 ItemsControl(TreeView、ListView 等)的背景不会取消编辑模式。
  • 通过超链接重新样式化 TextBox 不起作用,因为超链接存储在 TextBox 的 InlineCollection 中。但是 InlineCollection 不能通过依赖属性设置,而且我似乎无法通过自定义依赖属性来解决这个问题。
  • 就地文本框中输入的按键定义无法通过白名单定义。文本框不支持输入掩码。

关注点

我更有信心地学会了使用 Adorner。我也了解到 Adorner 只能出现在其宿主控件窗口的实际区域内(Adorner 绘制在其控件的 AdornerLayer 上(AdornerLayer.GetAdornerLayer()),其位置取决于 AdornerLayer 的定义位置。但你能定义的最顶层控件是 Window。因此,使用弹出消息控件而不是另一个 Adorner 来显示错误通知更为合适。

弹出通知的实现源于一个先前开发的项目,该项目在我需要它时派上了用场。使用视图和 ViewModel 之间的接口定义将事件从 ViewModel 转发到视图是我从中获得的一个技巧和模式。

我有一种挥之不去的感觉,即使用事件来实现更稳定的实现,而不是使用 IsEditing 依赖属性来切换编辑模式。此实现的稳定性证明我是正确的。

ContentControl 模式通过 Adorner 扩展可用控件的功能对我来说是新颖的。这种方法的优点在于,在需要状态和绑定时可以轻松使用它。如果请求的扩展足够简单可以实现,也可以考虑附加行为。

参考文献

历史

2014-07-30 创建了本文的第一个版本

© . All rights reserved.