diff --git a/docs/filtering.md b/docs/filtering.md new file mode 100644 index 00000000..e8062688 --- /dev/null +++ b/docs/filtering.md @@ -0,0 +1,183 @@ +# TreeDataGrid Filtering + +The TreeDataGrid control includes a powerful and flexible filtering system that allows users to filter data across columns. This document explains how to use the filtering system and customize it for your needs. + +## Basic Filtering + +TreeDataGrid supports filtering on text columns. To enable filtering: + +1. Make sure `ShowColumnFilters` is `true` on the TreeDataGrid (this is the default) +2. Enable filtering for specific columns by setting `IsFilterEnabled` to `true` in the column options: + +```csharp +var grid = new TreeDataGrid(); + +var columns = new ColumnList( + new TextColumn( + "Name", + x => x.Name, + options: new TextColumnOptions { IsFilterEnabled = true } + ), + new TextColumn( + "Email", + x => x.Email, + options: new TextColumnOptions { IsFilterEnabled = true } + ) +); + +var source = new FlatTreeDataGridSource(people) { Columns = columns }; +grid.Source = source; +``` + +This will display filter text boxes below each column header that has filtering enabled. The filter controls will expand to take the full width of the column, ensuring consistent layout and better usability. + +**Note:** For standard columns like TextColumn and CheckBoxColumn, setting IsFilterEnabled to true is sufficient. However, for TemplateColumn, you must specify all three properties: FilterValueSelector (to extract the value), Filter (to define how to filter), and FilterControlFactory (to create the filter UI). + +## Column Header Filter Layout + +The TreeDataGridColumnHeader is implemented as a vertical StackPanel that contains: + +1. A header button for displaying the column title and handling sorting +2. A filter control that appears when filtering is enabled + +This layout ensures that both the header and filter components take the full width of the column, providing a clean and consistent user interface. The filter control appears directly below the header button when `IsFilterEnabled` is set to `true`. + +## Programmatic Access to Filters + +You can interact with filters programmatically by accessing the column headers and their filter controls: + +```csharp +// Get a reference to the column header +var nameColumnHeader = grid.ColumnHeadersPresenter? + .TryGetElement(0) as TreeDataGridColumnHeader; + +// Access the filter control (if needed) +if (nameColumnHeader != null) +{ + // You can apply filters programmatically through the TreeDataGridSource + var column = grid.Source.Columns[0]; + grid.Source.SetFilterCondition(column, "Search term"); +} +``` + +## Custom Filter Column Types + +The TreeDataGrid supports different types of column filtering through the following systems: + +1. **Value Filter System (Recommended)**: Uses the `IValueFilter` interface +2. **Legacy System**: Uses the `IFilterableColumn` interface + +### Using the Value Filter System + +The value filter system is the recommended approach for new code. It provides a more flexible and extensible way to define filters: + +```csharp +// Define the filter interface +public interface IValueFilter +{ + bool Passes(object? condition, TValue? value); +} +``` + +Every column can use a filter by setting options. The TreeDataGrid provides several built-in filter implementations: + +- `TextValueFilter` - For text-based filtering +- `BooleanValueFilter` - For boolean/checkbox filtering +- `SetValueFilter` - For filtering based on a set of allowed values + +#### Example: Using Text Filter + +```csharp +var nameColumn = new TextColumn( + "Name", + x => x.Name, + options: new TextColumnOptions { IsFilterEnabled = true } +); +// Filter is automatically created when IsFilterEnabled is true + +// Or set a filter manually in the options: +var options = new TextColumnOptions { + IsFilterEnabled = true, + Filter = new TextValueFilter(TextFilterMode.Contains, false) +}; +``` + +#### Example: Using Template Column with Filter + +```csharp +var regionColumn = new TemplateColumn( + "Region", + "RegionCellTemplate", // Template resource key + "RegionEditCellTemplate", // Edit template resource key + options: new TemplateColumnOptions + { + // While IsFilterEnabled is used for TextColumn, it's not required for TemplateColumn when all three properties below are specified + // FilterValueSelector is required to extract the value to filter on + FilterValueSelector = x => x.Region, + // Filter specifies how to filter the values + Filter = new TextValueFilter(), + // FilterControlFactory is required to create the filter UI + FilterControlFactory = (col) => new TextFilterControl(col, "custom filter...") + } +); +// For TemplateColumn, all three properties above should be specified +``` + + +#### Example: Using Boolean Filter + +```csharp +var activeColumn = new CheckBoxColumn( + "Active", + x => x.IsActive, + options: new CheckBoxColumnOptions { IsFilterEnabled = true } +); +// The BooleanValueFilter is automatically created when IsFilterEnabled is true +``` + + +### The `TextValueFilter` supports different filter modes: +- `Contains` - Checks if the value contains the filter text (default) +- `StartsWith` - Checks if the value starts with the filter text +- `EndsWith` - Checks if the value ends with the filter text + +You can configure these modes when creating a filter: + +### Creating Custom Filter Columns + +Columns implement the `IFilterableColumn` interface to provide filtering: + +```csharp +public interface IFilterableColumn +{ + /// + /// Gets a value indicating whether filtering is enabled for this column. + /// + bool IsFilterEnabled { get; } +} + +public interface IFilterableColumn: IFilterableColumn +{ + /// + /// Determines if the model passes the filter. + /// + /// The model to check. + /// The filter condition to apply. + /// True if the model passes the filter, otherwise false. + bool PassesFilter(TModel model, object? condition); +} +``` + + +### Performance Considerations + +For large datasets, consider these performance tips: + +1. If possible, filter data at the source (database query) rather than in-memory +2. Implement debounced filtering (apply filter after typing stops) +3. Keep filter implementations efficient +4. Consider caching filter results when appropriate + +## Complete Example + +See the `CustomFilteringSample.cs` in the samples directory for a complete example of implementing custom filtering UI with TreeDataGrid. \ No newline at end of file diff --git a/samples/TreeDataGridDemo/MainWindow.axaml b/samples/TreeDataGridDemo/MainWindow.axaml index c3f192f7..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 @@ -28,7 +38,8 @@ + AutoDragDropRows="True" + ShowColumnFilters="True"> diff --git a/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs b/samples/TreeDataGridDemo/ViewModels/CountriesPageViewModel.cs index 2be1344c..87e956b4 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,18 +23,58 @@ public CountriesPageViewModel() { Columns = { - new TextColumn("Country", x => x.Name, (r, v) => r.Name = v, new GridLength(6, GridUnitType.Star), new() - { - IsTextSearchEnabled = 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("GDP", x => x.GDP, new GridLength(3, GridUnitType.Star), new() - { - TextAlignment = Avalonia.Media.TextAlignment.Right, - MaxWidth = new GridLength(150) - }), + // 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 + { + // 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 }), + + // 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 1ed1e6c1..62654b43 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; @@ -76,12 +76,31 @@ 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 HasFilters() + { + foreach (var column in Columns) + { + if (column is IFilterableColumn { IsFilterEnabled: true }) + return true; + } + + return false; + } + public event Action? Sorted; + public event Action? Filtered; public void Dispose() { @@ -100,6 +119,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 (HasFilters()) + 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)) @@ -152,17 +173,46 @@ bool ITreeDataGridSource.SortBy(IColumn? column, ListSortDirection direction) foreach (var c in Columns) c.SortDirection = c == column ? direction : null; } + return true; } return false; } + public void Filter(IDictionary conditions) + { + var newConditions = new Dictionary, object?>(); + foreach (var condition in conditions) + { + 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, Dictionary, object?> conditions) + { + // Check immutable filter system first (only apply active filters) + foreach (var kvp in conditions) + { + if (kvp.Key.IsFilterEnabled) + if (!kvp.Key.PassesFilter(model, kvp.Value)) + return false; + } + + + return true; + } + 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 dd2c48ac..55a95ce1 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(IDictionary conditions); } /// diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumn.cs index 066c69ae..78ca3aed 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,15 @@ public override ICell CreateCell(IRow row) TypedBinding.OneWay(g) : TypedBinding.TwoWay(g, (m, v) => setter(m, v ?? 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 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 793a8859..4d2cddab 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/CheckBoxColumnOptions.cs @@ -6,7 +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) + { + if (column is CheckBoxColumn col && IsFilterEnabled) + { + return new CheckBoxFilterControl(col, IsThreeStateFilter, null); + } + + 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 4d2e9a94..6ddbcd04 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); @@ -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); @@ -108,7 +114,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 +152,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..3cc36852 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/Filters.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +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. + /// + /// 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; + + 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 + }; + } +} + +/// +/// 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/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..051c145a 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; } + + object? ErasedOptions(); } } 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..8ef69f96 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/IFilterControlFactory.cs @@ -0,0 +1,168 @@ +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; } + + /// + /// 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. + /// + /// The new filter value. + public FilterValueChangedEventArgs(IFilterableColumn column, object? filterCondition) + { + Column = column; + FilterCondition = filterCondition; + } +} + +/// +/// 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. + /// 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 + { + 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, string 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; + + /// + /// 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(IFilterableColumn column, 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(column, isChecked)); + }); + } +} 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..0da45a2b --- /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(TModel 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..8558843f 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,13 @@ public override ICell CreateCell(IRow row) } string? ITextSearchableColumn.SelectValue(TModel model) => Options.TextSearchValueSelector?.Invoke(model); + + public bool IsFilterEnabled => Options?.Filter is not null; + + 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..0bfea720 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,16 +8,43 @@ 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 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; } + + /// + /// Gets or sets the filter to use for this column. + /// + public IValueFilter? Filter { get; set; } + + public Func? FilterControlFactory { get; set; } + + /// + /// Creates a filter control for this column. + /// + /// The column for which to create a filter control. + /// A filter control that can be used to filter the column. + public IFilterControl? CreateFilterControl(IColumn column) + { + 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 eac14623..4ac48a90 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(condition, value) ?? true; } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs b/src/Avalonia.Controls.TreeDataGrid/Models/TreeDataGrid/TextColumnOptions.cs index 485417d3..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; @@ -8,13 +8,17 @@ 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. /// @@ -39,5 +43,48 @@ 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; + + /// + /// Gets or sets the filter to use for this column. + /// + 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. + /// + 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) + { + if (column is IFilterableColumn col && IsFilterEnabled) + { + // 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 8098a82a..b966cbb7 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridColumnHeader.cs @@ -1,13 +1,22 @@ using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Linq; 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), @@ -23,6 +32,11 @@ 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; @@ -30,6 +44,8 @@ public class TreeDataGridColumnHeader : Button private ListSortDirection? _sortDirection; private TreeDataGrid? _owner; private Thumb? _resizer; + private Button? _headerButton; + private IFilterControl? _filterControl; public bool CanUserResize { @@ -50,10 +66,13 @@ public ListSortDirection? SortDirection get => _sortDirection; private set => SetAndRaise(SortDirectionProperty, ref _sortDirection, value); } + + public bool ShowFilter => _model is IFilterableColumn filterable && filterable.IsFilterEnabled; + public void Realize(IColumns columns, int columnIndex) { - if (_model is object) + if (_model != null) throw new InvalidOperationException("Column header is already realized."); _columns = columns; @@ -86,17 +105,40 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) base.OnApplyTemplate(e); _resizer = e.NameScope.Find("PART_Resizer"); + _headerButton = e.NameScope.Find + + + @@ -144,6 +159,18 @@ + + + +