抱歉了各位,让你们久等了,现在我对WPF的理解更深了一步,那么,话不多说,动手开整。
这次的的内容在上一章的基础上讲解,本章要实现TreeView的增删改查,既然想实现增删改查,那就需要重定义数据结构了,目前我在项目上碰到关于树的结构就两种,一种是递归形式的结构,一种是每个节点结构不同的非递归结构。
先创建一个如图所示的项目,注意别创建错了,一个是启动项,一个是类库
我就举个例子演示TreeView增删改查,不如,我想实现一个目录树,这个目录树只需要显示节点的名字就行,知道需求后,先创建一个基类,用于通知前端刷新,我取名为BindableObject,
它继承一个系统提供的通知改变的接口,看图
可能有人会不理解BindableObject里面的代码,不急,最后补充的地方我会解释,你先这样理解:这是一个基类,我定义了一个字段(接口实现的)和一个方法(自定义的),以后就直接调用方法就能实现双向绑定。
有了通知类后,现在就按照需求定义一个关于目录树结构的对象,如图
让TreeModel继承通知这个基类,调用方法就能让该属性双向绑定了,因为是递归结构,所有还差一个属性(子节点)
public class TreeModel : BindableObject
{
private string name;
/// <summary>
/// 节点名称
/// </summary>
public string Name
{
get => name;
set
{
name = value;
OnPropertyChanged();
}
}
public ObservableCollection<TreeModel> Children { get; set; } = new ObservableCollection<TreeModel>();
}
关于它的介绍,上篇文章有说明。
现在有了结构,但还缺少前端展示,那就创建如图所示的viewmodel和view
接着修改App里面的启动页面
运行测试下,看看有没有问题 ,能弹窗空白页面,那我们进行往下走。
既然要实现目录树,那就先开始前端界面的布局,我这里的思路是把页面用grid布局,使页面
分成两行,上面一行是用StackPanel布局的按钮,下面一行是目录树
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition />
</Grid.RowDefinitions>
<StackPanel/>
<TreeView/>
</Grid>
简单创建一个数据源,生成一个有内容的目录树
public class TreeViewModel : BindableObject
{
#region 属性
/// <summary>
/// 目录树
/// </summary>
public ObservableCollection<TreeModel> TreeModels { get; set; } = new ObservableCollection<TreeModel>();
public TreeViewModel()
{
InitTree();
}
/// <summary>
/// 初始化目录树
/// </summary>
private void InitTree()
{
TreeModels.Add(new TreeModel()
{
Name = "我是根节点",
});
}
}
把数据源绑定到前端运行一下,看看能否渲染出来 ,可以就继续。
<TreeView
x:Name="treeView"
Grid.Row="1"
ItemsSource="{Binding TreeModels}">
</i:Interaction.Triggers>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<Grid>
<TextBlock Text="{Binding Name}" />
</Grid>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
基本的容写好后,开始实现第一个功能,增加节点,怎么增加呢?怎么把触发的事件绑定到viewmodel呢?官方提供了一个接口ICommand,通过它,就能把viewmodel定义的命令绑定到前端,如图所示
public class DelegateCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool> _canExecute;
public DelegateCommand(Action execute, Func<bool> canExecute = null)
{
if (execute == null)
throw new ArgumentNullException(nameof(execute));
_execute = execute;
_canExecute = canExecute;
}
public virtual event EventHandler CanExecuteChanged;
public virtual bool CanExecute(object parameter = null)
{
return _canExecute == null || _canExecute();
}
public virtual void Execute(object parameter = null)
{
_execute();
}
}
它还有个泛型,用于有参命令的实现
public class DelegateCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Func<T, bool> _canExecute;
public DelegateCommand(Action<T> execute, Func<T, bool> canExecute = null)
{
if (execute == null)
throw new ArgumentNullException(nameof(execute));
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler CanExecuteChanged;
public bool CanExecute(object parameter)
{
return _canExecute == null || _canExecute((T)parameter);
}
public void Execute(object parameter)
{
_execute((T)parameter);
}
}
这里也许也会有人迷惑,不急,这个我也放到补充地方说明,你可以这样理解,这个类它是一个管理所有命名的管理员,通过它,可以把前端产生的事件传到viewmodel,调用到指定命令下所调用的方法。
紧接着,绑定view的DataContext,
this.DataContext = new TreeViewModel();
在viewmodel创建一个命令,
在构造函数中实例化它,生成一个名为AddRoot的方法,
/// <summary>
/// 添加根节点
/// </summary>
/// <param name="obj"></param>
/// <exception cref="NotImplementedException"></exception>
private void AddRoot()
{
TreeModels.Add(new TreeModel()
{
Name = "我是根节点",
}); ;
}
在前端定义一个按钮,绑定命令,如图所示
<Button
Margin="5"
Command="{Binding DataContext.AddRootNode, RelativeSource={RelativeSource AncestorType=Window, Mode=FindAncestor}}"
Content="添加根节点" />
这里的绑定可能会有人不理解,没事,补充地方见,运行测试,如图所示,点击按钮后,会增加一个节点
现在通过按钮是可以创建节点了,那我想要右键创建呢?我目前知道的有三种方法,这里我用通俗易懂的方法来讲解如何实现右键功能,由于现在treemodel只有一个属性,不好通过名字来显示右键对应的节点内容,比如在项目上,一般每个节点实现的右键功能不同,第一级节点有创建和删除,最后一级只有删除,没有创建,那怎么区分它是第一级还是最后一级呢?毫无疑问,可以通过添加一个属性,来区分每个节点的不同类型,所以我创建一个枚举。如图
public enum TreeNodeType
{
[Description("根节点")]
RootNode,
[Description("子节点")]
ChildNode
}
我的这个结构只有一级和无穷级,哈哈哈哈,所以每个节点的右键内容都是一样的,在treemodel中添加一个属性
private TreeNodeType nodeType;
/// <summary>
/// 节点类型
/// </summary>
public TreeNodeType NodeType
{
get => nodeType;
set
{
nodeType = value;
OnPropertyChanged();
}
}
右键添加的解决了,那右键删除呢?我怎么判断我右键的节点在目录数中是属于谁的,所以咱们还需要一个用于记录这个节点属性谁的属性——父节点信息,那就再添加一个属性吧,如图
public TreeModel? ParentNode { get; set; }
这里有个坑,我不知道会有多少人踩,咱们补充见,哈哈哈哈。
剩下最后一个功能的突破了,肯定有人会问,查这个功能呢?关于查这个概念,有两种理解,第一种是检索目录树(目录树很庞大的情况),还有一种是查看节点的所有信息,比如一个人的基本信息:身高、体重、性别等等,这里我就先理解为第二种,如果有人理解为第一种,给我留言,我后面扩展下,然后给你们讲解,如果是理解为第二种,那么我给你们留了一个思考题(看文字最后)。
言归正传由于我定义的结构简单,目录树在前端的有效信息只有name,也就没必要查了。剩下的改,怎么改呢?默认的名字要怎么改呢?目前我会两种,我还是以通俗易懂的方法来讲解,至于另外一种,感兴趣的伙伴可以给我留言,我教你。
既然要改,少不了输入框textbox,还记得咱们目录树的treeviewitem的结构吗?一个grid包着一个textblock,如果我再添加一个textbox,绑定同样的属性,会怎么样呢?肯定会乱成一锅粥呀,那如果我对textbox和textblock添加一个Visibility的属性,是不是从前端看是一个,实际上是有两个都在显示,看图
<Grid>
<TextBox
x:Name="textBox"
Text="{Binding Name}"
Visibility="Collapsed">
</TextBox>
<TextBlock Text="{Binding Name}" Visibility="Visible" />
</Grid>
因此只有我们让textbox和textblock合理的显示和隐藏,就能实现改的功能了,那么,我们在去扩展两个属性吧,用来控制textbox和textblock的显示和隐藏,如图
private Visibility isTextBoxVisibility = Visibility.Collapsed;
/// <summary>
/// 是否显示TextBox
/// </summary>
[JsonIgnore]
public Visibility IsTextBoxVisibility
{
get => isTextBoxVisibility;
set
{
isTextBoxVisibility = value;
OnPropertyChanged();
}
}
private Visibility isTextBlockVisibility = Visibility.Visible;
/// <summary>
/// 是否显示TextBlock
/// </summary>
[JsonIgnore]
public Visibility IsTextBlockVisibility
{
get => isTextBlockVisibility;
set
{
isTextBlockVisibility = value;
OnPropertyChanged();
}
}
功能难点都突破了,那开始实现逻辑吧
依次定义增删改的命令和方法
/// <summary>
/// 添加子节点
/// </summary>
/// <param name="obj"></param>
private void AddChild(TreeModel obj)
{
obj.Children.Add(new TreeModel()
{
Name = $"{obj.Name}的孩子",
NodeType = Common.Enums.TreeNodeType.ChildNode,
ParentNode = obj
});
}
/// <summary>
/// 删除节点
/// </summary>
/// <param name="obj"></param>
private void Delete(TreeModel obj)
{
if (obj.NodeType == Common.Enums.TreeNodeType.RootNode)
{
TreeModels.Remove(obj);
return;
}
obj.ParentNode.Children.Remove(obj);
}
/// <summary>
/// 重命名节点
/// </summary>
/// <param name="obj"></param>
/// <exception cref="NotImplementedException"></exception>
private void ReName(TreeModel obj)
{
obj.IsTextBoxVisibility = Visibility.Visible;
obj.IsTextBlockVisibility = Visibility.Collapsed;
}
前端绑定右键菜单
<HierarchicalDataTemplate.Triggers>
<DataTrigger Binding="{Binding NodeType}" Value="RootNode">
<Setter Property="ContextMenu" Value="{StaticResource root}" />
</DataTrigger>
<DataTrigger Binding="{Binding NodeType}" Value="ChildNode">
<Setter Property="ContextMenu" Value="{StaticResource child}" />
</DataTrigger>
</HierarchicalDataTemplate.Triggers>
<Window.Resources>
<ContextMenu x:Key="root">
<MenuItem
Command="{Binding DataContext.AddChildNode, RelativeSource={RelativeSource AncestorType=Window, Mode=FindAncestor}}"
CommandParameter="{Binding}"
Header="增加子节点" />
<MenuItem
Command="{Binding DataContext.DeleteNode, RelativeSource={RelativeSource AncestorType=Window, Mode=FindAncestor}}"
CommandParameter="{Binding}"
Header="删除节点" />
<MenuItem
Command="{Binding DataContext.ReNameNode, RelativeSource={RelativeSource AncestorType=Window, Mode=FindAncestor}}"
CommandParameter="{Binding}"
Header="重命名" />
</ContextMenu>
<ContextMenu x:Key="child">
<MenuItem
Command="{Binding DataContext.AddChildNode, RelativeSource={RelativeSource AncestorType=Window, Mode=FindAncestor}}"
CommandParameter="{Binding}"
Header="增加子节点" />
<MenuItem
Command="{Binding DataContext.DeleteNode, RelativeSource={RelativeSource AncestorType=Window, Mode=FindAncestor}}"
CommandParameter="{Binding}"
Header="删除节点" />
<MenuItem
Command="{Binding DataContext.ReNameNode, RelativeSource={RelativeSource AncestorType=Window, Mode=FindAncestor}}"
CommandParameter="{Binding}"
Header="重命名" />
</ContextMenu>
</Window.Resources>
运行测试,是不是发现有个问题,textbox要怎么消失呢?简单,有多种方法,事件焦点,键盘事件等等,我这里用的是enter表示我输入完了,同样定义一个命令,如图
/// <summary>
/// 完成重命名节点
/// </summary>
/// <param name="obj"></param>
/// <exception cref="NotImplementedException"></exception>
private void FinishReName(TreeModel obj)
{
obj.IsTextBoxVisibility = Visibility.Collapsed;
obj.IsTextBlockVisibility = Visibility.Visible;
}
再次运行测试,发现节点处会闪一下,有点奇怪,对不对?其实是,我们改了textbox内容后,textblock的内容还是原来的,并没有同步更新,所有我们要怎么解决同步的问题呢?text有一个事件TextChanged,可以实时获取用户输入的内容,那么怎么传给viewmodel呢?这里需要借助升级后的一个包Microsoft.Xaml.Behaviors.Wpf,添加命名空间 xmlns:i="http://schemas.microsoft.com/xaml/behaviors",这时又出来一个问题,我们怎么拿到对应的节点呢,如果拿不到,textbox和textblock的内容还是分开的,,这时我想到了可以通过右键的时候,获取对应的节点保存下来,成为全局变量,所以,咱们分两步,定义一个命令用于接收选择的节点,在定义一个命令用于实时同步改变的内容,如图所示
/// <summary>
/// 获取当前选择的节点
/// </summary>
/// <param name="obj"></param>
/// <exception cref="NotImplementedException"></exception>
private void RightUp(TreeView obj)
{
SelectedNode = obj.SelectedItem as TreeModel;
}
/// <summary>
/// 实时更新节点名称
/// </summary>
/// <param name="obj"></param>
/// <exception cref="NotImplementedException"></exception>
private void Changed(TextBox obj)
{
SelectedNode.Name = obj.Text;
}
对应前端的绑定
<i:Interaction.Triggers>
<i:EventTrigger EventName="MouseRightButtonUp">
<i:InvokeCommandAction Command="{Binding DataContext.MouseRightButtonUp, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=Window}}"
CommandParameter="{Binding ElementName=treeView}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<i:Interaction.Triggers>
<i:EventTrigger EventName="TextChanged">
<i:InvokeCommandAction Command="{Binding DataContext.TextChanged, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=Window}}"
CommandParameter="{Binding ElementName=textBox}" />
</i:EventTrigger>
</i:Interaction.Triggers>
同时,记得修改代码(把上述的内容改为如图所示,这里的参数其实可以不需要,你们要改就自己改吧)
运行测试,看看能不能实现想要的效果,到此结束。
思考
现在我有两个需求,第一个,如果我在treemodel添加一个description的属性来描述对应的节点(也就是查的第二种情况),请问在前端显示description的信息?第二个,如果有成千上万个节点,我不可能一个一个删除,请问通过按钮,怎么一键清空所有的目录树信息?
补充
1、BindableObject中,CallerMemberName的作用类似于nameof(),这样我们就没必要没掉用一次,写一遍nameof了,它执行的逻辑是,第一次运行时,都是null,没必要更新,如果对已有的进行更改,就会拿到最新的更改的信息,通过this指向的model更新对应的属性
2、DelegateCommand,理解了它的逻辑,也就明白了,首先它会判断每个命令是否可行,true为可行,false不可行,由于我们设置第二个参数为null,所以每次命令都为true,
之后点击按钮后,开始掉用执行体,也就是对应的方法
3、JsonIgnore是Newtonsoft.Json包的一个用法,目的是忽略掉不需要保存的内容,至于为啥可以忽略,你们可以想想
4、关于绑定,这类用的是相对绑定,因为我们设置了DataContext是viewmodel,通过对象点的方式获取命令,不过有时并不能绑定上,需要主动的指定绑定源,AncestorType表示在谁身上找,Mode=FindAncestor表示找到一个就行
5、如果还有什么不明白,留言告诉我,当然,如果你们遇到有些自己实现不了的需要,欢迎跟我交流,我们一起进步,冲冲冲
结束
这是我第二次写,后续我打算把一些好用的设计给你们展示下,这样吧,下一篇讲述如何在下拉框中实现一层一层的功能(描述不太准确),是对treeview的扩展应用
还是希望各位大佬们批评指正
附赠我编写的源码GitHub - TQtong/TreeView(注意别提交代码哈)