來翻新老專案 (3) - MapleLooker - TreeView 資料綁定

這次主軸是將 TreeView 的資料節點做資料綁定
原本是打算對播放器元件做一些重構,但調整 Slider 時碰到問題暫時沒法解決
所以就改重構 TreeView 的部份了

TreeView 如何處理資料綁定?

相較於一般的控制項如:CheckBox、RadioButton、TextBlock 等等,都是直接綁定 TextIsChecked 等簡單屬性
而 TextList、TreeView 等等有關集合的物件,則通常會透過 ItemsSource 來管理物件集合

這類使用物件集合的物件有幾個對 UI 渲染非常重要的屬性:ItemTemplateItemContainerStyleItemContainerGenerator 等等

ItemsSource

原先使用 TreeView 時,都是自己手動建立 TreeViewItem (在 WinForm 則是 TreeNode),並手動將資料指定到 Tag 屬性方便使用
到了 Wpf 中,則可以使用 ItemsSource 直接管理資料集合,並在觸發事件時直接使用資料而非節點

ItemsSource 除了要求實做 IEnumerable 外,並無其他限制,可放入任何資料

ItemContainerGenerator

TreeView 會透過 ItemContainerGenerator 為每一個資料生成對應的節點控制項,也就是 TreeViewItem

ItemTemplate

ItemTemplate 主要負責節點控制項的客製化
先前 ItemContainerGenerator 已經幫忙為資料生成一個外殼 TreeViewItem
TreeViewItem 裡面則有一個 Header 屬性支援客製化模板,為每個資料建立自定義 UI 並依照資料進行綁定
ItemTemplate 會影響 Header 的呈現方式

若沒有什麼特殊需求,TreeView 的 ItemTemplate 使用基本的 TextBlock 來呈現節點資料即可

ItemContainerStyle

如果要對 ItemContainer 的數值進行綁定或額外設定,則需要設定 ItemContainerStyle

ItemContainerStyle 允許設定 ItemContainer 的各項配置,包含一般的屬性以及事件
藉由 ItemContainerStyle 可以在自訂節點上綁定 IsExpandedIsSelected 等屬性,並綁定 TreeViewItem 進行控制

若使用的控制項原本就有設定 ItemContainerStyle,設定 Style 之後會導致原本的配置被覆蓋
這時候就要在設定 Style 時特別設定 BaseOn ,基於原本的 Style 配置進行進一步的改動

開始修改 TreeView

了解 TreeView 有哪些東西,接著就是開始將原本手動建立 Item 的部份改為使用資料綁定與 Template

準備節點物件

首先是節點物件

Wpf 的 TreeView 和 WinForm 的 TreeView 運作上有很大的不同
最初 WinForm 的 TreeView 在任何跟 TreeNode 有關的事件、是否展開都是直接在 TreeView 設定
但是 Wpf 的事件與是否展開皆在 TreeViewItem 設定
這導致最初從 WinForm 轉移到 Wpf 之後,TreeView 的節點在讀取時會全部展開,造成 UI 一次載入大量節點造成卡頓

為了避免節點全部展開,我需要對節點展開的狀態進行控制
因此,需要建立自訂的節點類別,在連結資料的情況下,同時提供綁定在 TreeViewItem 上的屬性:

WzTreeNode.cs
public class WzTreeNode : INotifyPropertyChanged
{
private WzObjectBase _value;
public required WzObjectBase Value {
get => _value;
set {
_value = value;
OnPropertyChanged();
}
}

private string _name;
public required string Name {
get => _name;
set {
_name = value;
OnPropertyChanged();
}
}

public ObservableCollection<WzTreeNode> Children { get; set; } = new();

private bool _isExpanded = false;
public bool IsExpanded {
get => _isExpanded;
set {
_isExpanded = value;
OnPropertyChanged();
}
}

public event PropertyChangedEventHandler PropertyChanged;

protected void OnPropertyChanged([CallerMemberName] string propertyName = null) {
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

上面的 INotifyPropertyChanged 是一個介面,要求類別提供一個 PropertyChanged 事件用於通知 UI 資料發生變化

任何 INotifyPropertyChanged 的物件都必須在 UI 執行緒呼叫 PropertyChanged
若是需要透過其他執行緒對這些物件進行改動,建議是透過 UI 的 Dispatcher 使用 Invoke 回到 UI 執行緒操作物件
或是將 PropertyChanged 事件拉到 UI 執行緒執行,而物件本體則可以不受限制的修改

ObservableCollection 是一個實作 INotifyPropertyChanged 的集合
對集合本身的操作如 AddRemoveClear 等都會通知 UI

由於 ObservableCollection 實作 INotifyPropertyChanged,所以必須在 UI 執行緒進行操作

修改 xaml

ItemsSource 一樣直接綁定到 MainForm 本身,建立一個 RootNodes 用於綁定
由於使用 ObservableCollection,因此不用對 MainForm 實作 INotifyPropertyChanged

MainForm.xaml.cs
public partial class MainForm : WpfUi.FluentWindow
{
// ...
public ObservableCollection<WzTreeNode> RootNodes { get; set; } = new();

// ...
}

接著在 TreeView 上綁定 ItemsSourceRootNodes 並且設定 Style 跟 Template:

MainForm.xaml
<TreeView 
...
ItemsSource="{Binding RootNodes, ElementName=_parent}">
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem" BasedOn="{StaticResource DefaultTreeViewItemStyle}">
<Setter Property="IsExpanded" Value="{Binding IsExpanded}"/>
<Setter Property="Margin" Value="0 5 0 0"/>
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<TextBlock Text="{Binding Name}"/>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>

上面的 HierarchicalDataTemplate 主要是給 TreeView 使用,用於未知深度的樹節點
若是 TextList 之類單純列表形式的控制項,只需要使用 DataTemplate 即可
與 DataTemplate 的不同之處在於對多綁了一次 ChildrenItemsSource

Style 的部份有另外使用到 BaseOn
如同前面所說,設定 Style 會把原先預設的 Style 全部蓋掉
因此,透過 BaseOn 基於原本的 Style 做修改,才能避免蓋掉原本 Wpf.UI 設定的樣式

樣板的部份因為沒有特殊需求,所以只使用最簡單的 TextBlock

設定 ContextMenu

最早 WinForm 時期的 ContextMenu 是藉由 MouseClick 的右鍵點擊事件建立並顯示 ContextMenu
而到 Wpf 這邊,目前我還找不到好的方式處理這一塊

如果使用滑鼠點擊事件,我可以透過 sender 變數取得 TreeViewItem
但是超出第一層的 TreeViewItem 就沒辦法顯示選單

也嘗試過直接用 Style 裡面透過 Setter 設定 ContextMenu
但是一切到沒有選單的區塊點右鍵後再切回來, Wpf.UI 的樣式就會跑掉

最後折衷的作法,就是在自訂 Node 上設定 ContextMenu ,並綁定到 TextBlock 上
缺點是,只有在文字區塊點擊右鍵才會跳出選單

事件重構

因應前面的一些改動,因此一些事件的觸發都要做一些相對應的改動
同時也將輸入做一些調整以方便實做 ICommand 介面做綁定

這邊主要以關閉節點的事件做說明:

MainForm.xaml.cs
void Wz_Close() {
TreeViewItem sele = WzTreeView.SelectedItem as TreeViewItem;
if (sele != null) {
if (sele.Tag is WzFile) {
var msgbox = new WpfUi.MessageBox() {
Title = "警告",
Content = "請問要關閉所選的WZ嗎?\r\n" + ((WzFile)sele.Tag).Name,
PrimaryButtonText = "是",
CloseButtonText = "取消",
};
if (msgbox.ShowDialogAsync().Result == WpfUi.MessageBoxResult.Primary) {
WzFile disFile = sele.Tag as WzFile;
sele.Tag = null;
disFile.Dispose();
WzMaganer.LoadedWZs.Remove(disFile);
WzTreeView.Items.Remove(sele);
ViewBox.ShowWzInfo(null);
}
} else if (WzTreeView.Items.Contains(sele)) {
var msgbox = new WpfUi.MessageBox() {
Title = "警告",
Content = "請問要關閉所選的資料嗎?\r\n" + ((WzObjectBase)sele.Tag).Name
};
if (msgbox.ShowDialogAsync().Result == WpfUi.MessageBoxResult.Primary) {
((IDisposable)sele.Tag).Dispose();
WzTreeView.Items.Remove(sele);
ViewBox.ShowWzInfo(null);
}
}
}
}

重構之後變成這樣:

MainForm.xaml.cs
void CloseWzNode(WzTreeNode node) {
if (node.Value is WzFile file) {
var msgbox = new WpfUi.MessageBox() {
Title = "警告",
Content = "請問要關閉所選的WZ嗎?\r\n" + file.Name,
PrimaryButtonText = "是",
CloseButtonText = "取消",
};
if (msgbox.ShowDialogAsync().Result == WpfUi.MessageBoxResult.Primary) {
file.Dispose();
WzMaganer.LoadedWZs.Remove(file);
RootNodes.Remove(node);
}
} else {
var msgbox = new WpfUi.MessageBox() {
Title = "警告",
Content = "請問要關閉所選的資料嗎?\r\n" + node.Name
};
if (msgbox.ShowDialogAsync().Result == WpfUi.MessageBoxResult.Primary) {
node.Value?.Dispose();
WzTreeView.Items.Remove(node);
}
}
}

主要的差異在於,事件本身已經是以資料為主,所以不用再透過 TreeViewItem 的 Tag 取得資料了
整個方法已經聚焦在 WzTreeNode 上,使得前面的巢狀 if 通通可以拔掉

再來是使用 is 判斷變數的同時,可以同時宣告變數
透過這個語法糖,就少了後續宣告變數的部份,程式也變得更加簡潔

最後透過 ?. 關鍵字,若節點本身並沒有資料,則不會執行 Dispose()
就少了一些強制轉型跟 null 的判斷

ViewBox 的部份後面會說明,這邊的程式已經是調整之後,也不需要手動傳遞數值到自訂控制項了

修改呈現物件資訊的自訂控制項 - ViewBox

ViewBox 主要是為了呈現 WZ 資料而建立的自訂控制項
原本都是手動將物件傳遞到控制項來更新控制項資訊
現在則要透過 xaml 的方式直接綁定 TreeView 的 SelectedItem 來呈現目前選擇的節點資訊

DependencyProperty

為了支援直接從 xaml 綁定屬性的功能,需要新增 DependencyProperty

有關相依性屬性的部份可以參考官方文件
這塊我還沒有深入研究,目前就是從網路上找資源跟著做而已 🙂

下面是針對 CurrentWzObject 屬性的配置:

WzViewBox.xaml.cs
public partial class WzViewBox : UserControl
{
public static readonly DependencyProperty CurrentWzObjectProperty =
DependencyProperty.Register(
nameof(CurrentWzObject),
typeof(WzObjectBase),
typeof(WzViewBox),
new PropertyMetadata(null, OnCurrentWzObjectChanged)
);
public WzObjectBase CurrentWzObject {
get => (WzObjectBase)GetValue(CurrentWzObjectProperty);
set => SetValue(CurrentWzObjectProperty, value);
}

private static void OnCurrentWzObjectChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) {
if (sender is WzViewBox self) {
self.UpdateInfo(e.NewValue as WzObjectBase);
}
}
}

針對屬性 CurrentWzObject ,需要設定一樣名稱的 Property CurrentWzObjectProperty
然後在 CurrentWzObject 的 Getter 跟 Setter 都設定從自己身上取得對應 Property 的資料

這邊另外設定了 OnChanged 事件用於更新內部元件的資料

在 MainForm.xaml 中進行綁定

完成了 DependencyProperty 的設定後
再來就可以直接在 xaml 上使用屬性了

這邊直接對 CurrentWzObject 綁定 TreeView 的 SelectedItem.Value

MainForm.xaml
<comp:WzViewBox
x:Name="ViewBox"
CurrentWzObject="{Binding SelectedItem.Value, ElementName=WzTreeView}"/>

最終成果

maple-looker-20250216.gif

後記

原本是要先重構舊的程式
但看了看 MainForm 的程式,發現大多跟 UI 相關,其實非 UI 邏輯的程式量並不多
中途本來想先改 SoundPlayer 這個子控制項,但是調整聲音位置的部份一直沒辦法調好,所以就跑去改寫 TreeView

原本我在 ItemTemplate 設定 TreeViewItem,而且還忘記把 IsEnabled 設回 true,導致中途試的時候一直打不開 + 位置錯誤
折騰了好一陣子才終於把項目調整好,然後又碰到 Wpf.UI 的樣式被吃掉,找了好久才知道 BaseOn 這個屬性
幸好最後修改結果還算成功

ContextMenu 目前還沒有好的修改方式
試過好幾種辦法,都沒辦法有效的讓動態生成的選單適當的顯示出來
最後只好綁定到 TextBlock 上先讓選單正常運作

目前看起來效果還不錯,而且還順便把以前為了避免 TreeView 載入卡頓而設定的 Thread 給砍掉
整個 UI 的程式開始變得精簡,是好的開始 🙂