最早在 WinForm 時期,TreeNode 的 ContextMenu 是動態生成並即時顯示在指定位置上
轉到 WPF 之後,因為呼叫方式的改變,我改成動態建立 ContextMenu 並掛在節點的 TextBlock 上
一開始,我打算掛在 TreeViewItem 上,當時透過設定 Style 來實現
但卻遇上了選單樣式失效的問題,由於遲遲無法解決,最後還是用一開始掛在 TextBlock 的方案
在翻新的過程中,一直在尋找能夠綁定在 TreeViewItem 並正常呈現方法
或者至少能夠直接用 xaml 來描述 ContextMenu 也行
這次透過免費版 Claude,問到可以根據節點類型切換 ContextMenu 的方法
於是就有了這次的 ContextMenu 重構,想試試看效果如何
DataTrigger
DataTrigger 可以根據資料的數值來設定各種 UI 屬性
通常可以透過元件的 Style.Triggers
進行設定
<TextBlock> <TextBlock.Style> <Style TargetType="TextBlock"> <Style.Triggers> </Style.Triggers> </Style> </TextBlock.Style> </TextBlock>
|
xaml 看起來有點多層,這是因為 DataTrigger
需要在 Triggers
屬性底下設定才行
因為需要設定元件的屬性,所以要將 DataTrigger
放在 Style.Triggers
裡面使用
重構後的 ContextMenu 會根據節點的類型個別設定,最終 xaml 寫成這個樣子:
<TextBlock.Style> <Style TargetType="TextBlock"> <Style.Resources> <ContextMenu x:Key="wzFileMenu"> <MenuItem Header="關閉WZ檔案" Command="{Binding CloseNodeCommand}"/> </ContextMenu> <ContextMenu x:Key="wzListMenu"> <MenuItem Header="關閉清單" Command="{Binding CloseNodeCommand}"/> </ContextMenu> <ContextMenu x:Key="wzImageMenu"> <MenuItem Header="解析" Command="{Binding ExpandCommand}" IsEnabled="{Binding IsLoaded, Converter={StaticResource inverseBool}}"/> <MenuItem Header="重置" Command="{Binding ResetCommand}" IsEnabled="{Binding IsLoaded}"/> </ContextMenu> <ContextMenu x:Key="wzDataMenu"> <MenuItem Header="解析" Command="{Binding ExpandCommand}" IsEnabled="{Binding IsLoaded, Converter={StaticResource inverseBool}}"/> <MenuItem Header="重置" Command="{Binding ResetCommand}" IsEnabled="{Binding IsLoaded}"/> <MenuItem Header="關閉Data檔案" Command="{Binding CloseNodeCommand}"/> </ContextMenu> <ContextMenu x:Key="wzSoundMenu"> <MenuItem Header="儲存聲音" Command="{Binding SaveSoundCommand}"/> </ContextMenu> <ContextMenu x:Key="wzCanvasMenu"> <MenuItem Header="儲存圖片" Command="{Binding SaveCanvasCommand}"/> </ContextMenu> </Style.Resources> <Style.Triggers> <DataTrigger Binding="{Binding Converter={StaticResource objectType}}" Value="{x:Type wzTree:WzFileTreeNode}"> <Setter Property="ContextMenu" Value="{StaticResource wzFileMenu}"/> </DataTrigger> <DataTrigger Binding="{Binding Converter={StaticResource objectType}}" Value="{x:Type wzTree:WzListTreeNode}"> <Setter Property="ContextMenu" Value="{StaticResource wzListMenu}"/> </DataTrigger> <DataTrigger Binding="{Binding Converter={StaticResource objectType}}" Value="{x:Type wzTree:WzImageTreeNode}"> <Setter Property="ContextMenu" Value="{StaticResource wzImageMenu}"/> </DataTrigger> <DataTrigger Binding="{Binding Converter={StaticResource objectType}}" Value="{x:Type wzTree:WzDataTreeNode}"> <Setter Property="ContextMenu" Value="{StaticResource wzDataMenu}"/> </DataTrigger> <DataTrigger Binding="{Binding Converter={StaticResource objectType}}" Value="{x:Type wzTree:WzSoundTreeNode}"> <Setter Property="ContextMenu" Value="{StaticResource wzSoundMenu}"/> </DataTrigger> <DataTrigger Binding="{Binding Converter={StaticResource objectType}}" Value="{x:Type wzTree:WzCanvasTreeNode}"> <Setter Property="ContextMenu" Value="{StaticResource wzCanvasMenu}"/> </DataTrigger> </Style.Triggers> </Style> </TextBlock.Style>
|
主要就是在 Style.Resources
定義各種類型的 ContextMenu
最後在 Style.Triggers
內使用 DataTrigger
針對不同的節點類型設定對應的選單
原本我是把 ContextMenu 直接寫在 Setter.Value
裡面,但是這樣寫 Setter 的部份會變成這樣:
<Setter> <Setter.Value> <ContextMenu/> </Setter.Value> </Setter>
|
因為太多層會讓 xaml 變得很難看,所以才用 Resource 綁定的方式讓 xaml 好看一點
Command
Command 我不是很熟悉,目前只知道他是一個通用的接口
部份 UI 元件會有 Command 屬性可以設定輸入時要觸發的行為
比如說按下 Button,或是按下 MenuItem,這些元件會呼叫 Command 設定好的程式
原本相關選單的事件都放在 MainForm.xaml.cs 內,並在動態產生選單時直接指定事件
現在因為 ContextMenu 直接定義在 xaml 內的關係,所以改用 Command 並綁定到對應的 MenuItem 上
為了要綁定 Command,所以我在每個節點類別上定義各自的 Command,並原本定義在 MainForm 的事件都搬進去
部份會動到根節點的事件,則是透過建立 event 讓外部自行實做其他跟節點無關的操作
下面是其中一個節點的定義:
WzSoundTreeNode.cs[SupportedOSPlatform("Windows")] public class WzSoundTreeNode : WzTreeNode { public ICommand SaveSoundCommand { get; private set; }
public WzSoundTreeNode(string name, WzSound value) : base(name, value) { this.SaveSoundCommand = new RelayCommand<object>(this.OnSaveSound); }
private SaveFileDialog CreateDialog(WzSound sound) { SaveFileDialog dialog = new(); if (sound.IsWav) { dialog.DefaultExt = ".wav"; dialog.Filter = "Wave 音樂檔(.wav)|*.wav"; } else { dialog.DefaultExt = ".mp3"; dialog.Filter = "MP3 音樂檔(.mp3)|*.mp3"; } return dialog; }
private void OnSaveSound(object obj) { WzSound sound = (WzSound)obj; SaveFileDialog SaveSound = this.CreateDialog(sound); if (SaveSound.ShowDialog() == true) { sound.SaveSound(new BinaryWriter(SaveSound.OpenFile())); } } }
|
這個節點的選單主要可以儲存聲音
我在裡面使用了 WpfUI 提供的 RelayCommand
綁定 OnSaveSound
事件,並且在設定選單時直接綁在選單上
未解決的 CanExecute 的問題
Command 有一個 CanExecute
屬性可以設定,主要用來判斷 Command 能不能執行
原本應該會自己更新 UI 的狀態,但是我透過 Trigger 將 Command 綁到 MenuItem 時,MenuItem 卻不會更新 IsEnabled
的狀態
目前在網路上找到的資料不多,只能推測可能是 Trigger 造成的問題
因為沒有解法,最後乾脆直接綁定 IsEnabled
來控制 MenuItem 的開關
完成
ContextMenu 重構大致上就到這邊結束
只不過是換了一個寫法動態設定 ContextMenu 而已,呈現的效果跟原本差不多
主要還是藉這次機會整理一下 Code,並學習其他寫法
其他優化、修正
TreeView 的 VirtualizingPanel 解決節點太多卡頓問題
這次測試時發現了節點太多導致 UI 卡住的問題
本來以為是短時間內建立太多節點導致,後來才發現只是單純節點太多…
要處理這個問題,可以開啟 TreeView 的 VirtualizingPanel 功能
它只會在可以看見節點的地方建立 UI 元素,減少了大量 UI 元素導致的卡頓
TreeView 開啟 VirtualizingPanel 功能<TreeView VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling"> </TreeView>
|
TreeViewItem 的 IsExpanded 要 TwoWay 綁定才能控制
一開始看 TreeNode 有正常設定 IsExpanded
的值以後,就沒去理會
直到要用程式關閉節點才知道,TreeViewItem 的 IsExpanded
屬性需要使用 TwoWay 綁定
原本預設的 OneWay 綁定,會將綁定數值的改動反映到 UI 上
但是 TreeViewItem 本身也可以控制 IsExpanded
因此需要設定 TwoWay 綁定使 TreeViewItem 控制同一個綁定屬性
結語
目前這樣寫下來,感覺 WPF 的入門的難度挺高的
比起現在的網頁框架有現成的 CSS 可以用,寫法也比較簡單易懂
用 WPF 的 xaml 來寫 UI 需要了解各種類別的限制與使用時機
這導致早期為了實現一些動畫或動態生成的 UI 時,需要學習很多基礎知識
Animation、TimeLine、UI 元件、Setter、Trigger、Style
眾多的概念使得初學時特別容易碰壁,有時在網路上也找不到類似的問題,而 MSDN 的文件又龐大又難找,學起特別累
但逐漸了解這些類別以及其限制之後,後面寫 UI 就簡單多了
這次為了減少查文件的時間,就在 Claude 上開了免費帳號去問問題
目前使用起來體驗還不錯,只不過有時候問出來的 Code 或 xaml 不能用 😐
例如 DataTrigger 的部份,Claude 給出的答案長這樣:
<DataTrigger Binding="{Binding}" Value="{x:Type wzTree:WzFileTreeNode}"> </DataTrigger>
|
實際上,這邊的 Binding 只是綁定節點本身,沒辦法用來判斷型別
所以後來才寫了一個 ObjectTypeConvertor 把物件轉成型別去做判斷
雖然 Claude 的回答不完全可靠,但起碼提供了方向,還是比上網 Google 方便一點