Tùy chỉnh menu chuột phải theo cột trong TreeDataGrid Avalonia

TreeDataGrid trong Avalonia không cung cấp cơ chế tích hợp sẵn để gắn menu chuột phải riêng biệt cho từng cột. Tuy nhiên, nhờ vào cơ chế sự kiện định tuyến và khả năng truy cập vào cây hiển thị (visual tree), ta có thể xác định chính xác cột nào đang được nhấp chuột phải và điều khiển nội dung menu một cách linh hoạt.

Cách tiếp cận chính

Thay vì dùng ContextMenu tĩnh toàn cục, ta khai báo một ContextMenu duy nhất ở mức TreeDataGrid, sau đó xử lý sự kiện ContextRequested để:

  • Xác định thành phần trực quan (visual) nhận sự kiện — thường là TextBlock hoặc Border chứa dữ liệu ô;
  • Truy ngược lên cây cha để xác định vị trí cột tương ứng trong Source.Columns;
  • Cập nhật trạng thái hiển thị (IsVisible) và hành vi của các mục menu dựa trên tiêu đề cột hoặc kiểu dữ liệu.

Mẫu XAML tối ưu hóa

<Window
    x:Class="AvaloniaApp.Views.MainView"
    xmlns="https://github.com/avaloniaui"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:vm="using:AvaloniaApp.ViewModels"
    x:DataType="vm:MainViewModel">

    <Grid>
        <TreeDataGrid
            Name="DataGrid"
            ContextRequested="OnGridContextRequested">
            <TreeDataGrid.ContextMenu>
                <ContextMenu x:Name="DynamicContextMenu">
                    <MenuItem Header="Sao chép tên" Command="{Binding CopyNameCommand}" IsVisible="{Binding IsNameColumnActive}" />
                    <MenuItem Header="Sao chép mô tả" Command="{Binding CopyDescriptionCommand}" IsVisible="{Binding IsDescriptionColumnActive}" />
                    <MenuItem Header="Sao chép nhãn" Command="{Binding CopyLabelCommand}" IsVisible="{Binding IsLabelColumnActive}" />
                    <Separator />
                    <MenuItem Header="Xuất sang CSV" Command="{Binding ExportCellCommand}" />
                </ContextMenu>
            </TreeDataGrid.ContextMenu>
        </TreeDataGrid>

        <Button Content="Tải dữ liệu" Command="{Binding LoadDataCommand}" HorizontalAlignment="Right" Margin="10" />
    </Grid>
</Window>

Triển khai logic xác định cột trong C#

Thay vì lặp thủ công qua GetVisualChildren(), ta sử dụng FindLogicalAncestorOfType<>() và kiểm tra vị trí thông qua IColumn để đảm bảo tính ổn định khi giao diện thay đổi:

private void OnGridContextRequested(object? sender, ContextRequestedEventArgs e)
{
    if (e.Source is not IControl sourceControl || DataGrid.Source is not ITreeDataGridSource source)
        return;

    // Tìm TextBlock hoặc Border đại diện cho ô dữ liệu
    var cellContent = sourceControl.FindLogicalAncestorOfType<TextBlock>()
                   ?? sourceControl.FindLogicalAncestorOfType<Border>();

    if (cellContent == null) return;

    // Truy ngược đến hàng và xác định chỉ số cột từ vị trí trực quan
    var column = GetColumnFromVisual(cellContent, source);
    if (column == null) return;

    // Cập nhật trạng thái menu theo cột
    var viewModel = DataContext as MainViewModel;
    viewModel?.UpdateContextMenuForColumn(column.Header?.ToString() ?? string.Empty);
}

private IColumn? GetColumnFromVisual(IControl visual, ITreeDataGridSource source)
{
    var parent = visual.Parent;
    while (parent != null && !(parent is ITreeDataGridRow))
    {
        parent = parent.Parent;
    }

    if (parent is ITreeDataGridRow row && row.DataContext is object dataItem)
    {
        // Dựa vào thứ tự hiển thị của cột trong source
        var children = row.GetVisualChildren();
        for (int i = 0; i < children.Count; i++)
        {
            if (children[i] == visual || children[i].GetVisualChildren().Contains(visual))
            {
                return i < source.Columns.Count ? source.Columns[i] : null;
            }
        }
    }

    return null;
}

Triển khai ViewModel hỗ trợ

Để tách biệt rõ ràng giữa UI và logic, ViewModel quản lý trạng thái menu qua các thuộc tính INotifyPropertyChanged:

public partial class MainViewModel : ViewModelBase
{
    private bool _isNameColumnActive;
    private bool _isDescriptionColumnActive;
    private bool _isLabelColumnActive;

    public bool IsNameColumnActive
    {
        get => _isNameColumnActive;
        private set => this.RaiseAndSetIfChanged(ref _isNameColumnActive, value);
    }

    public bool IsDescriptionColumnActive
    {
        get => _isDescriptionColumnActive;
        private set => this.RaiseAndSetIfChanged(ref _isDescriptionColumnActive, value);
    }

    public bool IsLabelColumnActive
    {
        get => _isLabelColumnActive;
        private set => this.RaiseAndSetIfChanged(ref _isLabelColumnActive, value);
    }

    public void UpdateContextMenuForColumn(string columnHeader)
    {
        IsNameColumnActive = columnHeader == "Tên";
        IsDescriptionColumnActive = columnHeader == "Mô tả";
        IsLabelColumnActive = columnHeader == "Nhãn";
    }
}

Lưu ý thực tiễn

  • Không nên phụ thuộc vào tên Name hoặc thứ tự index cứng trong XAML — luôn trích xuất thông tin từ IColumn.Header hoặc IColumn.DataSelector;
  • Với cột tùy chỉnh (TemplateColumn), cần đảm bảo template có cấu trúc nhất quán để xác định TextBlock hoặc ContentPresenter một cách đáng tin cậy;
  • Nếu cần hành vi phức tạp hơn (ví dụ: sao chép giá trị ô hiện tại), lưu trữ DataContext của hàng và IColumn vào Tag hoặc CommandParameter khi cập nhật menu.

Thẻ: AvaloniaUI TreeDataGrid ContextMenu DataBinding csharp

Đăng vào ngày 21 tháng 6 lúc 21:50