WPF MVVM 内联编辑 TextBox 控件






4.96/5 (17投票s)
通过文本框叠加重命名项目,
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
本文附带的演示项目包含 FileListViewTest 和 TestFolderBrowser 项目中的两个演示应用程序。最好将其中一个项目设置为启动项目,然后在查看其他任何内容之前在 Visual Studio 中执行它。
请确保双击文件夹浏览器(视图或对话框)中的文件夹,或使用上下文菜单执行演示本文 EditBox 控件的 重命名 和 新建文件夹 命令。
图 1 中的艺术图展示了本文讨论的概念。该概念是,ItemsControl
(TreeView
、ListBox
等的基础)中的每个项都可以显示一个在 InplaceEditBoxLib.Views.EditBox 中定义的自定义控件,而不是使用 Label
或 TextBlock
控件。
可以通过以下 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
,而在 **编辑** 模式下仅显示一个带有 TextBox
的Adorner。编辑模式还支持一个测量 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
中的 OnSwitchToEditingMode
和 OnSwitchToNormalMode
方法控制。第一个方法建立编辑模式,第二个方法在编辑模式结束时调用。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]。我只在 BuildTextBox
、MeasureOverride
和 ArrangeOverride
方法中更改了一些细节,以便在装饰器可见时与测量文本块实现绑定和测量。
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 来轻松实现。然后,引发事件就如同调用提供的 RequestEditMode
或 ShowNotification
方法一样简单(例如,请参阅 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.IEditBox
与 EditBox.cs.xaml
和 EditBoxAdorner.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 的双击。
MinimumClickTime 和 MaximumClickTime 的默认值分别为 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>
已知限制
- 在选定的
Treeview
或Listbox
项上按 F2 键不会启动编辑模式。 - 单击
ItemsControl
(TreeView、ListView 等)的背景不会取消编辑模式。 - 通过超链接重新样式化 TextBox 不起作用,因为超链接存储在 TextBox 的
InlineCollection
中。但是InlineCollection
不能通过依赖属性设置,而且我似乎无法通过自定义依赖属性来解决这个问题。 - 就地文本框中输入的按键定义无法通过白名单定义。文本框不支持输入掩码。
关注点
我更有信心地学会了使用 Adorner。我也了解到 Adorner 只能出现在其宿主控件窗口的实际区域内(Adorner 绘制在其控件的 AdornerLayer 上(AdornerLayer.GetAdornerLayer()),其位置取决于 AdornerLayer 的定义位置。但你能定义的最顶层控件是 Window。因此,使用弹出消息控件而不是另一个 Adorner 来显示错误通知更为合适。
弹出通知的实现源于一个先前开发的项目,该项目在我需要它时派上了用场。使用视图和 ViewModel 之间的接口定义将事件从 ViewModel 转发到视图是我从中获得的一个技巧和模式。
我有一种挥之不去的感觉,即使用事件来实现更稳定的实现,而不是使用 IsEditing
依赖属性来切换编辑模式。此实现的稳定性证明我是正确的。
ContentControl
模式通过 Adorner 扩展可用控件的功能对我来说是新颖的。这种方法的优点在于,在需要状态和绑定时可以轻松使用它。如果请求的扩展足够简单可以实现,也可以考虑附加行为。
参考文献
- [1] 此代码使用了 ATC Avalon Team 的部分工作
http://blogs.msdn.com/atc_avalon_team/archive/2006/03/14/550934.aspx
- [2] CodeProject 文章“WPF 中的可编辑 TextBlock,用于就地编辑”
https://codeproject.org.cn/Articles/31592/Editable-TextBlock-in-WPF-for-In-place-Editing?fid=1532208%56df=90%56mpp=25%56noise=3%56prof=False%56sort=Position%56view=Normal%56spc=Relaxed%56fr=26#xx0xx
- [3] WPF 文件列表视图和组合框(第二版)
http://fsc.codeplex.com/
https://codeproject.org.cn/Articles/760603/A-WPF-File-ListView-and-ComboBox-Version-II
- [4] 通过弹出控件进行用户通知
http://msgbox.codeplex.com/wikipage?title=User%20Notification%20Demo%56referringTitle=Documentation
历史
2014-07-30 创建了本文的第一个版本