From c7d007549995aef611319779d39089d807ef108f Mon Sep 17 00:00:00 2001 From: JakkuSakura Date: Sun, 31 Aug 2025 23:27:25 +0800 Subject: [PATCH 1/8] Add column filtering support to TreeDataGrid - Introduce IsFilterEnabled option for text columns - Add ShowColumnFilters property to TreeDataGrid - Implement filter UI in column headers and wire up filter logic - Update FlatTreeDataGridSource to handle column filters and filter application - Update templates and view models to enable filtering for relevant columns --- samples/TreeDataGridDemo/MainWindow.axaml | 3 +- .../ViewModels/CountriesPageViewModel.cs | 14 +- .../FlatTreeDataGridSource.cs | 95 +++++++++++++ .../Models/TreeDataGrid/TextColumnOptions.cs | 5 + .../Primitives/TreeDataGridColumnHeader.cs | 134 ++++++++++++++++++ .../Themes/Generic.axaml | 92 ++++++------ .../TreeDataGrid.cs | 9 ++ 7 files changed, 306 insertions(+), 46 deletions(-) diff --git a/samples/TreeDataGridDemo/MainWindow.axaml b/samples/TreeDataGridDemo/MainWindow.axaml index c3f192f7..585b7982 100644 --- a/samples/TreeDataGridDemo/MainWindow.axaml +++ b/samples/TreeDataGridDemo/MainWindow.axaml @@ -28,7 +28,8 @@ + AutoDragDropRows="True" + ShowColumnFilters="True"> diff --git a/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs b/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs index 2be1344c..ea9fef4d 100644 --- a/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs +++ b/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs @@ -24,14 +24,22 @@ public CountriesPageViewModel() new TextColumn("Country", x => x.Name, (r, v) => r.Name = v, new GridLength(6, GridUnitType.Star), new() { IsTextSearchEnabled = true, + IsFilterEnabled = true, }), new TemplateColumn("Region", "RegionCell", "RegionEditCell"), - new TextColumn("Population", x => x.Population, new GridLength(3, GridUnitType.Star)), - new TextColumn("Area", x => x.Area, new GridLength(3, GridUnitType.Star)), + new TextColumn("Population", x => x.Population, new GridLength(3, GridUnitType.Star), new() + { + IsFilterEnabled = true, + }), + new TextColumn("Area", x => x.Area, new GridLength(3, GridUnitType.Star), new() + { + IsFilterEnabled = true, + }), new TextColumn("GDP", x => x.GDP, new GridLength(3, GridUnitType.Star), new() { TextAlignment = Avalonia.Media.TextAlignment.Right, - MaxWidth = new GridLength(150) + MaxWidth = new GridLength(150), + IsFilterEnabled = true, }), } }; diff --git a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs index 1ed1e6c1..77ad558f 100644 --- a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs @@ -24,12 +24,14 @@ public class FlatTreeDataGridSource : NotifyingBase, private IComparer? _comparer; private ITreeDataGridSelection? _selection; private bool _isSelectionSet; + private readonly Dictionary _columnFilters = new(); public FlatTreeDataGridSource(IEnumerable items) { _items = items; _itemsView = TreeDataGridItemsSourceView.GetOrCreate(items); Columns = new ColumnList(); + Columns.CollectionChanged += OnColumnsCollectionChanged; } public ColumnList Columns { get; } @@ -81,7 +83,13 @@ public ITreeDataGridSelection? Selection public bool IsHierarchical => false; public bool IsSorted => _comparer is not null; + /// + /// Gets a value indicating whether any filters are currently applied. + /// + public bool IsFiltered => _columnFilters.Any(kvp => !string.IsNullOrWhiteSpace(kvp.Value)); + public event Action? Sorted; + public event Action? Filtered; public void Dispose() { @@ -100,6 +108,8 @@ void ITreeDataGridSource.DragDropRows( throw new NotSupportedException("Only move is currently supported for drag/drop."); if (IsSorted) throw new NotSupportedException("Drag/drop is not supported on sorted data."); + if (IsFiltered) + throw new NotSupportedException("Drag/drop is not supported on filtered data."); if (position == TreeDataGridRowDropPosition.Inside) throw new ArgumentException("Invalid drop position.", nameof(position)); if (indexes.Any(x => x.Count != 1)) @@ -163,6 +173,91 @@ IEnumerable ITreeDataGridSource.GetModelChildren(object model) return Enumerable.Empty(); } + /// + /// Sets a filter value for the specified column. + /// + /// The column to filter. + /// The filter text. + public void SetColumnFilter(IColumn column, string? filterText) + { + if (string.IsNullOrWhiteSpace(filterText)) + { + _columnFilters.Remove(column); + } + else + { + _columnFilters[column] = filterText; + } + + ApplyFilters(); + } + + /// + /// Gets the filter text for the specified column. + /// + /// The column. + /// The filter text or null if no filter is set. + public string? GetColumnFilter(IColumn column) + { + return _columnFilters.TryGetValue(column, out var filter) ? filter : null; + } + + /// + /// Clears all column filters. + /// + public void ClearAllFilters() + { + _columnFilters.Clear(); + ApplyFilters(); + } + + private void ApplyFilters() + { + if (IsFiltered) + { + var filteredItems = _items.Where(PassesAllFilters); + _itemsView = TreeDataGridItemsSourceView.GetOrCreate(filteredItems); + } + else + { + _itemsView = TreeDataGridItemsSourceView.GetOrCreate(_items); + } + + _rows?.SetItems(_itemsView); + Filtered?.Invoke(); + } + + private bool PassesAllFilters(TModel model) + { + foreach (var kvp in _columnFilters) + { + if (string.IsNullOrWhiteSpace(kvp.Value)) + continue; + + if (kvp.Key is ITextSearchableColumn searchableColumn) + { + var value = searchableColumn.SelectValue(model); + if (value == null || !value.Contains(kvp.Value, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + } + return true; + } + + private void OnColumnsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + // Clear filters for removed columns + if (e.OldItems != null) + { + foreach (var item in e.OldItems.OfType()) + { + _columnFilters.Remove(item); + } + } + } + private AnonymousSortableRows CreateRows() { return new AnonymousSortableRows(_itemsView, _comparer); diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs index 485417d3..725cfd91 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs @@ -15,6 +15,11 @@ public class TextColumnOptions : ColumnOptions, ITextCellOptions /// public bool IsTextSearchEnabled { get; set; } + /// + /// Gets or sets a value indicating whether filtering is enabled for this column. + /// + public bool IsFilterEnabled { get; set; } + /// /// Gets or sets the format string for the cells in the column. /// diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs index 8098a82a..7d7c6111 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs @@ -23,13 +23,20 @@ public class TreeDataGridColumnHeader : Button nameof(SortDirection), o => o.SortDirection); + public static readonly DirectProperty ShowFilterProperty = + AvaloniaProperty.RegisterDirect( + nameof(ShowFilter), + o => o.ShowFilter); + private bool _canUserResize; private IColumns? _columns; private object? _header; private IColumn? _model; private ListSortDirection? _sortDirection; + private bool _showFilter; private TreeDataGrid? _owner; private Thumb? _resizer; + private TextBox? _filterBox; public bool CanUserResize { @@ -51,6 +58,12 @@ public ListSortDirection? SortDirection private set => SetAndRaise(SortDirectionProperty, ref _sortDirection, value); } + public bool ShowFilter + { + get => _showFilter; + private set => SetAndRaise(ShowFilterProperty, ref _showFilter, value); + } + public void Realize(IColumns columns, int columnIndex) { if (_model is object) @@ -86,12 +99,25 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) base.OnApplyTemplate(e); _resizer = e.NameScope.Find("PART_Resizer"); + _filterBox = e.NameScope.Find("PART_FilterBox"); if (_resizer is not null) { _resizer.DragDelta += ResizerDragDelta; _resizer.DoubleTapped += ResizerDoubleTapped; } + + if (_filterBox is not null) + { + _filterBox.TextChanged += OnFilterTextChanged; + _filterBox.KeyDown += OnFilterKeyDown; + } + + // Only update filter if we have a model and owner + if (_model != null && _owner != null) + { + UpdateFilter(); + } } private void ResizerDoubleTapped(object? sender, Interactivity.RoutedEventArgs e) @@ -177,6 +203,114 @@ private void UpdatePropertiesFromModel() CanUserResize = _model?.CanUserResize ?? _owner?.CanUserResizeColumns ?? false; Header = _model?.Header; SortDirection = _model?.SortDirection; + + // Only update filter if we have all necessary references + if (_model != null && _owner != null) + { + UpdateFilter(); + } + else + { + ShowFilter = false; + } + } + + private void UpdateFilter() + { + var shouldShowFilter = _owner?.ShowColumnFilters == true && HasFilterEnabled(); + ShowFilter = shouldShowFilter; + + if (_filterBox != null && _model != null && shouldShowFilter) + { + var currentFilter = GetCurrentFilter(); + _filterBox.Text = currentFilter ?? string.Empty; + _filterBox.Watermark = "Filter..."; + } + else if (_filterBox != null) + { + _filterBox.Text = string.Empty; + } + } + + private bool HasFilterEnabled() + { + // Simple check for filtering - we'll enable it for any text column that has IsFilterEnabled = true + return _model != null && IsTextColumnWithFilterEnabled(_model); + } + + private static bool IsTextColumnWithFilterEnabled(object column) + { + try + { + var type = column.GetType(); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(TextColumn<,>)) + { + // Use the most specific Options property to avoid ambiguity + var optionsProperty = type.GetProperty("Options", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly); + if (optionsProperty?.GetValue(column) is object options) + { + var isFilterEnabledProperty = options.GetType().GetProperty("IsFilterEnabled"); + return (bool)(isFilterEnabledProperty?.GetValue(options) ?? false); + } + } + } + catch + { + // If reflection fails, just return false + } + return false; + } + + private string? GetCurrentFilter() + { + if (_owner?.Source != null && _model != null) + { + var sourceType = _owner.Source.GetType(); + if (sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() == typeof(FlatTreeDataGridSource<>)) + { + try + { + var method = sourceType.GetMethod("GetColumnFilter"); + return method?.Invoke(_owner.Source, new[] { _model }) as string; + } + catch { } + } + } + return null; + } + + private void SetFilter(string? filterText) + { + if (_owner?.Source != null && _model != null) + { + var sourceType = _owner.Source.GetType(); + if (sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() == typeof(FlatTreeDataGridSource<>)) + { + try + { + var method = sourceType.GetMethod("SetColumnFilter"); + method?.Invoke(_owner.Source, new object?[] { _model, filterText }); + } + catch { } + } + } + } + + private void OnFilterTextChanged(object? sender, TextChangedEventArgs e) + { + if (_filterBox != null) + { + SetFilter(_filterBox.Text); + } + } + + private void OnFilterKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape && _filterBox != null) + { + _filterBox.Text = string.Empty; + e.Handled = true; + } } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml b/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml index 662fc59f..9f912a7f 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml +++ b/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml @@ -85,48 +85,56 @@ BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}"> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs index 1a8728a6..ab641a4c 100644 --- a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs +++ b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs @@ -51,6 +51,9 @@ public class TreeDataGrid : TemplatedControl public static readonly StyledProperty ShowColumnHeadersProperty = AvaloniaProperty.Register(nameof(ShowColumnHeaders), true); + public static readonly StyledProperty ShowColumnFiltersProperty = + AvaloniaProperty.Register(nameof(ShowColumnFilters), true); + public static readonly DirectProperty SourceProperty = AvaloniaProperty.RegisterDirect( nameof(Source), @@ -158,6 +161,12 @@ public bool ShowColumnHeaders set => SetValue(ShowColumnHeadersProperty, value); } + public bool ShowColumnFilters + { + get => GetValue(ShowColumnFiltersProperty); + set => SetValue(ShowColumnFiltersProperty, value); + } + public ITreeDataGridCellSelectionModel? ColumnSelection => Source?.Selection as ITreeDataGridCellSelectionModel; public ITreeDataGridRowSelectionModel? RowSelection => Source?.Selection as ITreeDataGridRowSelectionModel; From a8976d96e8211578f07dd30b313404bf284b41f9 Mon Sep 17 00:00:00 2001 From: JakkuSakura Date: Tue, 2 Sep 2025 20:29:58 +0800 Subject: [PATCH 2/8] Enhance filtering capabilities in TreeDataGrid with new filterable columns --- samples/TreeDataGridDemo/MainWindow.axaml | 12 +- .../ViewModels/CountriesPageViewModel.cs | 81 +++++-- .../FlatTreeDataGridSource.cs | 90 +++----- .../Models/TreeDataGrid/CheckBoxColumn.cs | 10 +- .../TreeDataGrid/CheckBoxColumnOptions.cs | 4 + .../Models/TreeDataGrid/ColumnBase`1.cs | 9 +- .../Models/TreeDataGrid/ColumnBase`2.cs | 2 + .../Models/TreeDataGrid/Filters.cs | 122 ++++++++++ .../TreeDataGrid/IFilterControlFactory.cs | 217 ++++++++++++++++++ .../Models/TreeDataGrid/IFilterableColumn.cs | 19 ++ .../Models/TreeDataGrid/IValueFilter.cs | 23 ++ .../Models/TreeDataGrid/TemplateColumn.cs | 16 +- .../TreeDataGrid/TemplateColumnOptions.cs | 9 + .../Models/TreeDataGrid/TextColumn.cs | 12 +- .../Models/TreeDataGrid/TextColumnOptions.cs | 7 +- .../Primitives/TreeDataGridColumnHeader.cs | 71 +++++- .../Themes/Generic.axaml | 1 + 17 files changed, 605 insertions(+), 100 deletions(-) create mode 100644 src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/Filters.cs create mode 100644 src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs create mode 100644 src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs create mode 100644 src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IValueFilter.cs diff --git a/samples/TreeDataGridDemo/MainWindow.axaml b/samples/TreeDataGridDemo/MainWindow.axaml index 585b7982..c320fea9 100644 --- a/samples/TreeDataGridDemo/MainWindow.axaml +++ b/samples/TreeDataGridDemo/MainWindow.axaml @@ -11,8 +11,18 @@ - + Cell Selection + + Filter Example: + Try typing in the filter boxes under each column header: + • Country: Try partial names + • Region: Try "EUROPE" or "ASIA" + • Population: Try numbers like "10000" + • Area: Try areas like "100000" + + + Sealand diff --git a/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs b/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs index ea9fef4d..190b743b 100644 --- a/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs +++ b/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs @@ -1,10 +1,12 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Linq; using Avalonia.Controls; using Avalonia.Controls.Models.TreeDataGrid; using Avalonia.Controls.Selection; +using Avalonia.Media; using ReactiveUI; using TreeDataGridDemo.Models; +using TreeDataGridDemo.ViewModels; namespace TreeDataGridDemo.ViewModels { @@ -21,26 +23,63 @@ public CountriesPageViewModel() { Columns = { - new TextColumn("Country", x => x.Name, (r, v) => r.Name = v, new GridLength(6, GridUnitType.Star), new() - { - IsTextSearchEnabled = true, - IsFilterEnabled = true, - }), - new TemplateColumn("Region", "RegionCell", "RegionEditCell"), - new TextColumn("Population", x => x.Population, new GridLength(3, GridUnitType.Star), new() - { - IsFilterEnabled = true, - }), - new TextColumn("Area", x => x.Area, new GridLength(3, GridUnitType.Star), new() - { - IsFilterEnabled = true, - }), - new TextColumn("GDP", x => x.GDP, new GridLength(3, GridUnitType.Star), new() - { - TextAlignment = Avalonia.Media.TextAlignment.Right, - MaxWidth = new GridLength(150), - IsFilterEnabled = true, - }), + // Text column with advanced filter + new TextColumn( + "Country", + x => x.Name, + (r, v) => r.Name = v, + new GridLength(6, GridUnitType.Star), + new TextColumnOptions + { + IsTextSearchEnabled = true, + IsFilterEnabled = true // This will create a TextFilter automatically + }), + + // Use a template column with filtering for region + new TemplateColumn( + "Region", + "RegionCell", + "RegionEditCell", + new GridLength(3, GridUnitType.Star), + new TemplateColumnOptions + { + IsFilterEnabled = true, + FilterValueSelector = x => x.Region // Value selector for filtering + }), + + // Population column with numeric filtering + new TextColumn( + "Population", + x => x.Population, + new GridLength(3, GridUnitType.Star), + new TextColumnOptions + { + IsFilterEnabled = true + }), + + // Area column with numeric filtering + new TextColumn( + "Area", + x => x.Area, + new GridLength(3, GridUnitType.Star), + new TextColumnOptions + { + IsFilterEnabled = true, + StringFormat = "{0:N0}" + }), + + // GDP column + new TextColumn( + "GDP", + x => x.GDP, + new GridLength(3, GridUnitType.Star), + new TextColumnOptions + { + TextAlignment = TextAlignment.Right, + MaxWidth = new GridLength(150), + IsFilterEnabled = true, + StringFormat = "${0:N0}" + }), } }; Source.RowSelection!.SingleSelect = false; diff --git a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs index 77ad558f..a707e844 100644 --- a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs @@ -16,7 +16,7 @@ namespace Avalonia.Controls public class FlatTreeDataGridSource : NotifyingBase, ITreeDataGridSource, IDisposable - where TModel: class + where TModel : class { private IEnumerable _items; private TreeDataGridItemsSourceView _itemsView; @@ -24,7 +24,7 @@ public class FlatTreeDataGridSource : NotifyingBase, private IComparer? _comparer; private ITreeDataGridSelection? _selection; private bool _isSelectionSet; - private readonly Dictionary _columnFilters = new(); + private readonly Dictionary, object?> _filterConditions = new(); public FlatTreeDataGridSource(IEnumerable items) { @@ -78,15 +78,28 @@ public ITreeDataGridSelection? Selection IEnumerable ITreeDataGridSource.Items => Items; - public ITreeDataGridCellSelectionModel? CellSelection => Selection as ITreeDataGridCellSelectionModel; - public ITreeDataGridRowSelectionModel? RowSelection => Selection as ITreeDataGridRowSelectionModel; + public ITreeDataGridCellSelectionModel? CellSelection => + Selection as ITreeDataGridCellSelectionModel; + + public ITreeDataGridRowSelectionModel? RowSelection => + Selection as ITreeDataGridRowSelectionModel; + public bool IsHierarchical => false; public bool IsSorted => _comparer is not null; /// /// Gets a value indicating whether any filters are currently applied. /// - public bool IsFiltered => _columnFilters.Any(kvp => !string.IsNullOrWhiteSpace(kvp.Value)); + public bool HasFilters() + { + foreach (var column in Columns) + { + if (column is IFilterableColumn { IsFilterEnabled: true }) + return true; + } + + return false; + } public event Action? Sorted; public event Action? Filtered; @@ -108,7 +121,7 @@ void ITreeDataGridSource.DragDropRows( throw new NotSupportedException("Only move is currently supported for drag/drop."); if (IsSorted) throw new NotSupportedException("Drag/drop is not supported on sorted data."); - if (IsFiltered) + if (HasFilters()) throw new NotSupportedException("Drag/drop is not supported on filtered data."); if (position == TreeDataGridRowDropPosition.Inside) throw new ArgumentException("Invalid drop position.", nameof(position)); @@ -162,6 +175,7 @@ bool ITreeDataGridSource.SortBy(IColumn? column, ListSortDirection direction) foreach (var c in Columns) c.SortDirection = c == column ? direction : null; } + return true; } @@ -173,47 +187,9 @@ IEnumerable ITreeDataGridSource.GetModelChildren(object model) return Enumerable.Empty(); } - /// - /// Sets a filter value for the specified column. - /// - /// The column to filter. - /// The filter text. - public void SetColumnFilter(IColumn column, string? filterText) - { - if (string.IsNullOrWhiteSpace(filterText)) - { - _columnFilters.Remove(column); - } - else - { - _columnFilters[column] = filterText; - } - - ApplyFilters(); - } - - /// - /// Gets the filter text for the specified column. - /// - /// The column. - /// The filter text or null if no filter is set. - public string? GetColumnFilter(IColumn column) - { - return _columnFilters.TryGetValue(column, out var filter) ? filter : null; - } - - /// - /// Clears all column filters. - /// - public void ClearAllFilters() - { - _columnFilters.Clear(); - ApplyFilters(); - } - private void ApplyFilters() { - if (IsFiltered) + if (HasFilters()) { var filteredItems = _items.Where(PassesAllFilters); _itemsView = TreeDataGridItemsSourceView.GetOrCreate(filteredItems); @@ -229,31 +205,29 @@ private void ApplyFilters() private bool PassesAllFilters(TModel model) { - foreach (var kvp in _columnFilters) + // Check immutable filter system first (only apply active filters) + foreach (var column in Columns) { - if (string.IsNullOrWhiteSpace(kvp.Value)) - continue; - - if (kvp.Key is ITextSearchableColumn searchableColumn) + if (column is IFilterableColumn col && col.IsFilterEnabled) { - var value = searchableColumn.SelectValue(model); - if (value == null || !value.Contains(kvp.Value, StringComparison.OrdinalIgnoreCase)) - { - return false; - } + var cond = _filterConditions[col]; + if (!col.PassesFilter(model, cond)) return false; } } + + return true; } - private void OnColumnsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + private void OnColumnsCollectionChanged(object? sender, + System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { // Clear filters for removed columns if (e.OldItems != null) { - foreach (var item in e.OldItems.OfType()) + foreach (var item in e.OldItems.OfType>()) { - _columnFilters.Remove(item); + _filterConditions.Remove(item); } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs index 066c69ae..66bf444a 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs @@ -8,7 +8,7 @@ namespace Avalonia.Controls.Models.TreeDataGrid /// A column in an which displays a check box. /// /// The model type. - public class CheckBoxColumn : ColumnBase + public class CheckBoxColumn : ColumnBase, IFilterableColumn where TModel : class { /// @@ -89,5 +89,13 @@ public override ICell CreateCell(IRow row) TypedBinding.OneWay(g) : TypedBinding.TwoWay(g, (m, v) => setter(m, v ?? false)); } + + public bool IsFilterEnabled => ((CheckBoxColumnOptions)Options)?.IsFilterEnabled ?? false; + + public bool PassesFilter(TModel model, object? condition) + { + var checked_ = ValueSelector(model); + return new BooleanValueFilter().Passes(condition, checked_); + } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs index 793a8859..02c6cafd 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs @@ -8,5 +8,9 @@ namespace Avalonia.Controls.Models.TreeDataGrid /// The model type. public class CheckBoxColumnOptions : ColumnOptions { + /// + /// Gets or sets a value indicating whether filtering is enabled for this column. + /// + public bool IsFilterEnabled { get; set; } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs index 4d2e9a94..72c84165 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs @@ -30,7 +30,7 @@ public ColumnBase( object? header, GridLength? width, ColumnOptions options) - { + { _header = header; Options = options; SetWidth(width ?? GridLength.Auto); @@ -51,7 +51,7 @@ public double ActualWidth /// /// To set the column width use . /// - public GridLength Width + public GridLength Width { get => _width; private set => RaiseAndSetIfChanged(ref _width, value); @@ -108,7 +108,8 @@ double IUpdateColumnLayout.CellMeasured(double width, int rowIndex) { _autoWidth = Math.Max(NonNaN(_autoWidth), CoerceActualWidth(width)); return Width.GridUnitType == GridUnitType.Auto || double.IsNaN(ActualWidth) ? - _autoWidth : ActualWidth; + _autoWidth : + ActualWidth; } void IUpdateColumnLayout.CalculateStarWidth(double availableWidth, double totalStars) @@ -145,7 +146,7 @@ bool IUpdateColumnLayout.CommitActualWidth() { return false; } - + return !MathUtilities.AreClose(oldWidth, ActualWidth); } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`2.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`2.cs index f330a793..5dcc8b21 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`2.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`2.cs @@ -127,5 +127,7 @@ private int DefaultSortDescending(TModel? x, TModel? y) var b = ValueSelector(y); return Comparer.Default.Compare(b, a); } + + public TValue? SelectValue(TModel model) => ValueSelector(model); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/Filters.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/Filters.cs new file mode 100644 index 00000000..590c7af0 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/Filters.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Avalonia.Controls.Models.TreeDataGrid; + +/// +/// A filter that checks text values. +/// +public class TextValueFilter : IValueFilter +{ + /// + /// Determines if the value passes the filter. + /// + /// The filter condition (should be a string). + /// The value to check. + /// True if the value passes the filter, otherwise false. + public bool Passes(object? condition, string? value) + { + if (condition is not string filterText || string.IsNullOrWhiteSpace(filterText)) + return true; + + return value != null && value.Contains(filterText, StringComparison.OrdinalIgnoreCase); + } +} + +/// +/// A filter that checks boolean values. +/// +public class BooleanValueFilter : IValueFilter +{ + /// + /// Determines if the value passes the filter. + /// + /// The filter condition (should be a bool? or string). + /// The value to check. + /// True if the value passes the filter, otherwise false. + bool IValueFilter.Passes(object? condition, bool value) + { + if (condition == null) + return true; + // C# does not support nullable at type level + // if (value == null) + // return false; + if (condition is bool b) + return b == value; + if (condition is string s && bool.TryParse(s, out var parsed)) + return parsed == value; + return false; + } + + public bool Passes(object? condition, bool? value) + { + if (value == null) + return false; + return ((IValueFilter)this).Passes(condition, value); + } +} + +/// +/// A filter that checks if a value is in a set of allowed values. +/// +/// The value type. +public class SetValueFilter : IValueFilter +{ + /// + /// Determines if the value passes the filter. + /// + /// The filter condition (should be a collection of TValue or string). + /// The value to check. + /// True if the value passes the filter, otherwise false. + public bool Passes(object? condition, TValue? value) + { + // Handle null condition (no filter) + if (condition == null) + return true; + if (value == null) + return false; + + // Create a hash set from the condition + HashSet allowedValues; + var comparer = EqualityComparer.Default; + + if (condition is IEnumerable values) + { + allowedValues = new HashSet(values, comparer); + } + else if (condition is string s && !string.IsNullOrWhiteSpace(s)) + { + // For string condition, this would depend on TValue + // This is a simplified example that only works for string values + if (typeof(TValue) == typeof(string)) + { + var parts = s.Split(','); + var stringValues = new HashSet( + parts.Select(p => p.Trim()), + StringComparer.OrdinalIgnoreCase); + + allowedValues = new HashSet( + stringValues.Cast(), + comparer); + } + else + { + // If we can't parse the condition for non-string types, don't filter + return true; + } + } + else + { + // If we can't use the condition, don't filter + return true; + } + + // If no values to filter by, don't filter + if (allowedValues.Count == 0) + return true; + + // Check if the value is in the allowed values + return allowedValues.Contains(value); + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs new file mode 100644 index 00000000..7c127a56 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs @@ -0,0 +1,217 @@ +using System; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; + +namespace Avalonia.Controls.Models.TreeDataGrid +{ + /// + /// Interface for filter controls that can be added to column headers. + /// + public interface IFilterControl + { + /// + /// Gets the visual control element. + /// + Control Control { get; } + + /// + /// Gets or sets the current filter value. + /// + object? FilterValue { get; set; } + + /// + /// Event raised when the filter value changes. + /// + event EventHandler? FilterValueChanged; + } + + /// + /// Event arguments for filter value changes. + /// + public class FilterValueChangedEventArgs : EventArgs + { + /// + /// Gets the new filter value. + /// + public object? FilterValue { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The new filter value. + public FilterValueChangedEventArgs(object? filterValue) + { + FilterValue = filterValue; + } + } + + /// + /// Interface for creating filter controls for column headers. + /// + public interface IFilterControlFactory + { + /// + /// Creates a filter control for a column header. + /// + /// The column for which to create a filter control. + /// The initial filter value. + /// A filter control that can be used to filter the column, or null if no filter is available. + IFilterControl? CreateFilterControl(IColumn column, object? initialValue); + } + + /// + /// A text filter control implementation. + /// + public class TextFilterControl : IFilterControl + { + private readonly TextBox _textBox; + + /// + /// Gets the visual control element. + /// + public Control Control => _textBox; + + /// + /// Gets or sets the current filter value. + /// + public object? FilterValue + { + get => _textBox.Text; + set => _textBox.Text = value?.ToString() ?? string.Empty; + } + + /// + /// Event raised when the filter value changes. + /// + public event EventHandler? FilterValueChanged; + + /// + /// Initializes a new instance of the class. + /// + /// The watermark to display in the text box. + /// The initial filter value. + public TextFilterControl(string watermark, object? initialValue) + { + _textBox = new TextBox + { + Text = initialValue?.ToString() ?? string.Empty, + Watermark = watermark, + Margin = new Thickness(2, 1), + FontSize = 11, + Height = 20 + }; + + _textBox.GetObservable(TextBox.TextProperty).Subscribe(text => + { + FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs( + string.IsNullOrWhiteSpace(text) ? null : text)); + }); + } + } + + /// + /// A checkbox filter control implementation. + /// + public class CheckBoxFilterControl : IFilterControl + { + private readonly CheckBox _checkBox; + + /// + /// Gets the visual control element. + /// + public Control Control => _checkBox; + + /// + /// Gets or sets the current filter value. + /// + public object? FilterValue + { + get => _checkBox.IsChecked; + set => _checkBox.IsChecked = value as bool?; + } + + /// + /// Event raised when the filter value changes. + /// + public event EventHandler? FilterValueChanged; + + /// + /// Initializes a new instance of the class. + /// + /// Whether the checkbox should support three states. + /// The initial filter value. + public CheckBoxFilterControl(bool isThreeState, object? initialValue) + { + bool? initialChecked = null; + if (initialValue is bool boolValue) + { + initialChecked = boolValue; + } + else if (initialValue is string strValue && bool.TryParse(strValue, out var parsedValue)) + { + initialChecked = parsedValue; + } + + _checkBox = new CheckBox + { + IsThreeState = isThreeState, + IsChecked = initialChecked, + Margin = new Thickness(4), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + _checkBox.GetObservable(ToggleButton.IsCheckedProperty).Subscribe(isChecked => + { + FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs(isChecked)); + }); + } + } + + /// + /// A wrapper for a direct control to be used as an IFilterControl. + /// + public class DirectControlWrapper : IFilterControl + { + private readonly Control _control; + private object? _filterValue; + + /// + /// Gets the visual control element. + /// + public Control Control => _control; + + /// + /// Gets or sets the current filter value. + /// + public object? FilterValue + { + get => _filterValue; + set => _filterValue = value; + } + + /// + /// Event raised when the filter value changes. + /// + public event EventHandler? FilterValueChanged; + + /// + /// Initializes a new instance of the class. + /// + /// The control to wrap. + public DirectControlWrapper(Control control) + { + _control = control; + } + + /// + /// Raises the FilterValueChanged event. + /// + /// The new filter value. + public void RaiseFilterValueChanged(object? value) + { + _filterValue = value; + FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs(value)); + } + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs new file mode 100644 index 00000000..6196fd45 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs @@ -0,0 +1,19 @@ +namespace Avalonia.Controls.Models.TreeDataGrid +{ + public interface IFilterableColumn + { + + /// + /// Gets a value indicating whether filtering is enabled for this column. + /// + bool IsFilterEnabled { get; } + } + /// + /// Interface for columns that support text-based filtering. + /// + /// The model type. + public interface IFilterableColumn: IFilterableColumn + { + bool PassesFilter(IModel model, object? condition); + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IValueFilter.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IValueFilter.cs new file mode 100644 index 00000000..dcf2c804 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IValueFilter.cs @@ -0,0 +1,23 @@ +using System; + +namespace Avalonia.Controls.Models.TreeDataGrid; + +public interface IValueFilter +{ + bool Passes(object? condition, object? value); +} +/// +/// Represents an immutable filter that can be applied to a value. +/// +/// The value type. +public interface IValueFilter: IValueFilter +{ + /// + /// Determines whether the value passes this filter with the given condition. + /// + /// The filter condition (e.g., filter text, checkbox state). + /// The value to check. + /// True if the value passes the filter, otherwise false. + bool Passes(object? condition, TValue? value); + bool IValueFilter.Passes(object? condition, object? value) => value is TValue typedValue && Passes(condition, typedValue); +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs index d10a2102..42209783 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs @@ -10,8 +10,8 @@ namespace Avalonia.Controls.Models.TreeDataGrid /// template. /// /// The model type. - /// The column data type. - public class TemplateColumn : ColumnBase, ITextSearchableColumn + public class TemplateColumn : ColumnBase, ITextSearchableColumn, + IFilterableColumn { private readonly Func _getCellTemplate; private readonly Func? _getEditingCellTemplate; @@ -32,7 +32,8 @@ public TemplateColumn( _cellTemplate = cellTemplate; _cellEditingTemplate = cellEditingTemplate; _getEditingCellTemplate = cellEditingTemplate is not null ? - GetCellEditingTemplate : null; + GetCellEditingTemplate : + null; } public TemplateColumn( @@ -62,7 +63,7 @@ public IDataTemplate GetCellTemplate(Control anchor) { if (_cellTemplate is not null) return _cellTemplate; - + _cellTemplate = anchor.FindResource(_cellTemplateResourceKey!) as IDataTemplate; if (_cellTemplate is null) @@ -112,5 +113,12 @@ public override ICell CreateCell(IRow row) } string? ITextSearchableColumn.SelectValue(TModel model) => Options.TextSearchValueSelector?.Invoke(model); + + public bool IsFilterEnabled => Options?.IsTextSearchEnabled ?? false; + public bool PassesFilter(TModel model, object? condition) + { + var value = Options.FilterValueSelector?.Invoke(model); + return Options.Filter?.Passes(condition, value) ?? true; + } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs index 658947a4..5999bb1f 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs @@ -13,10 +13,19 @@ public class TemplateColumnOptions : ColumnOptions, ITemplateCel /// Gets or sets a value indicating whether the column takes part in text searches. /// public bool IsTextSearchEnabled { get; set; } + public bool IsFilterEnabled { get; set; } /// /// Gets or sets a function which selects the search text from a model. /// public Func? TextSearchValueSelector { get; set; } + + /// + /// Gets or sets a function which selects the filter value from a model. + /// If null, TextSearchValueSelector will be used for filtering. + /// + public Func? FilterValueSelector { get; set; } + + public IValueFilter? Filter { get; set; } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs index eac14623..afd79d91 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs @@ -8,7 +8,8 @@ namespace Avalonia.Controls.Models.TreeDataGrid /// /// The model type. /// The column data type. - public class TextColumn : ColumnBase, ITextSearchableColumn + public class TextColumn : ColumnBase, ITextSearchableColumn, + IFilterableColumn where TModel : class { /// @@ -65,9 +66,14 @@ public override ICell CreateCell(IRow row) return new TextCell(CreateBindingExpression(row.Model), Binding.Write is null, Options); } - string? ITextSearchableColumn.SelectValue(TModel model) + string? ITextSearchableColumn.SelectValue(TModel model) => ValueSelector(model)?.ToString(); + + bool IFilterableColumn.IsFilterEnabled => Options?.IsFilterEnabled ?? false; + + bool IFilterableColumn.PassesFilter(TModel model, object? condition) { - return ValueSelector(model)?.ToString(); + var value = ValueSelector(model); + return Options?.Filter?.Passes(value, condition) ?? true; } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs index 725cfd91..9d87fa62 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs @@ -14,12 +14,7 @@ public class TextColumnOptions : ColumnOptions, ITextCellOptions /// Gets or sets a value indicating whether the column takes part in text searches. /// public bool IsTextSearchEnabled { get; set; } - - /// - /// Gets or sets a value indicating whether filtering is enabled for this column. - /// public bool IsFilterEnabled { get; set; } - /// /// Gets or sets the format string for the cells in the column. /// @@ -44,5 +39,7 @@ public class TextColumnOptions : ColumnOptions, ITextCellOptions /// Gets or sets the text alignment mode for the cells in the column. /// public TextAlignment TextAlignment { get; set; } = TextAlignment.Left; + + public IValueFilter? Filter { get; set; } = new TextValueFilter(); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs index 7d7c6111..2361bd68 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs @@ -28,6 +28,12 @@ public class TreeDataGridColumnHeader : Button nameof(ShowFilter), o => o.ShowFilter); + public static readonly DirectProperty FilterTextProperty = + AvaloniaProperty.RegisterDirect( + nameof(FilterText), + o => o.FilterText, + (o, v) => o.FilterText = v); + private bool _canUserResize; private IColumns? _columns; private object? _header; @@ -37,6 +43,7 @@ public class TreeDataGridColumnHeader : Button private TreeDataGrid? _owner; private Thumb? _resizer; private TextBox? _filterBox; + private string? _filterText; public bool CanUserResize { @@ -64,6 +71,25 @@ public bool ShowFilter private set => SetAndRaise(ShowFilterProperty, ref _showFilter, value); } + public string? FilterText + { + get => _filterText; + set + { + if (SetAndRaise(FilterTextProperty, ref _filterText, value)) + { + // Update the UI if the filter box exists + if (_filterBox != null && _filterBox.Text != value) + { + _filterBox.Text = value ?? string.Empty; + } + + // Apply the filter + SetFilter(value); + } + } + } + public void Realize(IColumns columns, int columnIndex) { if (_model is object) @@ -110,6 +136,11 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) if (_filterBox is not null) { _filterBox.TextChanged += OnFilterTextChanged; + // If we already have a filter value, apply it to the textbox + if (!string.IsNullOrEmpty(_filterText)) + { + _filterBox.Text = _filterText; + } _filterBox.KeyDown += OnFilterKeyDown; } @@ -223,6 +254,7 @@ private void UpdateFilter() if (_filterBox != null && _model != null && shouldShowFilter) { var currentFilter = GetCurrentFilter(); + _filterText = currentFilter; _filterBox.Text = currentFilter ?? string.Empty; _filterBox.Watermark = "Filter..."; } @@ -234,10 +266,42 @@ private void UpdateFilter() private bool HasFilterEnabled() { - // Simple check for filtering - we'll enable it for any text column that has IsFilterEnabled = true - return _model != null && IsTextColumnWithFilterEnabled(_model); + if (_model == null) + return false; + + // First check if it implements the new IFilterableColumn interface + if (IsFilterableColumn(_model)) + return true; + + // For backward compatibility, also check the legacy way with TextColumn + return IsTextColumnWithFilterEnabled(_model); } + private static bool IsFilterableColumn(object column) + { + try + { + // Check if the column implements IFilterableColumn for any T + var columnType = column.GetType(); + foreach (var interfaceType in columnType.GetInterfaces()) + { + if (interfaceType.IsGenericType && + interfaceType.GetGenericTypeDefinition() == typeof(IFilterableColumn<>)) + { + // Get the IsFilterEnabled property + var isFilterEnabledProperty = interfaceType.GetProperty("IsFilterEnabled"); + return (bool)(isFilterEnabledProperty?.GetValue(column) ?? false); + } + } + } + catch + { + // If reflection fails, continue to the next check + } + + return false; + } + private static bool IsTextColumnWithFilterEnabled(object column) { try @@ -300,7 +364,8 @@ private void OnFilterTextChanged(object? sender, TextChangedEventArgs e) { if (_filterBox != null) { - SetFilter(_filterBox.Text); + // Update the property when the text box changes + FilterText = _filterBox.Text; } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml b/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml index 9f912a7f..878a83e1 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml +++ b/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml @@ -128,6 +128,7 @@ + Date: Tue, 2 Sep 2025 22:36:44 +0800 Subject: [PATCH 3/8] Add filtering support for CheckBox and Template columns in TreeDataGrid --- .../Models/TreeDataGrid/CheckBoxColumn.cs | 6 +- .../TreeDataGrid/CheckBoxColumnOptions.cs | 31 ++- .../Models/TreeDataGrid/ColumnBase`1.cs | 6 + .../HierarchicalExpanderColumn.cs | 8 +- .../Models/TreeDataGrid/IColumn.cs | 2 + .../TreeDataGrid/IFilterControlFactory.cs | 21 ++ .../Models/TreeDataGrid/TemplateColumn.cs | 6 +- .../TreeDataGrid/TemplateColumnOptions.cs | 36 ++- .../Models/TreeDataGrid/TextColumn.cs | 2 +- .../Models/TreeDataGrid/TextColumnOptions.cs | 30 ++- .../Primitives/TreeDataGridColumnHeader.cs | 218 +++++------------- .../Themes/Generic.axaml | 12 +- 12 files changed, 197 insertions(+), 181 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs index 66bf444a..78ca3aed 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs @@ -90,12 +90,14 @@ public override ICell CreateCell(IRow row) TypedBinding.TwoWay(g, (m, v) => setter(m, v ?? false)); } - public bool IsFilterEnabled => ((CheckBoxColumnOptions)Options)?.IsFilterEnabled ?? false; + public new CheckBoxColumnOptions Options => (CheckBoxColumnOptions)base.Options; + + public bool IsFilterEnabled => Options?.IsFilterEnabled ?? false; public bool PassesFilter(TModel model, object? condition) { var checked_ = ValueSelector(model); - return new BooleanValueFilter().Passes(condition, checked_); + return Options?.Filter?.Passes(condition, checked_) ?? true; } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs index 02c6cafd..3ce95a22 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace Avalonia.Controls.Models.TreeDataGrid { @@ -6,11 +6,38 @@ namespace Avalonia.Controls.Models.TreeDataGrid /// Holds less commonly-used options for a . /// /// The model type. - public class CheckBoxColumnOptions : ColumnOptions + public class CheckBoxColumnOptions : ColumnOptions, IFilterControlFactory + where TModel : class { /// /// Gets or sets a value indicating whether filtering is enabled for this column. /// public bool IsFilterEnabled { get; set; } + + /// + /// Gets or sets the filter to use for this column. + /// + public IValueFilter? Filter { get; set; } = new BooleanValueFilter(); + + /// + /// Gets or sets a value indicating whether the filter should use a three-state checkbox. + /// + public bool IsThreeStateFilter { get; set; } = true; + + /// + /// Creates a filter control for this column. + /// + /// The column for which to create a filter control. + /// The initial filter value. + /// A filter control that can be used to filter the column. + public IFilterControl? CreateFilterControl(IColumn column, object? initialValue) + { + if (column is CheckBoxColumn && IsFilterEnabled) + { + return new CheckBoxFilterControl(IsThreeStateFilter, initialValue); + } + + return null; + } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs index 72c84165..6ddbcd04 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/ColumnBase`1.cs @@ -90,6 +90,12 @@ public ListSortDirection? SortDirection /// public object? Tag { get; set; } + object IColumn.ErasedOptions() + { + return Options; + } + + bool? IColumn.CanUserResize => Options.CanUserResizeColumn; double IUpdateColumnLayout.MinActualWidth => CoerceActualWidth(0); double IUpdateColumnLayout.MaxActualWidth => CoerceActualWidth(double.PositiveInfinity); diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/HierarchicalExpanderColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/HierarchicalExpanderColumn.cs index 0755ca0a..dfbe92fb 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/HierarchicalExpanderColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/HierarchicalExpanderColumn.cs @@ -16,7 +16,7 @@ public class HierarchicalExpanderColumn : NotifyingBase, IColumn, IExpanderColumn, IUpdateColumnLayout - where TModel : class + where TModel : class { private readonly IColumn _inner; private readonly Func?> _childSelector; @@ -83,6 +83,12 @@ public object? Tag set => _inner.Tag = value; } + object? IColumn.ErasedOptions() + { + return null; + } + + public GridLength Width => _inner.Width; public IColumn Inner => _inner; double IUpdateColumnLayout.MinActualWidth => ((IUpdateColumnLayout)_inner).MinActualWidth; diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs index 21ba7496..5251c4c9 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs @@ -47,5 +47,7 @@ public interface IColumn : INotifyPropertyChanged /// Gets or sets a user-defined object attached to the column. /// object? Tag { get; set; } + + internal object? ErasedOptions(); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs index 7c127a56..d44946a7 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs @@ -214,4 +214,25 @@ public void RaiseFilterValueChanged(object? value) FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs(value)); } } + + /// + /// Extension methods for working with columns. + /// + public static class ColumnExtensions + { + /// + /// Gets the erased options for a column. + /// + /// The column. + /// The column options, or null if the column doesn't have options. + public static object? ErasedOptions(this IColumn? column) + { + if (column == null) + return null; + + // Try to get the Options property using reflection + var optionsProperty = column.GetType().GetProperty("Options"); + return optionsProperty?.GetValue(column); + } + } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs index 42209783..3fa39d64 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs @@ -114,10 +114,12 @@ public override ICell CreateCell(IRow row) string? ITextSearchableColumn.SelectValue(TModel model) => Options.TextSearchValueSelector?.Invoke(model); - public bool IsFilterEnabled => Options?.IsTextSearchEnabled ?? false; + public bool IsFilterEnabled => Options?.IsFilterEnabled ?? false; + public bool PassesFilter(TModel model, object? condition) { - var value = Options.FilterValueSelector?.Invoke(model); + var value = Options.FilterValueSelector?.Invoke(model) ?? + (Options.TextSearchValueSelector != null ? Options.TextSearchValueSelector(model) : null); return Options.Filter?.Passes(condition, value) ?? true; } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs index 5999bb1f..f6246b96 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Controls.Templates; using Avalonia.Media; namespace Avalonia.Controls.Models.TreeDataGrid @@ -7,25 +8,52 @@ namespace Avalonia.Controls.Models.TreeDataGrid /// Holds less commonly-used options for a . /// /// The model type. - public class TemplateColumnOptions : ColumnOptions, ITemplateCellOptions + public class TemplateColumnOptions : ColumnOptions, ITemplateCellOptions, IFilterControlFactory { /// /// Gets or sets a value indicating whether the column takes part in text searches. /// public bool IsTextSearchEnabled { get; set; } + + /// + /// Gets or sets a value indicating whether filtering is enabled for this column. + /// public bool IsFilterEnabled { get; set; } /// /// Gets or sets a function which selects the search text from a model. /// public Func? TextSearchValueSelector { get; set; } - + /// /// Gets or sets a function which selects the filter value from a model. /// If null, TextSearchValueSelector will be used for filtering. /// public Func? FilterValueSelector { get; set; } - - public IValueFilter? Filter { get; set; } + + /// + /// Gets or sets the filter to use for this column. + /// + public IValueFilter? Filter { get; set; } + + + /// + /// Gets or sets a custom filter control factory to use for creating the filter control. + /// If null, a default text filter will be used. + /// + public IFilterControlFactory? CustomFilterFactory { get; set; } + + /// + /// Creates a filter control for this column. + /// + /// The column for which to create a filter control. + /// The initial filter value. + /// A filter control that can be used to filter the column. + public IFilterControl? CreateFilterControl(IColumn column, object? initialValue) + { + if (column is not TemplateColumn || !IsFilterEnabled) return null; + // Use the custom factory if one is provided + return CustomFilterFactory?.CreateFilterControl(column, initialValue); + } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs index afd79d91..974b21db 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs @@ -68,7 +68,7 @@ public override ICell CreateCell(IRow row) string? ITextSearchableColumn.SelectValue(TModel model) => ValueSelector(model)?.ToString(); - bool IFilterableColumn.IsFilterEnabled => Options?.IsFilterEnabled ?? false; + bool IFilterableColumn.IsFilterEnabled => Options?.IsFilterEnabled ?? false; bool IFilterableColumn.PassesFilter(TModel model, object? condition) { diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs index 9d87fa62..cc0dbfcf 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs @@ -8,12 +8,16 @@ namespace Avalonia.Controls.Models.TreeDataGrid /// Holds less commonly-used options for a . /// /// The model type. - public class TextColumnOptions : ColumnOptions, ITextCellOptions + public class TextColumnOptions : ColumnOptions, ITextCellOptions, IFilterControlFactory { /// /// Gets or sets a value indicating whether the column takes part in text searches. /// public bool IsTextSearchEnabled { get; set; } + + /// + /// Gets or sets a value indicating whether filtering is enabled for this column. + /// public bool IsFilterEnabled { get; set; } /// /// Gets or sets the format string for the cells in the column. @@ -40,6 +44,30 @@ public class TextColumnOptions : ColumnOptions, ITextCellOptions /// public TextAlignment TextAlignment { get; set; } = TextAlignment.Left; + /// + /// Gets or sets the filter to use for this column. + /// public IValueFilter? Filter { get; set; } = new TextValueFilter(); + + /// + /// Gets or sets the filter prompt text to show in the filter box watermark. + /// + public string FilterPrompt { get; set; } = "Filter..."; + + /// + /// Creates a filter control for this column. + /// + /// The column for which to create a filter control. + /// The initial filter value. + /// A filter control that can be used to filter the column. + public IFilterControl? CreateFilterControl(IColumn column, object? initialValue) + { + if (column is IFilterableColumn && IsFilterEnabled) + { + return new TextFilterControl(FilterPrompt, initialValue); + } + + return null; + } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs index 2361bd68..28b52018 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs @@ -1,6 +1,8 @@ using System; using System.ComponentModel; +using System.Linq; using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Utilities; @@ -27,23 +29,15 @@ public class TreeDataGridColumnHeader : Button AvaloniaProperty.RegisterDirect( nameof(ShowFilter), o => o.ShowFilter); - - public static readonly DirectProperty FilterTextProperty = - AvaloniaProperty.RegisterDirect( - nameof(FilterText), - o => o.FilterText, - (o, v) => o.FilterText = v); - + private bool _canUserResize; private IColumns? _columns; private object? _header; private IColumn? _model; private ListSortDirection? _sortDirection; - private bool _showFilter; private TreeDataGrid? _owner; private Thumb? _resizer; - private TextBox? _filterBox; - private string? _filterText; + private IFilterControl? _filterControl; public bool CanUserResize { @@ -65,34 +59,14 @@ public ListSortDirection? SortDirection private set => SetAndRaise(SortDirectionProperty, ref _sortDirection, value); } - public bool ShowFilter - { - get => _showFilter; - private set => SetAndRaise(ShowFilterProperty, ref _showFilter, value); - } + private IColumn? CurrentColumn => _columns?[ColumnIndex]; + + public bool ShowFilter => CurrentColumn is IFilterableColumn filterable && filterable.IsFilterEnabled; - public string? FilterText - { - get => _filterText; - set - { - if (SetAndRaise(FilterTextProperty, ref _filterText, value)) - { - // Update the UI if the filter box exists - if (_filterBox != null && _filterBox.Text != value) - { - _filterBox.Text = value ?? string.Empty; - } - - // Apply the filter - SetFilter(value); - } - } - } public void Realize(IColumns columns, int columnIndex) { - if (_model is object) + if (_model != null) throw new InvalidOperationException("Column header is already realized."); _columns = columns; @@ -125,7 +99,7 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) base.OnApplyTemplate(e); _resizer = e.NameScope.Find("PART_Resizer"); - _filterBox = e.NameScope.Find("PART_FilterBox"); + var filterContainer = e.NameScope.Find("PART_FilterBox"); if (_resizer is not null) { @@ -133,22 +107,20 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) _resizer.DoubleTapped += ResizerDoubleTapped; } - if (_filterBox is not null) - { - _filterBox.TextChanged += OnFilterTextChanged; - // If we already have a filter value, apply it to the textbox - if (!string.IsNullOrEmpty(_filterText)) - { - _filterBox.Text = _filterText; - } - _filterBox.KeyDown += OnFilterKeyDown; - } - // Only update filter if we have a model and owner if (_model != null && _owner != null) { UpdateFilter(); } + + if (filterContainer != null) + { + filterContainer.IsVisible = ShowFilter; + if (_filterControl != null) + { + filterContainer.Content = _filterControl.Control; + } + } } private void ResizerDoubleTapped(object? sender, Interactivity.RoutedEventArgs e) @@ -234,148 +206,72 @@ private void UpdatePropertiesFromModel() CanUserResize = _model?.CanUserResize ?? _owner?.CanUserResizeColumns ?? false; Header = _model?.Header; SortDirection = _model?.SortDirection; - + // Only update filter if we have all necessary references if (_model != null && _owner != null) { UpdateFilter(); } - else - { - ShowFilter = false; - } } private void UpdateFilter() { - var shouldShowFilter = _owner?.ShowColumnFilters == true && HasFilterEnabled(); - ShowFilter = shouldShowFilter; - - if (_filterBox != null && _model != null && shouldShowFilter) + // Find the ContentControl named PART_FilterBox + var contentControl = this.GetTemplateChildren().OfType() + .FirstOrDefault(x => x.Name == "PART_FilterBox"); + // Clean up any existing filter control + if (_filterControl != null) { - var currentFilter = GetCurrentFilter(); - _filterText = currentFilter; - _filterBox.Text = currentFilter ?? string.Empty; - _filterBox.Watermark = "Filter..."; - } - else if (_filterBox != null) - { - _filterBox.Text = string.Empty; - } - } + _filterControl.FilterValueChanged -= OnFilterValueChanged; - private bool HasFilterEnabled() - { - if (_model == null) - return false; - - // First check if it implements the new IFilterableColumn interface - if (IsFilterableColumn(_model)) - return true; - - // For backward compatibility, also check the legacy way with TextColumn - return IsTextColumnWithFilterEnabled(_model); - } + // Remove from visual tree if it was added - private static bool IsFilterableColumn(object column) - { - try - { - // Check if the column implements IFilterableColumn for any T - var columnType = column.GetType(); - foreach (var interfaceType in columnType.GetInterfaces()) + if (contentControl != null) { - if (interfaceType.IsGenericType && - interfaceType.GetGenericTypeDefinition() == typeof(IFilterableColumn<>)) - { - // Get the IsFilterEnabled property - var isFilterEnabledProperty = interfaceType.GetProperty("IsFilterEnabled"); - return (bool)(isFilterEnabledProperty?.GetValue(column) ?? false); - } + contentControl.Content = null; } + + _filterControl = null; } - catch - { - // If reflection fails, continue to the next check - } - - return false; - } - - private static bool IsTextColumnWithFilterEnabled(object column) - { - try - { - var type = column.GetType(); - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(TextColumn<,>)) - { - // Use the most specific Options property to avoid ambiguity - var optionsProperty = type.GetProperty("Options", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly); - if (optionsProperty?.GetValue(column) is object options) - { - var isFilterEnabledProperty = options.GetType().GetProperty("IsFilterEnabled"); - return (bool)(isFilterEnabledProperty?.GetValue(options) ?? false); - } - } - } - catch - { - // If reflection fails, just return false - } - return false; - } - private string? GetCurrentFilter() - { - if (_owner?.Source != null && _model != null) + if (_model == null) return; + var options = CurrentColumn?.ErasedOptions(); + + // Get the filter control factory from the column options + if (options is not IFilterControlFactory factory) return; + + // Create a filter control using the factory + _filterControl = factory.CreateFilterControl(_model, null); + + if (_filterControl == null) return; + + // Subscribe to filter value changes + _filterControl.FilterValueChanged += OnFilterValueChanged; + + // Add to visual tree + var control = _filterControl.Control; + + if (contentControl != null) { - var sourceType = _owner.Source.GetType(); - if (sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() == typeof(FlatTreeDataGridSource<>)) - { - try - { - var method = sourceType.GetMethod("GetColumnFilter"); - return method?.Invoke(_owner.Source, new[] { _model }) as string; - } - catch { } - } + contentControl.Content = control; } - return null; } - private void SetFilter(string? filterText) + private void OnFilterValueChanged(object? sender, FilterValueChangedEventArgs e) { + // Update the filter value if (_owner?.Source != null && _model != null) { var sourceType = _owner.Source.GetType(); - if (sourceType.IsGenericType && sourceType.GetGenericTypeDefinition() == typeof(FlatTreeDataGridSource<>)) + if (sourceType.IsGenericType && + sourceType.GetGenericTypeDefinition() == typeof(FlatTreeDataGridSource<>)) { - try - { - var method = sourceType.GetMethod("SetColumnFilter"); - method?.Invoke(_owner.Source, new object?[] { _model, filterText }); - } - catch { } + var method = sourceType.GetMethod("SetColumnFilter"); + // For backward compatibility, convert to string if needed + string? stringValue = e.FilterValue?.ToString(); + method?.Invoke(_owner.Source, new object?[] { _model, stringValue }); } } } - - private void OnFilterTextChanged(object? sender, TextChangedEventArgs e) - { - if (_filterBox != null) - { - // Update the property when the text box changes - FilterText = _filterBox.Text; - } - } - - private void OnFilterKeyDown(object? sender, KeyEventArgs e) - { - if (e.Key == Key.Escape && _filterBox != null) - { - _filterBox.Text = string.Empty; - e.Handled = true; - } - } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml b/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml index 878a83e1..d43b68e3 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml +++ b/src/Avalonia.Controls.TreeDataGrid/Themes/Generic.axaml @@ -128,13 +128,11 @@ - - + + From 17d3d8c6d27f8458ee90f0aacfe6509525b404d3 Mon Sep 17 00:00:00 2001 From: JakkuSakura Date: Tue, 2 Sep 2025 23:10:03 +0800 Subject: [PATCH 4/8] Refactor filtering interfaces and methods for improved clarity and functionality --- .../FlatTreeDataGridSource.cs | 20 +++-- .../ITreeDataGridSource.cs | 3 + .../Models/TreeDataGrid/IColumn.cs | 1 - .../TreeDataGrid/IFilterControlFactory.cs | 75 +------------------ .../Models/TreeDataGrid/IFilterableColumn.cs | 4 +- .../Models/TreeDataGrid/TemplateColumn.cs | 3 +- 6 files changed, 16 insertions(+), 90 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs index a707e844..c9ed0d26 100644 --- a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs @@ -182,12 +182,7 @@ bool ITreeDataGridSource.SortBy(IColumn? column, ListSortDirection direction) return false; } - IEnumerable ITreeDataGridSource.GetModelChildren(object model) - { - return Enumerable.Empty(); - } - - private void ApplyFilters() + public void Filter() { if (HasFilters()) { @@ -202,17 +197,20 @@ private void ApplyFilters() _rows?.SetItems(_itemsView); Filtered?.Invoke(); } + IEnumerable ITreeDataGridSource.GetModelChildren(object model) + { + return Enumerable.Empty(); + } + private bool PassesAllFilters(TModel model) { // Check immutable filter system first (only apply active filters) foreach (var column in Columns) { - if (column is IFilterableColumn col && col.IsFilterEnabled) - { - var cond = _filterConditions[col]; - if (!col.PassesFilter(model, cond)) return false; - } + if (column is IFilterableColumn { IsFilterEnabled: true } col) + if (!col.PassesFilter(model)) + return false; } diff --git a/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs index dd2c48ac..22b1fe0d 100644 --- a/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs @@ -41,6 +41,7 @@ public interface ITreeDataGridSource : INotifyPropertyChanged /// Event which would be triggered after SortBy method execution. /// event Action Sorted; + event Action Filtered; /// /// Executes a row drag/drop operation. @@ -76,6 +77,8 @@ void DragDropRows( /// The sort direction. /// True if the sort could be performed; otherwise false. bool SortBy(IColumn column, ListSortDirection direction); + + void Filter(); } /// diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs index 5251c4c9..1dadd538 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs @@ -48,6 +48,5 @@ public interface IColumn : INotifyPropertyChanged /// object? Tag { get; set; } - internal object? ErasedOptions(); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs index d44946a7..0907321b 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs @@ -13,12 +13,7 @@ public interface IFilterControl /// Gets the visual control element. /// Control Control { get; } - - /// - /// Gets or sets the current filter value. - /// - object? FilterValue { get; set; } - + /// /// Event raised when the filter value changes. /// @@ -167,72 +162,4 @@ public CheckBoxFilterControl(bool isThreeState, object? initialValue) }); } } - - /// - /// A wrapper for a direct control to be used as an IFilterControl. - /// - public class DirectControlWrapper : IFilterControl - { - private readonly Control _control; - private object? _filterValue; - - /// - /// Gets the visual control element. - /// - public Control Control => _control; - - /// - /// Gets or sets the current filter value. - /// - public object? FilterValue - { - get => _filterValue; - set => _filterValue = value; - } - - /// - /// Event raised when the filter value changes. - /// - public event EventHandler? FilterValueChanged; - - /// - /// Initializes a new instance of the class. - /// - /// The control to wrap. - public DirectControlWrapper(Control control) - { - _control = control; - } - - /// - /// Raises the FilterValueChanged event. - /// - /// The new filter value. - public void RaiseFilterValueChanged(object? value) - { - _filterValue = value; - FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs(value)); - } - } - - /// - /// Extension methods for working with columns. - /// - public static class ColumnExtensions - { - /// - /// Gets the erased options for a column. - /// - /// The column. - /// The column options, or null if the column doesn't have options. - public static object? ErasedOptions(this IColumn? column) - { - if (column == null) - return null; - - // Try to get the Options property using reflection - var optionsProperty = column.GetType().GetProperty("Options"); - return optionsProperty?.GetValue(column); - } - } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs index 6196fd45..0d984c4e 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs @@ -12,8 +12,8 @@ public interface IFilterableColumn /// Interface for columns that support text-based filtering. /// /// The model type. - public interface IFilterableColumn: IFilterableColumn + public interface IFilterableColumn: IFilterableColumn { - bool PassesFilter(IModel model, object? condition); + bool PassesFilter(TModel model); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs index 3fa39d64..1ac73cea 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs @@ -118,8 +118,7 @@ public override ICell CreateCell(IRow row) public bool PassesFilter(TModel model, object? condition) { - var value = Options.FilterValueSelector?.Invoke(model) ?? - (Options.TextSearchValueSelector != null ? Options.TextSearchValueSelector(model) : null); + var value = Options.FilterValueSelector?.Invoke(model); return Options.Filter?.Passes(condition, value) ?? true; } } From 0fed5d06eeb198dd0a96906620f5e5b170fb486f Mon Sep 17 00:00:00 2001 From: JakkuSakura Date: Tue, 2 Sep 2025 23:32:25 +0800 Subject: [PATCH 5/8] Enhance filtering system by adding support for custom filter conditions and updating related interfaces --- .../FlatTreeDataGridSource.cs | 39 ++++++------------- .../HierarchicalTreeDataGridSource.cs | 6 +++ .../ITreeDataGridSource.cs | 4 +- .../Models/TreeDataGrid/IColumn.cs | 1 + .../TreeDataGrid/IFilterControlFactory.cs | 20 ++++++---- .../Models/TreeDataGrid/IFilterableColumn.cs | 2 +- .../Models/TreeDataGrid/TextColumnOptions.cs | 4 +- .../Primitives/TreeDataGridColumnHeader.cs | 26 +++++-------- 8 files changed, 44 insertions(+), 58 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs index c9ed0d26..62654b43 100644 --- a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs @@ -24,14 +24,12 @@ public class FlatTreeDataGridSource : NotifyingBase, private IComparer? _comparer; private ITreeDataGridSelection? _selection; private bool _isSelectionSet; - private readonly Dictionary, object?> _filterConditions = new(); public FlatTreeDataGridSource(IEnumerable items) { _items = items; _itemsView = TreeDataGridItemsSourceView.GetOrCreate(items); Columns = new ColumnList(); - Columns.CollectionChanged += OnColumnsCollectionChanged; } public ColumnList Columns { get; } @@ -182,34 +180,32 @@ bool ITreeDataGridSource.SortBy(IColumn? column, ListSortDirection direction) return false; } - public void Filter() + public void Filter(IDictionary conditions) { - if (HasFilters()) - { - var filteredItems = _items.Where(PassesAllFilters); - _itemsView = TreeDataGridItemsSourceView.GetOrCreate(filteredItems); - } - else + var newConditions = new Dictionary, object?>(); + foreach (var condition in conditions) { - _itemsView = TreeDataGridItemsSourceView.GetOrCreate(_items); + newConditions.Add((IFilterableColumn)condition.Key, condition.Value); } - + var filteredItems = _items.Where(item => PassesAllFilters(item, newConditions)); + _itemsView = TreeDataGridItemsSourceView.GetOrCreate(filteredItems); _rows?.SetItems(_itemsView); Filtered?.Invoke(); } + IEnumerable ITreeDataGridSource.GetModelChildren(object model) { return Enumerable.Empty(); } - private bool PassesAllFilters(TModel model) + private bool PassesAllFilters(TModel model, Dictionary, object?> conditions) { // Check immutable filter system first (only apply active filters) - foreach (var column in Columns) + foreach (var kvp in conditions) { - if (column is IFilterableColumn { IsFilterEnabled: true } col) - if (!col.PassesFilter(model)) + if (kvp.Key.IsFilterEnabled) + if (!kvp.Key.PassesFilter(model, kvp.Value)) return false; } @@ -217,19 +213,6 @@ private bool PassesAllFilters(TModel model) return true; } - private void OnColumnsCollectionChanged(object? sender, - System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - { - // Clear filters for removed columns - if (e.OldItems != null) - { - foreach (var item in e.OldItems.OfType>()) - { - _filterConditions.Remove(item); - } - } - } - private AnonymousSortableRows CreateRows() { return new AnonymousSortableRows(_itemsView, _comparer); diff --git a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs index 6689e5af..c39f9f78 100644 --- a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs @@ -98,6 +98,7 @@ public ITreeDataGridSelection? Selection public event EventHandler>>? RowCollapsing; public event EventHandler>>? RowCollapsed; public event Action? Sorted; + // public event Action? Filtered; public void Dispose() { @@ -217,6 +218,11 @@ public bool SortBy(IColumn? column, ListSortDirection direction) return false; } + public void Filter(IDictionary _conditions) + { + throw new NotImplementedException(); + } + void ITreeDataGridSource.DragDropRows( ITreeDataGridSource source, IEnumerable indexes, diff --git a/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs index 22b1fe0d..55a95ce1 100644 --- a/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs @@ -41,7 +41,7 @@ public interface ITreeDataGridSource : INotifyPropertyChanged /// Event which would be triggered after SortBy method execution. /// event Action Sorted; - event Action Filtered; + // event Action Filtered; /// /// Executes a row drag/drop operation. @@ -78,7 +78,7 @@ void DragDropRows( /// True if the sort could be performed; otherwise false. bool SortBy(IColumn column, ListSortDirection direction); - void Filter(); + void Filter(IDictionary conditions); } /// diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs index 1dadd538..051c145a 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IColumn.cs @@ -48,5 +48,6 @@ public interface IColumn : INotifyPropertyChanged /// object? Tag { get; set; } + object? ErasedOptions(); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs index 0907321b..5abf82e1 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs @@ -25,18 +25,20 @@ public interface IFilterControl /// public class FilterValueChangedEventArgs : EventArgs { + public IFilterableColumn Column { get; } /// /// Gets the new filter value. /// - public object? FilterValue { get; } + public object? FilterCondition { get; } /// /// Initializes a new instance of the class. /// - /// The new filter value. - public FilterValueChangedEventArgs(object? filterValue) + /// The new filter value. + public FilterValueChangedEventArgs(IFilterableColumn column, object? filterCondition) { - FilterValue = filterValue; + Column = column; + FilterCondition = filterCondition; } } @@ -79,13 +81,14 @@ public object? FilterValue /// Event raised when the filter value changes. /// public event EventHandler? FilterValueChanged; - + /// /// Initializes a new instance of the class. /// + /// /// The watermark to display in the text box. /// The initial filter value. - public TextFilterControl(string watermark, object? initialValue) + public TextFilterControl(IFilterableColumn column, string watermark, object? initialValue) { _textBox = new TextBox { @@ -99,6 +102,7 @@ public TextFilterControl(string watermark, object? initialValue) _textBox.GetObservable(TextBox.TextProperty).Subscribe(text => { FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs( + column, string.IsNullOrWhiteSpace(text) ? null : text)); }); } @@ -135,7 +139,7 @@ public object? FilterValue /// /// Whether the checkbox should support three states. /// The initial filter value. - public CheckBoxFilterControl(bool isThreeState, object? initialValue) + public CheckBoxFilterControl(IFilterableColumn column, bool isThreeState, object? initialValue) { bool? initialChecked = null; if (initialValue is bool boolValue) @@ -158,7 +162,7 @@ public CheckBoxFilterControl(bool isThreeState, object? initialValue) _checkBox.GetObservable(ToggleButton.IsCheckedProperty).Subscribe(isChecked => { - FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs(isChecked)); + FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs(column, isChecked)); }); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs index 0d984c4e..0da45a2b 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterableColumn.cs @@ -14,6 +14,6 @@ public interface IFilterableColumn /// The model type. public interface IFilterableColumn: IFilterableColumn { - bool PassesFilter(TModel model); + bool PassesFilter(TModel model, object? condition); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs index cc0dbfcf..a9ccf2d4 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs @@ -62,9 +62,9 @@ public class TextColumnOptions : ColumnOptions, ITextCellOptions /// A filter control that can be used to filter the column. public IFilterControl? CreateFilterControl(IColumn column, object? initialValue) { - if (column is IFilterableColumn && IsFilterEnabled) + if (column is IFilterableColumn col && IsFilterEnabled) { - return new TextFilterControl(FilterPrompt, initialValue); + return new TextFilterControl(col, FilterPrompt, initialValue); } return null; diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs index 28b52018..0336ff1c 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Linq; using Avalonia.Controls.Models.TreeDataGrid; @@ -58,10 +59,8 @@ public ListSortDirection? SortDirection get => _sortDirection; private set => SetAndRaise(SortDirectionProperty, ref _sortDirection, value); } - - private IColumn? CurrentColumn => _columns?[ColumnIndex]; - - public bool ShowFilter => CurrentColumn is IFilterableColumn filterable && filterable.IsFilterEnabled; + + public bool ShowFilter => _model is IFilterableColumn filterable && filterable.IsFilterEnabled; public void Realize(IColumns columns, int columnIndex) @@ -235,7 +234,7 @@ private void UpdateFilter() } if (_model == null) return; - var options = CurrentColumn?.ErasedOptions(); + var options = _model.ErasedOptions(); // Get the filter control factory from the column options if (options is not IFilterControlFactory factory) return; @@ -257,21 +256,14 @@ private void UpdateFilter() } } + private Dictionary _filterConditions = new(); + private void OnFilterValueChanged(object? sender, FilterValueChangedEventArgs e) { // Update the filter value - if (_owner?.Source != null && _model != null) - { - var sourceType = _owner.Source.GetType(); - if (sourceType.IsGenericType && - sourceType.GetGenericTypeDefinition() == typeof(FlatTreeDataGridSource<>)) - { - var method = sourceType.GetMethod("SetColumnFilter"); - // For backward compatibility, convert to string if needed - string? stringValue = e.FilterValue?.ToString(); - method?.Invoke(_owner.Source, new object?[] { _model, stringValue }); - } - } + if (_owner?.Source == null || _model == null) return; + _filterConditions[e.Column] = e.FilterCondition; + } } } From 52d61c6cf060218491534ed261174b95eaabb53b Mon Sep 17 00:00:00 2001 From: JakkuSakura Date: Wed, 3 Sep 2025 00:30:51 +0800 Subject: [PATCH 6/8] Enhance filtering system by introducing customizable text filtering options and improving filter control creation --- .../ViewModels/CountriesPageViewModel.cs | 53 ++-- .../TreeDataGrid/CheckBoxColumnOptions.cs | 8 +- .../Models/TreeDataGrid/Filters.cs | 68 ++++- .../TreeDataGrid/IFilterControlFactory.cs | 277 +++++++++--------- .../Models/TreeDataGrid/TemplateColumn.cs | 2 +- .../TreeDataGrid/TemplateColumnOptions.cs | 23 +- .../Models/TreeDataGrid/TextColumn.cs | 2 +- .../Models/TreeDataGrid/TextColumnOptions.cs | 23 +- .../Primitives/TreeDataGridColumnHeader.cs | 4 +- 9 files changed, 264 insertions(+), 196 deletions(-) diff --git a/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs b/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs index 190b743b..87e956b4 100644 --- a/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs +++ b/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs @@ -25,54 +25,49 @@ public CountriesPageViewModel() { // Text column with advanced filter new TextColumn( - "Country", - x => x.Name, - (r, v) => r.Name = v, - new GridLength(6, GridUnitType.Star), + "Country", + x => x.Name, + (r, v) => r.Name = v, + new GridLength(6, GridUnitType.Star), new TextColumnOptions { IsTextSearchEnabled = true, IsFilterEnabled = true // This will create a TextFilter automatically }), - + // Use a template column with filtering for region new TemplateColumn( - "Region", + "Region", "RegionCell", "RegionEditCell", new GridLength(3, GridUnitType.Star), new TemplateColumnOptions { - IsFilterEnabled = true, - FilterValueSelector = x => x.Region // Value selector for filtering + // Value selector for filtering + FilterValueSelector = x => x.Region, + Filter = new TextValueFilter(), + FilterControlFactory = (col) => new TextFilterControl(col, "custom filter..."), }), - + // Population column with numeric filtering new TextColumn( - "Population", - x => x.Population, - new GridLength(3, GridUnitType.Star), - new TextColumnOptions - { - IsFilterEnabled = true - }), - + "Population", + x => x.Population, + new GridLength(3, GridUnitType.Star), + new TextColumnOptions { IsFilterEnabled = true }), + // Area column with numeric filtering new TextColumn( - "Area", - x => x.Area, - new GridLength(3, GridUnitType.Star), - new TextColumnOptions - { - IsFilterEnabled = true, - StringFormat = "{0:N0}" - }), - + "Area", + x => x.Area, + new GridLength(3, GridUnitType.Star), + new TextColumnOptions { IsFilterEnabled = true, StringFormat = "{0:N0}" }), + // GDP column new TextColumn( - "GDP", - x => x.GDP, - new GridLength(3, GridUnitType.Star), + "GDP", + x => x.GDP, + new GridLength(3, GridUnitType.Star), new TextColumnOptions { TextAlignment = TextAlignment.Right, diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs index 3ce95a22..4d2cddab 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace Avalonia.Controls.Models.TreeDataGrid { @@ -30,11 +30,11 @@ public class CheckBoxColumnOptions : ColumnOptions, IFilterContr /// The column for which to create a filter control. /// The initial filter value. /// A filter control that can be used to filter the column. - public IFilterControl? CreateFilterControl(IColumn column, object? initialValue) + public IFilterControl? CreateFilterControl(IColumn column) { - if (column is CheckBoxColumn && IsFilterEnabled) + if (column is CheckBoxColumn col && IsFilterEnabled) { - return new CheckBoxFilterControl(IsThreeStateFilter, initialValue); + return new CheckBoxFilterControl(col, IsThreeStateFilter, null); } return null; diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/Filters.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/Filters.cs index 590c7af0..3cc36852 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/Filters.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/Filters.cs @@ -4,11 +4,60 @@ namespace Avalonia.Controls.Models.TreeDataGrid; +/// +/// Defines the mode of text filtering. +/// +public enum TextFilterMode +{ + /// + /// Check if the value contains the filter text. + /// + Contains, + + /// + /// Check if the value starts with the filter text. + /// + StartsWith, + + /// + /// Check if the value ends with the filter text. + /// + EndsWith +} + /// /// A filter that checks text values. /// public class TextValueFilter : IValueFilter { + /// + /// Gets or sets the filter mode to use. + /// + public TextFilterMode FilterMode { get; set; } = TextFilterMode.Contains; + + /// + /// Gets or sets whether filtering is case sensitive. + /// + public bool CaseSensitive { get; set; } = false; + + /// + /// Initializes a new instance of with default settings. + /// + public TextValueFilter() + { + } + + /// + /// Initializes a new instance of with specified filter mode. + /// + /// The filter mode to use. + /// Whether filtering is case sensitive. + public TextValueFilter(TextFilterMode mode, bool caseSensitive = false) + { + FilterMode = mode; + CaseSensitive = caseSensitive; + } + /// /// Determines if the value passes the filter. /// @@ -19,8 +68,23 @@ public bool Passes(object? condition, string? value) { if (condition is not string filterText || string.IsNullOrWhiteSpace(filterText)) return true; - - return value != null && value.Contains(filterText, StringComparison.OrdinalIgnoreCase); + + if (value == null) + return false; + + // Determine string comparison + StringComparison comparison = CaseSensitive ? + StringComparison.Ordinal : + StringComparison.OrdinalIgnoreCase; + + // Apply filter based on mode + return FilterMode switch + { + TextFilterMode.Contains => value.Contains(filterText, comparison), + TextFilterMode.StartsWith => value.StartsWith(filterText, comparison), + TextFilterMode.EndsWith => value.EndsWith(filterText, comparison), + _ => value.Contains(filterText, comparison) // Default to Contains + }; } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs index 5abf82e1..8ef69f96 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs @@ -2,168 +2,167 @@ using Avalonia.Controls.Primitives; using Avalonia.Layout; -namespace Avalonia.Controls.Models.TreeDataGrid +namespace Avalonia.Controls.Models.TreeDataGrid; + +/// +/// Interface for filter controls that can be added to column headers. +/// +public interface IFilterControl { /// - /// Interface for filter controls that can be added to column headers. + /// Gets the visual control element. + /// + Control Control { get; } + + /// + /// Event raised when the filter value changes. + /// + event EventHandler? FilterValueChanged; +} + +/// +/// Event arguments for filter value changes. +/// +public class FilterValueChangedEventArgs : EventArgs +{ + public IFilterableColumn Column { get; } + + /// + /// Gets the new filter value. + /// + public object? FilterCondition { get; } + + /// + /// Initializes a new instance of the class. /// - public interface IFilterControl + /// The new filter value. + public FilterValueChangedEventArgs(IFilterableColumn column, object? filterCondition) { - /// - /// Gets the visual control element. - /// - Control Control { get; } - - /// - /// Event raised when the filter value changes. - /// - event EventHandler? FilterValueChanged; + Column = column; + FilterCondition = filterCondition; } - +} + +/// +/// Interface for creating filter controls for column headers. +/// +public interface IFilterControlFactory +{ /// - /// Event arguments for filter value changes. + /// Creates a filter control for a column header. /// - public class FilterValueChangedEventArgs : EventArgs + /// The column for which to create a filter control. + /// A filter control that can be used to filter the column, or null if no filter is available. + IFilterControl? CreateFilterControl(IColumn column); +} + +/// +/// A text filter control implementation. +/// +public class TextFilterControl : IFilterControl +{ + private readonly TextBox _textBox; + + /// + /// Gets the visual control element. + /// + public Control Control => _textBox; + + /// + /// Gets or sets the current filter value. + /// + public object? FilterValue { - public IFilterableColumn Column { get; } - /// - /// Gets the new filter value. - /// - public object? FilterCondition { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The new filter value. - public FilterValueChangedEventArgs(IFilterableColumn column, object? filterCondition) - { - Column = column; - FilterCondition = filterCondition; - } + get => _textBox.Text; + set => _textBox.Text = value?.ToString() ?? string.Empty; } - + /// - /// Interface for creating filter controls for column headers. + /// Event raised when the filter value changes. /// - public interface IFilterControlFactory + public event EventHandler? FilterValueChanged; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The watermark to display in the text box. + /// The initial filter value. + public TextFilterControl(IFilterableColumn column, string watermark, string initialValue = "") { - /// - /// Creates a filter control for a column header. - /// - /// The column for which to create a filter control. - /// The initial filter value. - /// A filter control that can be used to filter the column, or null if no filter is available. - IFilterControl? CreateFilterControl(IColumn column, object? initialValue); + _textBox = new TextBox + { + Text = initialValue, + Watermark = watermark, + Margin = new Thickness(2, 1), + FontSize = 11, + Height = 20 + }; + + _textBox.GetObservable(TextBox.TextProperty).Subscribe(text => + { + FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs( + column, + string.IsNullOrWhiteSpace(text) ? null : text)); + }); } +} + +/// +/// A checkbox filter control implementation. +/// +public class CheckBoxFilterControl : IFilterControl +{ + private readonly CheckBox _checkBox; + + /// + /// Gets the visual control element. + /// + public Control Control => _checkBox; /// - /// A text filter control implementation. + /// Gets or sets the current filter value. /// - public class TextFilterControl : IFilterControl + public object? FilterValue { - private readonly TextBox _textBox; - - /// - /// Gets the visual control element. - /// - public Control Control => _textBox; - - /// - /// Gets or sets the current filter value. - /// - public object? FilterValue - { - get => _textBox.Text; - set => _textBox.Text = value?.ToString() ?? string.Empty; - } - - /// - /// Event raised when the filter value changes. - /// - public event EventHandler? FilterValueChanged; - - /// - /// Initializes a new instance of the class. - /// - /// - /// The watermark to display in the text box. - /// The initial filter value. - public TextFilterControl(IFilterableColumn column, string watermark, object? initialValue) - { - _textBox = new TextBox - { - Text = initialValue?.ToString() ?? string.Empty, - Watermark = watermark, - Margin = new Thickness(2, 1), - FontSize = 11, - Height = 20 - }; - - _textBox.GetObservable(TextBox.TextProperty).Subscribe(text => - { - FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs( - column, - string.IsNullOrWhiteSpace(text) ? null : text)); - }); - } + get => _checkBox.IsChecked; + set => _checkBox.IsChecked = value as bool?; } - + /// - /// A checkbox filter control implementation. + /// Event raised when the filter value changes. /// - public class CheckBoxFilterControl : IFilterControl + public event EventHandler? FilterValueChanged; + + /// + /// Initializes a new instance of the class. + /// + /// Whether the checkbox should support three states. + /// The initial filter value. + public CheckBoxFilterControl(IFilterableColumn column, bool isThreeState, object? initialValue) { - private readonly CheckBox _checkBox; - - /// - /// Gets the visual control element. - /// - public Control Control => _checkBox; - - /// - /// Gets or sets the current filter value. - /// - public object? FilterValue - { - get => _checkBox.IsChecked; - set => _checkBox.IsChecked = value as bool?; + bool? initialChecked = null; + if (initialValue is bool boolValue) + { + initialChecked = boolValue; } - - /// - /// Event raised when the filter value changes. - /// - public event EventHandler? FilterValueChanged; - - /// - /// Initializes a new instance of the class. - /// - /// Whether the checkbox should support three states. - /// The initial filter value. - public CheckBoxFilterControl(IFilterableColumn column, bool isThreeState, object? initialValue) + else if (initialValue is string strValue && bool.TryParse(strValue, out var parsedValue)) { - bool? initialChecked = null; - if (initialValue is bool boolValue) - { - initialChecked = boolValue; - } - else if (initialValue is string strValue && bool.TryParse(strValue, out var parsedValue)) - { - initialChecked = parsedValue; - } - - _checkBox = new CheckBox - { - IsThreeState = isThreeState, - IsChecked = initialChecked, - Margin = new Thickness(4), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - }; - - _checkBox.GetObservable(ToggleButton.IsCheckedProperty).Subscribe(isChecked => - { - FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs(column, isChecked)); - }); + initialChecked = parsedValue; } + + _checkBox = new CheckBox + { + IsThreeState = isThreeState, + IsChecked = initialChecked, + Margin = new Thickness(4), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + _checkBox.GetObservable(ToggleButton.IsCheckedProperty).Subscribe(isChecked => + { + FilterValueChanged?.Invoke(this, new FilterValueChangedEventArgs(column, isChecked)); + }); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs index 1ac73cea..8558843f 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumn.cs @@ -114,7 +114,7 @@ public override ICell CreateCell(IRow row) string? ITextSearchableColumn.SelectValue(TModel model) => Options.TextSearchValueSelector?.Invoke(model); - public bool IsFilterEnabled => Options?.IsFilterEnabled ?? false; + public bool IsFilterEnabled => Options?.Filter is not null; public bool PassesFilter(TModel model, object? condition) { diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs index f6246b96..0bfea720 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TemplateColumnOptions.cs @@ -14,11 +14,7 @@ public class TemplateColumnOptions : ColumnOptions, ITemplateCel /// Gets or sets a value indicating whether the column takes part in text searches. /// public bool IsTextSearchEnabled { get; set; } - - /// - /// Gets or sets a value indicating whether filtering is enabled for this column. - /// - public bool IsFilterEnabled { get; set; } + /// /// Gets or sets a function which selects the search text from a model. @@ -36,24 +32,19 @@ public class TemplateColumnOptions : ColumnOptions, ITemplateCel /// public IValueFilter? Filter { get; set; } - - /// - /// Gets or sets a custom filter control factory to use for creating the filter control. - /// If null, a default text filter will be used. - /// - public IFilterControlFactory? CustomFilterFactory { get; set; } + public Func? FilterControlFactory { get; set; } /// /// Creates a filter control for this column. /// /// The column for which to create a filter control. - /// The initial filter value. /// A filter control that can be used to filter the column. - public IFilterControl? CreateFilterControl(IColumn column, object? initialValue) + public IFilterControl? CreateFilterControl(IColumn column) { - if (column is not TemplateColumn || !IsFilterEnabled) return null; - // Use the custom factory if one is provided - return CustomFilterFactory?.CreateFilterControl(column, initialValue); + if (Filter == null) return null; + if (FilterControlFactory == null) return null; + if (!(column is TemplateColumn col)) return null; + return FilterControlFactory?.Invoke(col); } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs index 974b21db..4ac48a90 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumn.cs @@ -73,7 +73,7 @@ public override ICell CreateCell(IRow row) bool IFilterableColumn.PassesFilter(TModel model, object? condition) { var value = ValueSelector(model); - return Options?.Filter?.Passes(value, condition) ?? true; + return Options?.Filter?.Passes(condition, value) ?? true; } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs index a9ccf2d4..cd6970e7 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using Avalonia.Media; @@ -49,6 +49,16 @@ public class TextColumnOptions : ColumnOptions, ITextCellOptions /// public IValueFilter? Filter { get; set; } = new TextValueFilter(); + /// + /// Gets or sets the text filter mode to use. + /// + public TextFilterMode TextFilterMode { get; set; } = TextFilterMode.Contains; + + /// + /// Gets or sets whether text filtering is case sensitive. + /// + public bool TextFilterCaseSensitive { get; set; } = false; + /// /// Gets or sets the filter prompt text to show in the filter box watermark. /// @@ -60,11 +70,18 @@ public class TextColumnOptions : ColumnOptions, ITextCellOptions /// The column for which to create a filter control. /// The initial filter value. /// A filter control that can be used to filter the column. - public IFilterControl? CreateFilterControl(IColumn column, object? initialValue) + public IFilterControl? CreateFilterControl(IColumn column) { if (column is IFilterableColumn col && IsFilterEnabled) { - return new TextFilterControl(col, FilterPrompt, initialValue); + // Apply the configured filter settings if using TextValueFilter + if (Filter is TextValueFilter textFilter) + { + textFilter.FilterMode = TextFilterMode; + textFilter.CaseSensitive = TextFilterCaseSensitive; + } + + return new TextFilterControl(col, FilterPrompt, ""); } return null; diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs index 0336ff1c..6952eb00 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs @@ -240,7 +240,7 @@ private void UpdateFilter() if (options is not IFilterControlFactory factory) return; // Create a filter control using the factory - _filterControl = factory.CreateFilterControl(_model, null); + _filterControl = factory.CreateFilterControl(_model); if (_filterControl == null) return; @@ -264,6 +264,8 @@ private void OnFilterValueChanged(object? sender, FilterValueChangedEventArgs e) if (_owner?.Source == null || _model == null) return; _filterConditions[e.Column] = e.FilterCondition; + // Apply the filter to the data source + _owner.Source.Filter(_filterConditions); } } } From c316f3622e1119405c50e557e8a744c916887879 Mon Sep 17 00:00:00 2001 From: JakkuSakura Date: Thu, 4 Sep 2025 11:21:04 +0800 Subject: [PATCH 7/8] Enhance TreeDataGridColumnHeader by adding a header button for sorting and updating filter control visibility --- .../Primitives/TreeDataGridColumnHeader.cs | 51 ++++++++- .../Themes/Generic.axaml | 108 +++++++++++------- 2 files changed, 112 insertions(+), 47 deletions(-) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs index 6952eb00..b966cbb7 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs @@ -5,12 +5,18 @@ using Avalonia.Controls.Models.TreeDataGrid; using Avalonia.Controls.Templates; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Utilities; namespace Avalonia.Controls.Primitives { - public class TreeDataGridColumnHeader : Button + public class TreeDataGridColumnHeader : UserControl { + public static readonly RoutedEvent ClickEvent = + RoutedEvent.Register( + nameof(Button.Click), + RoutingStrategies.Bubble); + public static readonly DirectProperty CanUserResizeProperty = AvaloniaProperty.RegisterDirect( nameof(CanUserResize), @@ -38,6 +44,7 @@ public class TreeDataGridColumnHeader : Button private ListSortDirection? _sortDirection; private TreeDataGrid? _owner; private Thumb? _resizer; + private Button? _headerButton; private IFilterControl? _filterControl; public bool CanUserResize @@ -98,6 +105,7 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) base.OnApplyTemplate(e); _resizer = e.NameScope.Find("PART_Resizer"); + _headerButton = e.NameScope.Find @@ -151,6 +159,18 @@ + + + +