diff --git a/docs/column-types.md b/docs/column-types.md index 34562cc4..d89a313b 100644 --- a/docs/column-types.md +++ b/docs/column-types.md @@ -20,6 +20,24 @@ This is the signature of the `TextColumn` constructor. There are two most import **Note**: The sample above is taken from [this article](https://github.com/AvaloniaUI/Avalonia.Controls.TreeDataGrid/blob/master/docs/get-started-flat.md). If you feel like you need more examples feel free to check it, there is a sample that shows how to use TextColumns and how to run a whole `TreeDataGrid` using them. +## CheckBoxColumn + +As its name suggests, `CheckBoxColumn` displays a `CheckBox` in its cells. For a readonly checkbox: + +```csharp +new CheckColumn("Firstborn", x => x.IsFirstborn) +``` + +The first parameter defines the column header. The second parameter is an expression which gets the value of the property from the model. + +For a read/write checkbox: + +```csharp +new CheckColumn("Firstborn", x => x.IsFirstborn, (o, v) => o.IsFirstborn = v) +``` + +This overload adds a second paramter which is the expression used to set the property in the model. + ## HierarchicalExpanderColumn `HierarchicalExpanderColumn` can be used only with `HierarchicalTreeDataGrid` (a.k.a TreeView) thats what Hierarchical stands for in its name, also it can be used only with `HierarchicalTreeDataGridSource`. This type of columns can be useful when you want cells to show an expander to reveal nested data. diff --git a/docs/selection.md b/docs/selection.md new file mode 100644 index 00000000..e8cf3adf --- /dev/null +++ b/docs/selection.md @@ -0,0 +1,92 @@ +# Selection + +Two selection modes are supported: + +- Row selection allows the user to select whole rows +- Cell selection allows the user to select individial cells + +Both selection types support either single or multiple selection. The default selection type is single row selection. + +## Index Paths + +Because `TreeDataGrid` supports hierarchical data, using a simple index to identify a row in the data source isn't enough. Instead indexes are represented using the `IndexPath` struct. + +An `IndexPath` is essentially an array of indexes, each element of which specifies the index at a succesively deeper level in the hierarchy of the data. + +Consider the following data source: + +``` +|- A +| |- B +| |- C +| |- D +|- E +``` + +- `A` has an index path of `0` as it's the first item at the root of the hierarchy +- `B` has an index path of `0,0` as it's the first child of the first item +- `C` has an index path of `0,1` as it's the second child of the first item +- `D` has an index path of `0,1,0` as it's the first child of `C` +- `E` has an index path of `1` as it's the second item in the root + +`IndexPath` is an immutable struct which is constructed with an array of integers, e.g.: `new ItemPath(0, 1, 0)`. There is also an implicit conversion from `int` for when working with a flat data source. + +## Row Selection + +Row selection is the default and is exposed via the `RowSelection` property on the `FlatTreeDataGridSource` and `HierarchicalTreeDataGridSource` classes when enabled. Row selection is stored in an instance of the `TreeDataGridRowSelectionModel` class. + +By default is single selection. To enable multiple selection set the the `SingleSelect` property to `false`, e.g.: + +```csharp +Source = new FlatTreeDataGridSource(_people) +{ + Columns = + { + new TextColumn("First Name", x => x.FirstName), + new TextColumn("Last Name", x => x.LastName), + new TextColumn("Age", x => x.Age), + }, +}; + +Source.RowSelection!.SingleSelect = false; +``` + +The properties on `ITreeDataGridRowSelectionModel` can be used to manipulate the selection, e.g.: + +```csharp +Source.RowSelection!.SelectedIndex = 1; +``` + +Or + +```csharp +Source.RowSelection!.SelectedIndex = new IndexPath(0, 1); +``` + +## Cell Selection + +To enable cell selection for a `TreeDataGridSource`, assign an instance of `TreeDataGridCellSelectionModel` to the source's `Selection` property: + +```csharp +Source = new FlatTreeDataGridSource(_people) +{ + Columns = + { + new TextColumn("First Name", x => x.FirstName), + new TextColumn("Last Name", x => x.LastName), + new TextColumn("Age", x => x.Age), + }, +}; + +Source.Selection = new TreeDataGridCellSelectionModel(Source); +``` + +Or for multiple cell selection: + +```csharp +Source.Selection = new TreeDataGridCellSelectionModel(Source) { SingleSelect = false }; +``` + +Cell selection is is exposed via the `CellSelection` property on the `FlatTreeDataGridSource` and `HierarchicalTreeDataGridSource` classes when enabled. + +The `CellIndex` struct indentifies an individual cell with by combination of an integer column index and an `IndexPath` row index. \ No newline at end of file diff --git a/readme.md b/readme.md index 049b8f21..ea2f9445 100644 --- a/readme.md +++ b/readme.md @@ -32,3 +32,4 @@ We accept issues and pull requests but we answer and review only pull requests a - [Creating a flat `TreeDataGrid`](docs/get-started-flat.md) - [Creating a hierarchical `TreeDataGrid`](docs/get-started-hierarchical.md) - [Supported column types](docs/column-types.md) +- [Selection](docs/selection.md) diff --git a/samples/TreeDataGridDemo/MainWindow.axaml b/samples/TreeDataGridDemo/MainWindow.axaml index 2dea9514..20d02aaf 100644 --- a/samples/TreeDataGridDemo/MainWindow.axaml +++ b/samples/TreeDataGridDemo/MainWindow.axaml @@ -11,7 +11,8 @@ - + + Cell Selection Sealand @@ -48,6 +49,11 @@ + + Cell Selection + _data; + private bool _cellSelection; public CountriesPageViewModel() { @@ -20,8 +22,8 @@ public CountriesPageViewModel() Columns = { new TextColumn("Country", x => x.Name, (r, v) => r.Name = v, new GridLength(6, GridUnitType.Star), new() - { - IsTextSearchEnabled = true + { + IsTextSearchEnabled = true }), new TextColumn("Region", x => x.Region, new GridLength(4, GridUnitType.Star)), new TextColumn("Population", x => x.Population, new GridLength(3, GridUnitType.Star)), @@ -35,6 +37,23 @@ public CountriesPageViewModel() Source.RowSelection!.SingleSelect = false; } + public bool CellSelection + { + get => _cellSelection; + set + { + if (_cellSelection != value) + { + _cellSelection = value; + if (_cellSelection) + Source.Selection = new TreeDataGridCellSelectionModel(Source) { SingleSelect = false }; + else + Source.Selection = new TreeDataGridRowSelectionModel(Source) { SingleSelect = false }; + this.RaisePropertyChanged(); + } + } + } + public FlatTreeDataGridSource Source { get; } public void AddCountry(Country country) => _data.Add(country); diff --git a/samples/TreeDataGridDemo/ViewModels/FilesPageViewModel.cs b/samples/TreeDataGridDemo/ViewModels/FilesPageViewModel.cs index 174b818a..2faf80a6 100644 --- a/samples/TreeDataGridDemo/ViewModels/FilesPageViewModel.cs +++ b/samples/TreeDataGridDemo/ViewModels/FilesPageViewModel.cs @@ -20,6 +20,7 @@ namespace TreeDataGridDemo.ViewModels public class FilesPageViewModel : ReactiveObject { private static IconConverter? s_iconConverter; + private bool _cellSelection; private FileTreeNodeModel? _root; private string _selectedDrive; private string? _selectedPath; @@ -94,6 +95,23 @@ public FilesPageViewModel() }); } + public bool CellSelection + { + get => _cellSelection; + set + { + if (_cellSelection != value) + { + _cellSelection = value; + if (_cellSelection) + Source.Selection = new TreeDataGridCellSelectionModel(Source) { SingleSelect = false }; + else + Source.Selection = new TreeDataGridRowSelectionModel(Source) { SingleSelect = false }; + this.RaisePropertyChanged(); + } + } + } + public IList Drives { get; } public string SelectedDrive diff --git a/src/Avalonia.Controls.TreeDataGrid/Avalonia.Controls.TreeDataGrid.csproj b/src/Avalonia.Controls.TreeDataGrid/Avalonia.Controls.TreeDataGrid.csproj index e1ca2c40..d67e9d68 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Avalonia.Controls.TreeDataGrid.csproj +++ b/src/Avalonia.Controls.TreeDataGrid/Avalonia.Controls.TreeDataGrid.csproj @@ -2,6 +2,7 @@ net5.0 True + 10 Avalonia.Controls diff --git a/src/Avalonia.Controls.TreeDataGrid/CellIndex.cs b/src/Avalonia.Controls.TreeDataGrid/CellIndex.cs new file mode 100644 index 00000000..a6a457d9 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/CellIndex.cs @@ -0,0 +1,13 @@ +namespace Avalonia.Controls +{ + /// + /// Represents a cell in a . + /// + /// + /// The index of the cell in the collection. + /// + /// + /// The hierarchical index of the row model in the data source. + /// + public readonly record struct CellIndex(int ColumnIndex, IndexPath RowIndex); +} diff --git a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs index 852a5741..a1e82667 100644 --- a/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/FlatTreeDataGridSource.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using Avalonia.Controls.Models; using Avalonia.Controls.Models.TreeDataGrid; using Avalonia.Controls.Selection; using Avalonia.Input; @@ -12,8 +13,10 @@ namespace Avalonia.Controls /// A data source for a which displays a flat grid. /// /// The model type. - public class FlatTreeDataGridSource : ITreeDataGridSource, IDisposable - where TModel: class + public class FlatTreeDataGridSource : NotifyingBase, + ITreeDataGridSource, + IDisposable + where TModel: class { private IEnumerable _items; private TreeDataGridItemsSourceView _itemsView; @@ -45,6 +48,7 @@ public IEnumerable Items _rows?.SetItems(_itemsView); if (_selection is object) _selection.Source = value; + RaisePropertyChanged(); } } } @@ -59,13 +63,18 @@ public ITreeDataGridSelection? Selection } set { - if (_selection is object) - throw new InvalidOperationException("Selection is already initialized."); - _selection = value; - _isSelectionSet = true; + if (_selection != value) + { + if (value?.Source != _items) + throw new InvalidOperationException("Selection source must be set to Items."); + _selection = value; + _isSelectionSet = true; + RaisePropertyChanged(); + } } } + public ITreeDataGridCellSelectionModel? CellSelection => Selection as ITreeDataGridCellSelectionModel; public ITreeDataGridRowSelectionModel? RowSelection => Selection as ITreeDataGridRowSelectionModel; public bool IsHierarchical => false; public bool IsSorted => _comparer is not null; diff --git a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs index 0c32897f..be06e991 100644 --- a/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/HierarchicalTreeDataGridSource.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Avalonia.Controls.Models; using Avalonia.Controls.Models.TreeDataGrid; using Avalonia.Controls.Selection; using Avalonia.Input; @@ -15,7 +16,8 @@ namespace Avalonia.Controls /// row may have multiple columns. /// /// The model type. - public class HierarchicalTreeDataGridSource : ITreeDataGridSource, + public class HierarchicalTreeDataGridSource : NotifyingBase, + ITreeDataGridSource, IDisposable, IExpanderRowController where TModel: class @@ -70,13 +72,18 @@ public ITreeDataGridSelection? Selection } set { - if (_selection is object) - throw new InvalidOperationException("Selection is already initialized."); - _selection = value; - _isSelectionSet = true; + if (_selection != value) + { + if (value?.Source != _items) + throw new InvalidOperationException("Selection source must be set to Items."); + _selection = value; + _isSelectionSet = true; + RaisePropertyChanged(); + } } } + public ITreeDataGridCellSelectionModel? CellSelection => Selection as ITreeDataGridCellSelectionModel; public ITreeDataGridRowSelectionModel? RowSelection => Selection as ITreeDataGridRowSelectionModel; public bool IsHierarchical => true; public bool IsSorted => _comparison is not null; diff --git a/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs b/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs index f2fdb179..535b4c04 100644 --- a/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs +++ b/src/Avalonia.Controls.TreeDataGrid/ITreeDataGridSource.cs @@ -10,7 +10,7 @@ namespace Avalonia.Controls /// /// Represents a data source for a control. /// - public interface ITreeDataGridSource + public interface ITreeDataGridSource : INotifyPropertyChanged { /// /// Gets the columns to be displayed. diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/ITreeDataGridCell.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/ITreeDataGridCell.cs index 6c512efa..67e574c4 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/ITreeDataGridCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/ITreeDataGridCell.cs @@ -1,4 +1,5 @@ using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Selection; namespace Avalonia.Controls.Primitives { @@ -6,7 +7,13 @@ internal interface ITreeDataGridCell { int ColumnIndex { get; } - void Realize(TreeDataGridElementFactory factory, ICell model, int columnIndex, int rowIndex); + void Realize( + TreeDataGridElementFactory factory, + ITreeDataGridSelectionInteraction? selection, + ICell model, + int columnIndex, + int rowIndex); + void Unrealize(); } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCell.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCell.cs index 11d63235..52b41f74 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCell.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using Avalonia.Controls.Metadata; using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Selection; using Avalonia.Input; using Avalonia.LogicalTree; @@ -10,7 +11,13 @@ namespace Avalonia.Controls.Primitives [PseudoClasses(":editing")] public abstract class TreeDataGridCell : TemplatedControl, ITreeDataGridCell { + public static readonly DirectProperty IsSelectedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsSelected), + o => o.IsSelected); + private bool _isEditing; + private bool _isSelected; private TreeDataGrid? _treeDataGrid; private Point _pressedPoint; @@ -24,7 +31,18 @@ static TreeDataGridCell() public int RowIndex { get; private set; } = -1; public ICell? Model { get; private set; } - public virtual void Realize(TreeDataGridElementFactory factory, ICell model, int columnIndex, int rowIndex) + public bool IsSelected + { + get => _isSelected; + private set => SetAndRaise(IsSelectedProperty, ref _isSelected, value); + } + + public virtual void Realize( + TreeDataGridElementFactory factory, + ITreeDataGridSelectionInteraction? selection, + ICell model, + int columnIndex, + int rowIndex) { if (columnIndex < 0) throw new IndexOutOfRangeException("Invalid column index."); @@ -32,6 +50,7 @@ public virtual void Realize(TreeDataGridElementFactory factory, ICell model, int ColumnIndex = columnIndex; RowIndex = rowIndex; Model = model; + IsSelected = selection?.IsCellSelected(columnIndex, rowIndex) ?? false; _treeDataGrid?.RaiseCellPrepared(this, columnIndex, RowIndex); } @@ -167,6 +186,23 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) } } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (change.Property == IsSelectedProperty) + { + PseudoClasses.Set(":selected", IsSelected); + } + + base.OnPropertyChanged(change); + } + + public void UpdateRowIndex(int index) => RowIndex = index; + + internal void UpdateSelection(ITreeDataGridSelectionInteraction? selection) + { + IsSelected = selection?.IsCellSelected(ColumnIndex, RowIndex) ?? false; + } + private bool EndEditCore() { if (_isEditing) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCellsPresenter.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCellsPresenter.cs index 434a7619..8a6627be 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCellsPresenter.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCellsPresenter.cs @@ -1,8 +1,11 @@ using System; +using System.Xml.Linq; using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Selection; using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.Media; +using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives { @@ -49,6 +52,12 @@ public void UpdateRowIndex(int index) if (index < 0 || Rows is null || index >= Rows.Count) throw new ArgumentOutOfRangeException(nameof(index)); RowIndex = index; + + foreach (var element in RealizedElements) + { + if (element is TreeDataGridCell { RowIndex: >= 0, ColumnIndex: >= 0 } cell) + cell.UpdateRowIndex(index); + } } protected override Size MeasureElement(int index, Control element, Size availableSize) @@ -61,7 +70,7 @@ protected override Control GetElementFromFactory(IColumn column, int index) { var model = _rows!.RealizeCell(column, index, RowIndex); var cell = (TreeDataGridCell)GetElementFromFactory(model, index, this); - cell.Realize(ElementFactory!, model, index, RowIndex); + cell.Realize(ElementFactory!, GetSelection(), model, index, RowIndex); return cell; } @@ -76,7 +85,7 @@ protected override void RealizeElement(Control element, IColumn column, int inde else if (cell.ColumnIndex == -1 && cell.RowIndex == -1) { var model = _rows!.RealizeCell(column, index, RowIndex); - ((TreeDataGridCell)element).Realize(ElementFactory!, model, index, RowIndex); + ((TreeDataGridCell)element).Realize(ElementFactory!, GetSelection(), model, index, RowIndex); ChildIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element, index)); } else @@ -105,6 +114,20 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == BackgroundProperty) InvalidateVisual(); } + + internal void UpdateSelection(ITreeDataGridSelectionInteraction? selection) + { + foreach (var element in RealizedElements) + { + if (element is TreeDataGridCell { RowIndex: >= 0, ColumnIndex: >= 0 } cell) + cell.UpdateSelection(selection); + } + } + + private ITreeDataGridSelectionInteraction? GetSelection() + { + return this.FindAncestorOfType()?.SelectionInteraction; + } public int GetChildIndex(ILogical child) { diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCheckBoxCell.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCheckBoxCell.cs index 5538c87f..3ba575ac 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCheckBoxCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridCheckBoxCell.cs @@ -1,5 +1,6 @@ using System; using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Selection; using Avalonia.Input; namespace Avalonia.Controls.Primitives @@ -50,7 +51,12 @@ public bool? Value } } - public override void Realize(TreeDataGridElementFactory factory, ICell model, int columnIndex, int rowIndex) + public override void Realize( + TreeDataGridElementFactory factory, + ITreeDataGridSelectionInteraction? selection, + ICell model, + int columnIndex, + int rowIndex) { if (model is CheckBoxCell cell) { @@ -63,7 +69,7 @@ public override void Realize(TreeDataGridElementFactory factory, ICell model, in throw new InvalidOperationException("Invalid cell model."); } - base.Realize(factory, model, columnIndex, rowIndex); + base.Realize(factory, selection, model, columnIndex, rowIndex); } protected override void OnPointerPressed(PointerPressedEventArgs e) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridExpanderCell.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridExpanderCell.cs index 3ed53072..bffafe63 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridExpanderCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridExpanderCell.cs @@ -1,6 +1,8 @@ using System; using System.ComponentModel; using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Selection; +using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives { @@ -48,7 +50,12 @@ public bool ShowExpander private set => SetAndRaise(ShowExpanderProperty, ref _showExpander, value); } - public override void Realize(TreeDataGridElementFactory factory, ICell model, int columnIndex, int rowIndex) + public override void Realize( + TreeDataGridElementFactory factory, + ITreeDataGridSelectionInteraction? selection, + ICell model, + int columnIndex, + int rowIndex) { if (_model is object) throw new InvalidOperationException("Cell is already realized."); @@ -73,7 +80,7 @@ public override void Realize(TreeDataGridElementFactory factory, ICell model, in throw new InvalidOperationException("Invalid cell model."); } - base.Realize(factory, model, columnIndex, rowIndex); + base.Realize(factory, selection, model, columnIndex, rowIndex); UpdateContent(_factory); } @@ -112,7 +119,7 @@ private void UpdateContent(TreeDataGridElementFactory factory) } if (_contentContainer.Child is ITreeDataGridCell innerCell) - innerCell.Realize(factory, innerModel, ColumnIndex, RowIndex); + innerCell.Realize(factory, null, innerModel, ColumnIndex, RowIndex); } else if (_contentContainer.Child is ITreeDataGridCell innerCell) { diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs index 9124b811..726d7c55 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridPresenterBase.cs @@ -82,14 +82,17 @@ public IReadOnlyList? Items protected abstract Orientation Orientation { get; } protected Rect Viewport { get; private set; } = s_invalidViewport; - public void BringIntoView(int index) + public void BringIntoView(int index, Rect? rect = null) { if (_items is null || index < 0 || index >= _items.Count) return; if (GetRealizedElement(index) is Control element) { - element.BringIntoView(); + if (rect.HasValue) + element.BringIntoView(rect.Value); + else + element.BringIntoView(); } else if (this.GetVisualRoot() is ILayoutRoot root) { @@ -101,16 +104,16 @@ public void BringIntoView(int index) // Get the expected position of the elment and put it in place. var anchorU = GetOrEstimateElementPosition(index); - var rect = Orientation == Orientation.Horizontal ? + var elementRect = Orientation == Orientation.Horizontal ? new Rect(anchorU, 0, _anchorElement.DesiredSize.Width, _anchorElement.DesiredSize.Height) : new Rect(0, anchorU, _anchorElement.DesiredSize.Width, _anchorElement.DesiredSize.Height); - _anchorElement.Arrange(rect); + _anchorElement.Arrange(elementRect); // If the item being brought into view was added since the last layout pass then // our bounds won't be updated, so any containing scroll viewers will not have an // updated extent. Do a layout pass to ensure that the containing scroll viewers // will be able to scroll the new item into view. - if (!Bounds.Contains(rect) && !Viewport.Contains(rect)) + if (!Bounds.Contains(elementRect) && !Viewport.Contains(elementRect)) { _isWaitingForViewportUpdate = true; root.LayoutManager.ExecuteLayoutPass(); @@ -118,9 +121,12 @@ public void BringIntoView(int index) } // Try to bring the item into view and do a layout pass. - _anchorElement.BringIntoView(); + if (rect.HasValue) + _anchorElement.BringIntoView(rect.Value); + else + _anchorElement.BringIntoView(); - _isWaitingForViewportUpdate = !Viewport.Contains(rect); + _isWaitingForViewportUpdate = !Viewport.Contains(elementRect); root.LayoutManager.ExecuteLayoutPass(); _isWaitingForViewportUpdate = false; diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs index e544b830..8c2f6d5e 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRow.cs @@ -1,12 +1,14 @@ using System; using Avalonia.Controls.Metadata; using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Selection; using Avalonia.Input; +using Avalonia.LogicalTree; namespace Avalonia.Controls.Primitives { [PseudoClasses(":selected")] - public class TreeDataGridRow : TemplatedControl, ISelectable + public class TreeDataGridRow : TemplatedControl { private const double DragDistance = 3; private static readonly Point s_InvalidPoint = new(double.NegativeInfinity, double.NegativeInfinity); @@ -25,8 +27,7 @@ public class TreeDataGridRow : TemplatedControl, ISelectable public static readonly DirectProperty IsSelectedProperty = AvaloniaProperty.RegisterDirect( nameof(IsSelected), - o => o.IsSelected, - (o, v) => o.IsSelected = v); + o => o.IsSelected); public static readonly DirectProperty RowsProperty = AvaloniaProperty.RegisterDirect( @@ -54,7 +55,7 @@ public TreeDataGridElementFactory? ElementFactory public bool IsSelected { get => _isSelected; - set => SetAndRaise(IsSelectedProperty, ref _isSelected, value); + private set => SetAndRaise(IsSelectedProperty, ref _isSelected, value); } public object? Model => DataContext; @@ -70,6 +71,7 @@ public IRows? Rows public void Realize( TreeDataGridElementFactory? elementFactory, + ITreeDataGridSelectionInteraction? selection, IColumns? columns, IRows? rows, int rowIndex) @@ -78,7 +80,9 @@ public void Realize( Columns = columns; Rows = rows; DataContext = rows?[rowIndex].Model; + IsSelected = selection?.IsRowSelected(rowIndex) ?? false; UpdateIndex(rowIndex); + UpdateSelection(selection); } public Control? TryGetCell(int columnIndex) @@ -96,6 +100,7 @@ public void Unrealize() { RowIndex = -1; DataContext = null; + IsSelected = false; CellsPresenter?.Unrealize(); } @@ -142,5 +147,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang base.OnPropertyChanged(change); } + + internal void UpdateSelection(ITreeDataGridSelectionInteraction? selection) + { + IsSelected = selection?.IsRowSelected(RowIndex) ?? false; + CellsPresenter?.UpdateSelection(selection); + } } } diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs index 9adf6b21..1808542b 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridRowsPresenter.cs @@ -1,9 +1,9 @@ using System; using Avalonia.Controls.Models.TreeDataGrid; using Avalonia.Controls.Selection; -using Avalonia.Data; using Avalonia.Layout; using Avalonia.LogicalTree; +using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives { @@ -15,14 +15,7 @@ public class TreeDataGridRowsPresenter : TreeDataGridPresenterBase, IChild o => o.Columns, (o, v) => o.Columns = v); - public static readonly DirectProperty SelectionProperty = - AvaloniaProperty.RegisterDirect( - nameof(Selection), - o => o.Selection, - (o, v) => o.Selection = v); - private IColumns? _columns; - private ITreeDataGridSelectionInteraction? _selection; public event EventHandler? ChildIndexChanged; @@ -32,35 +25,6 @@ public IColumns? Columns set => SetAndRaise(ColumnsProperty, ref _columns, value); } - public ITreeDataGridSelectionInteraction? Selection - { - get => _selection; - set - { - if (_selection != value) - { - var oldValue = _selection; - - if (_selection is object) - { - _selection.SelectionChanged -= OnSelectionChanged; - } - - _selection = value; - - if (_selection is object) - { - _selection.SelectionChanged += OnSelectionChanged; - } - - RaisePropertyChanged( - SelectionProperty, - oldValue, - _selection); - } - } - } - protected override Orientation Orientation => Orientation.Vertical; protected override (int index, double position) GetElementAt(double position) @@ -71,8 +35,7 @@ protected override (int index, double position) GetElementAt(double position) protected override void RealizeElement(Control element, IRow rowModel, int index) { var row = (TreeDataGridRow)element; - row.Realize(ElementFactory, Columns, (IRows?)Items, index); - row.IsSelected = _selection?.IsRowSelected(rowModel) == true; + row.Realize(ElementFactory, GetSelection(), Columns, (IRows?)Items, index); ChildIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element, index)); } @@ -88,17 +51,6 @@ protected override void UnrealizeElement(Control element) ChildIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(element, ((TreeDataGridRow)element).RowIndex)); } - private void UpdateSelection() - { - foreach (var element in VisualChildren) - { - if (element is TreeDataGridRow { RowIndex: >= 0 } row) - { - row.IsSelected = _selection?.IsRowSelected(row.RowIndex) == true; - } - } - } - protected override Size ArrangeOverride(Size finalSize) { Columns?.CommitActualWidths(); @@ -127,6 +79,15 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang base.OnPropertyChanged(change); } + internal void UpdateSelection(ITreeDataGridSelectionInteraction? selection) + { + foreach (var element in RealizedElements) + { + if (element is TreeDataGridRow { RowIndex: >= 0 } row) + row.UpdateSelection(selection); + } + } + private void OnColumnLayoutInvalidated(object? sender, EventArgs e) { InvalidateMeasure(); @@ -138,9 +99,9 @@ private void OnColumnLayoutInvalidated(object? sender, EventArgs e) } } - private void OnSelectionChanged(object? sender, EventArgs e) + private ITreeDataGridSelectionInteraction? GetSelection() { - UpdateSelection(); + return this.FindAncestorOfType()?.SelectionInteraction; } public int GetChildIndex(ILogical child) diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTemplateCell.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTemplateCell.cs index 9ca4500f..75cadc5b 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTemplateCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTemplateCell.cs @@ -1,5 +1,6 @@ using System; using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Selection; using Avalonia.Controls.Templates; using Avalonia.LogicalTree; @@ -31,10 +32,15 @@ public IDataTemplate? ContentTemplate set => SetAndRaise(ContentTemplateProperty, ref _contentTemplate, value); } - public override void Realize(TreeDataGridElementFactory factory, ICell model, int columnIndex, int rowIndex) + public override void Realize( + TreeDataGridElementFactory factory, + ITreeDataGridSelectionInteraction? selection, + ICell model, + int columnIndex, + int rowIndex) { DataContext = model; - base.Realize(factory, model, columnIndex, rowIndex); + base.Realize(factory, selection, model, columnIndex, rowIndex); } public override void Unrealize() diff --git a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs index edf4a6d0..baf96e41 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Primitives/TreeDataGridTextCell.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Selection; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; @@ -42,12 +43,17 @@ public string? Value protected override bool CanEdit => _canEdit; - public override void Realize(TreeDataGridElementFactory factory, ICell model, int columnIndex, int rowIndex) + public override void Realize( + TreeDataGridElementFactory factory, + ITreeDataGridSelectionInteraction? selection, + ICell model, + int columnIndex, + int rowIndex) { _canEdit = model.CanEdit; Value = model.Value?.ToString(); TextTrimming = (model as ITextCell)?.TextTrimming ?? TextTrimming.CharacterEllipsis; - base.Realize(factory, model, columnIndex, rowIndex); + base.Realize(factory, selection, model, columnIndex, rowIndex); SubscribeToModelChanges(); } diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridCellSelectionModel.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridCellSelectionModel.cs new file mode 100644 index 00000000..6a16062a --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridCellSelectionModel.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; + +namespace Avalonia.Controls.Selection +{ + /// + /// Maintains the cell selection state for an . + /// + public interface ITreeDataGridCellSelectionModel : ITreeDataGridSelection + { + /// + /// Occurs when the cell selection changes. + /// + event EventHandler? SelectionChanged; + } + + /// + /// Maintains the cell selection state for an . + /// + public interface ITreeDataGridCellSelectionModel : ITreeDataGridCellSelectionModel + where T : class + { + /// + /// Gets or sets a value indicating whether only a single cell can be selected at a time. + /// + bool SingleSelect { get; set; } + + /// + /// Gets or sets the index of the currently selected cell. + /// + CellIndex SelectedIndex { get; set; } + + /// + /// Gets the indexes of the currently selected cells. + /// + IReadOnlyList SelectedIndexes { get; } + + /// + /// Occurs when the cell selection changes. + /// + new event EventHandler>? SelectionChanged; + + /// + /// Checks whether the specified cell is selected. + /// + /// The index of the cell. + bool IsSelected(CellIndex index); + + /// + /// Sets the current selection to the specified range of cells. + /// + /// The index of the cell from which the selection should start. + /// The number of columns in the selection. + /// The number of rows in the selection. + /// + /// This method clears the current selection and selects the specified range of cells. + /// Note that if the is currently sorted then the + /// resulting selection may not be contiguous in the data source. + /// + void SetSelectedRange(CellIndex start, int columnCount, int rowCount); + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridColumnSelectionModel.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridColumnSelectionModel.cs new file mode 100644 index 00000000..49a01cf7 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridColumnSelectionModel.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Avalonia.Controls.Models.TreeDataGrid; + +namespace Avalonia.Controls.Selection +{ + public interface ITreeDataGridColumnSelectionModel : ISelectionModel + { + new IReadOnlyList SelectedItems { get; } + new IColumn? SelectedItem { get; set; } + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridSelectionInteraction.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridSelectionInteraction.cs index c2200050..e0bdd0df 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridSelectionInteraction.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/ITreeDataGridSelectionInteraction.cs @@ -12,8 +12,9 @@ public interface ITreeDataGridSelectionInteraction { public event EventHandler? SelectionChanged; - bool IsRowSelected(IRow rowModel); - bool IsRowSelected(int rowIndex); + bool IsCellSelected(int columnIndex, int rowIndex) => false; + bool IsRowSelected(IRow rowModel) => false; + bool IsRowSelected(int rowIndex) => false; public void OnKeyDown(TreeDataGrid sender, KeyEventArgs e) { } public void OnPreviewKeyDown(TreeDataGrid sender, KeyEventArgs e) { } public void OnKeyUp(TreeDataGrid sender, KeyEventArgs e) { } diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/IndexRanges.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/IndexRanges.cs index 62cf76dd..507151cd 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/IndexRanges.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/IndexRanges.cs @@ -44,8 +44,7 @@ public void Add(in IndexPath index) _ranges.Add(parent, ranges); } - IndexRange.Add(ranges, new IndexRange(index[^1])); - ++Count; + Count += IndexRange.Add(ranges, new IndexRange(index[^1])); } public void Add(in IndexPath parent, in IndexRange range) diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/SelectedCellIndexes.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/SelectedCellIndexes.cs new file mode 100644 index 00000000..b9b4b9bf --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/SelectedCellIndexes.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Avalonia.Controls.Selection +{ + internal class SelectedCellIndexes : IReadOnlyList + { + private readonly ITreeDataGridColumnSelectionModel _selectedColumns; + private readonly ITreeDataGridRowSelectionModel _selectedRows; + + public SelectedCellIndexes( + ITreeDataGridColumnSelectionModel selectedColumns, + ITreeDataGridRowSelectionModel selectedRows) + { + _selectedColumns = selectedColumns; + _selectedRows = selectedRows; + } + + public CellIndex this[int index] + { + get + { + if (index < 0 || index >= Count) + throw new IndexOutOfRangeException("The index was out of range."); + var column = _selectedColumns.SelectedIndexes[index % _selectedColumns.Count]; + var row = _selectedRows.SelectedIndexes[index / _selectedColumns.Count]; + return new(column, row); + } + } + + public int Count => _selectedColumns.Count * _selectedRows.Count; + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < Count; ++i) + yield return this[i]; + } + + IEnumerator IEnumerable.GetEnumerator() + { + for (var i = 0; i < Count; ++i) + yield return this[i]; + } + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionChangedEventArgs.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionChangedEventArgs.cs new file mode 100644 index 00000000..8533d3a0 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionChangedEventArgs.cs @@ -0,0 +1,20 @@ +using System; + +namespace Avalonia.Controls.Selection +{ + /// + /// Provides data for the event. + /// + public class TreeDataGridCellSelectionChangedEventArgs : EventArgs + { + } + + /// + /// Provides data for the event. + /// + /// The model type. + public class TreeDataGridCellSelectionChangedEventArgs : TreeDataGridCellSelectionChangedEventArgs + where T : class + { + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionModel.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionModel.cs new file mode 100644 index 00000000..19d709ef --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridCellSelectionModel.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Avalonia.Controls.Models.TreeDataGrid; +using Avalonia.Controls.Primitives; +using Avalonia.Input; + +namespace Avalonia.Controls.Selection +{ + public class TreeDataGridCellSelectionModel : ITreeDataGridCellSelectionModel, + ITreeDataGridSelectionInteraction + where TModel : class + { + private static readonly Point s_InvalidPoint = new(double.NegativeInfinity, double.NegativeInfinity); + private readonly ITreeDataGridColumnSelectionModel _selectedColumns; + private readonly ITreeDataGridRowSelectionModel _selectedRows; + private readonly SelectedCellIndexes _selectedIndexes; + private readonly ITreeDataGridSource _source; + private EventHandler? _untypedSelectionChanged; + private EventHandler? _viewSelectionChanged; + private Point _pressedPoint = s_InvalidPoint; + private (int x, int y) _rangeAnchor = (-1, -1); + private bool _columnsChanged; + private bool _rowsChanged; + + public TreeDataGridCellSelectionModel(ITreeDataGridSource source) + { + _source = source; + _selectedColumns = new TreeDataGridColumnSelectionModel(source.Columns); + _selectedRows = new TreeDataGridRowSelectionModel(source); + _selectedColumns.SelectionChanged += OnSelectedColumnsSelectionChanged; + _selectedRows.SelectionChanged += OnSelectedRowsSelectionChanged; + _selectedIndexes = new(_selectedColumns, _selectedRows); + } + + public int Count => _selectedColumns.Count * _selectedRows.Count; + + public bool SingleSelect + { + get => _selectedRows.SingleSelect; + set => _selectedColumns.SingleSelect = _selectedRows.SingleSelect = value; + } + + public CellIndex SelectedIndex + { + get => new(_selectedColumns.SelectedIndex, _selectedRows.SelectedIndex); + set + { + var rowIndex = _source.Rows.ModelIndexToRowIndex(value.RowIndex); + Select(value.ColumnIndex, rowIndex, value.RowIndex); + } + } + + public IReadOnlyList SelectedIndexes => _selectedIndexes; + + IEnumerable? ITreeDataGridSelection.Source + { + get => ((ITreeDataGridSelection)_selectedRows).Source; + set => ((ITreeDataGridSelection)_selectedRows).Source = value; + } + + public event EventHandler>? SelectionChanged; + + event EventHandler? ITreeDataGridSelectionInteraction.SelectionChanged + { + add => _viewSelectionChanged += value; + remove => _viewSelectionChanged -= value; + } + + event EventHandler? ITreeDataGridCellSelectionModel.SelectionChanged + { + add => _untypedSelectionChanged += value; + remove => _untypedSelectionChanged -= value; + } + + public bool IsSelected(CellIndex index) => IsSelected(index.ColumnIndex, index.RowIndex); + + /// + /// Checks whether the specified cell is selected. + /// + /// The column index of the cell. + /// The row index of the cell. + public bool IsSelected(int columnIndex, IndexPath rowIndex) + { + return _selectedColumns.IsSelected(columnIndex) && _selectedRows.IsSelected(rowIndex); + } + + public void SetSelectedRange(CellIndex start, int columnCount, int rowCount) + { + SetSelectedRange( + start.ColumnIndex, + _source.Rows.ModelIndexToRowIndex(start.RowIndex), + start.RowIndex, + columnCount, + rowCount); + } + + bool ITreeDataGridSelectionInteraction.IsCellSelected(int columnIndex, int rowIndex) + { + return IsSelected(columnIndex, rowIndex); + } + + void ITreeDataGridSelectionInteraction.OnKeyDown(TreeDataGrid sender, KeyEventArgs e) + { + var direction = e.Key.ToNavigationDirection(); + var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + + if (sender.RowsPresenter is null || + sender.Columns is null || + sender.Rows is null || + e.Handled || !direction.HasValue) + return; + + var (x, y) = direction switch + { + NavigationDirection.Up => (0, -1), + NavigationDirection.Down => (0, 1), + NavigationDirection.Left => (-1, 0), + NavigationDirection.Right => (1, 0), + _ => (0, 0) + }; + + var anchor = shift ? _rangeAnchor : GetAnchor(); + var columnIndex = Math.Clamp(anchor.x + x, 0, sender.Columns.Count - 1); + var rowIndex = Math.Clamp(anchor.y + y, 0, sender.Rows.Count - 1); + + if (!shift) + Select(columnIndex, rowIndex); + else + SelectFromAnchorTo(columnIndex, rowIndex); + + sender.ColumnHeadersPresenter?.BringIntoView(columnIndex); + sender.RowsPresenter?.BringIntoView( + rowIndex, + sender.ColumnHeadersPresenter?.TryGetElement(columnIndex)?.Bounds); + } + + void ITreeDataGridSelectionInteraction.OnPointerPressed(TreeDataGrid sender, PointerPressedEventArgs e) + { + // Select a cell on pointer pressed if: + // + // - It's a mouse click, not touch: we don't want to select on touch scroll gesture start + // - The cell isn't already selected: we don't want to deselect an existing multiple selection + // if the user is trying to drag multiple cells + // + // Otherwise select on pointer release. + if (!e.Handled && + e.Pointer.Type == PointerType.Mouse && + e.Source is Control source && + sender.TryGetCell(source, out var cell) && + !IsSelected(cell.ColumnIndex, cell.RowIndex)) + { + PointerSelect(sender, cell, e); + } + else + { + _pressedPoint = e.GetPosition(sender); + } + } + + void ITreeDataGridSelectionInteraction.OnPointerReleased(TreeDataGrid sender, PointerReleasedEventArgs e) + { + if (!e.Handled && + _pressedPoint != s_InvalidPoint && + e.Source is Control source && + sender.TryGetCell(source, out var cell)) + { + var p = e.GetPosition(sender); + if (Math.Abs(p.X - _pressedPoint.X) <= 3 || Math.Abs(p.Y - _pressedPoint.Y) <= 3) + PointerSelect(sender, cell, e); + } + } + + private void BeginBatchUpdate() + { + _selectedColumns.BeginBatchUpdate(); + _selectedRows.BeginBatchUpdate(); + } + + private void EndBatchUpdate() + { + _columnsChanged = _rowsChanged = false; + _selectedColumns.EndBatchUpdate(); + _selectedRows.EndBatchUpdate(); + + if (_columnsChanged || _rowsChanged) + { + var e = new TreeDataGridCellSelectionChangedEventArgs(); + _viewSelectionChanged?.Invoke(this, EventArgs.Empty); + SelectionChanged?.Invoke(this, e); + _untypedSelectionChanged?.Invoke(this, e); + } + } + + private void PointerSelect(TreeDataGrid sender, TreeDataGridCell cell, PointerEventArgs e) + { + var point = e.GetCurrentPoint(sender); + var isRightButton = point.Properties.PointerUpdateKind is PointerUpdateKind.RightButtonPressed or + PointerUpdateKind.RightButtonReleased; + + UpdateSelection( + sender, + cell.ColumnIndex, + cell.RowIndex, + rangeModifier: e.KeyModifiers.HasFlag(KeyModifiers.Shift), + rightButton: isRightButton); + e.Handled = true; + } + + private (int x, int y) GetAnchor() + { + var anchorModelIndex = _selectedRows.AnchorIndex; + return (_selectedColumns.AnchorIndex, _source.Rows.ModelIndexToRowIndex(anchorModelIndex)); + } + + private bool IsSelected(int columnIndex, int rowIndex) + { + var modelIndex = _source.Rows.RowIndexToModelIndex(rowIndex); + return _selectedColumns.IsSelected(columnIndex) && _selectedRows.IsSelected(modelIndex); + } + + private void Select(int columnIndex, int rowIndex) + { + var modelIndex = _source.Rows.RowIndexToModelIndex(rowIndex); + Select(columnIndex, rowIndex, modelIndex); + } + + private void Select(int columnIndex, int rowIndex, IndexPath modelndex) + { + BeginBatchUpdate(); + _selectedColumns.SelectedIndex = columnIndex; + _selectedRows.SelectedIndex = modelndex; + _rangeAnchor = (columnIndex, rowIndex); + EndBatchUpdate(); + } + + private void SelectFromAnchorTo(int columnIndex, int rowIndex) + { + var anchorColumnIndex = _selectedColumns.AnchorIndex; + var anchorRowIndex = _source.Rows.ModelIndexToRowIndex(_selectedRows.AnchorIndex); + + SetSelectedRange( + anchorColumnIndex, + anchorRowIndex, + _selectedRows.AnchorIndex, + (columnIndex - anchorColumnIndex) + 1, + (rowIndex - anchorRowIndex) + 1); + } + + private void SetSelectedRange( + int columnIndex, + int rowIndex, + IndexPath modelIndex, + int columnCount, + int rowCount) + { + var endColumnIndex = columnIndex + columnCount - 1; + var endRowIndex = rowIndex + rowCount - 1; + + BeginBatchUpdate(); + + _selectedColumns.SelectedIndex = columnIndex; + _selectedColumns.SelectRange(columnIndex, endColumnIndex); + _selectedRows.SelectedIndex = rowIndex; + + for (var i = Math.Min(rowIndex, endRowIndex); i <= Math.Max(rowIndex, endRowIndex); ++i) + { + _selectedRows.Select(_source.Rows.RowIndexToModelIndex(i)); + } + + _selectedRows.AnchorIndex = modelIndex; + _rangeAnchor = (endColumnIndex, endRowIndex); + + EndBatchUpdate(); + } + + private void UpdateSelection( + TreeDataGrid treeDataGrid, + int columnIndex, + int rowIndex, + bool rangeModifier = false, + bool rightButton = false) + { + var modelIndex = _source.Rows.RowIndexToModelIndex(rowIndex); + + if (modelIndex == default) + return; + + var multi = !SingleSelect; + var range = multi && rangeModifier; + + if (rightButton) + { + if (IsSelected(columnIndex, modelIndex) == false && !treeDataGrid.QueryCancelSelection()) + Select(columnIndex, rowIndex, modelIndex); + } + else if (range) + { + if (!treeDataGrid.QueryCancelSelection()) + SelectFromAnchorTo(columnIndex, rowIndex); + } + else if (_selectedColumns.SelectedIndex != columnIndex || + _selectedRows.SelectedIndex != modelIndex || + Count > 1) + { + if (!treeDataGrid.QueryCancelSelection()) + Select(columnIndex, rowIndex, modelIndex); + } + } + + private void OnSelectedColumnsSelectionChanged(object? sender, SelectionModelSelectionChangedEventArgs e) + { + _columnsChanged = true; + } + + private void OnSelectedRowsSelectionChanged(object? sender, TreeSelectionModelSelectionChangedEventArgs e) + { + _rowsChanged = true; + } + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridColumnSelectionModel.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridColumnSelectionModel.cs new file mode 100644 index 00000000..f24d6200 --- /dev/null +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridColumnSelectionModel.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls.Models.TreeDataGrid; + +namespace Avalonia.Controls.Selection +{ + public class TreeDataGridColumnSelectionModel : SelectionModel, + ITreeDataGridColumnSelectionModel + { + public TreeDataGridColumnSelectionModel(IColumns columns) + : base(columns) + { + } + } +} diff --git a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs index d80f1ffc..3489b99d 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs +++ b/src/Avalonia.Controls.TreeDataGrid/Selection/TreeDataGridRowSelectionModel.cs @@ -113,7 +113,6 @@ void ITreeDataGridSelectionInteraction.OnKeyDown(TreeDataGrid sender, KeyEventAr } } } - } protected void HandleTextInput(string? text, TreeDataGrid treeDataGrid, int selectedRowIndex) diff --git a/src/Avalonia.Controls.TreeDataGrid/Themes/Fluent.axaml b/src/Avalonia.Controls.TreeDataGrid/Themes/Fluent.axaml index 26ff2884..847c6a33 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Themes/Fluent.axaml +++ b/src/Avalonia.Controls.TreeDataGrid/Themes/Fluent.axaml @@ -13,5 +13,9 @@ - + + + diff --git a/src/Avalonia.Controls.TreeDataGrid/Themes/FluentControls.axaml b/src/Avalonia.Controls.TreeDataGrid/Themes/FluentControls.axaml index 1ff97953..fea15f12 100644 --- a/src/Avalonia.Controls.TreeDataGrid/Themes/FluentControls.axaml +++ b/src/Avalonia.Controls.TreeDataGrid/Themes/FluentControls.axaml @@ -33,8 +33,7 @@ + Items="{TemplateBinding Rows}"/> diff --git a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs index 5b97beb6..069c69ac 100644 --- a/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs +++ b/src/Avalonia.Controls.TreeDataGrid/TreeDataGrid.cs @@ -43,13 +43,6 @@ public class TreeDataGrid : TemplatedControl o => o.Rows, (o, v) => o.Rows = v); - [Browsable(false)] - public static readonly DirectProperty SelectionInteractionProperty = - AvaloniaProperty.RegisterDirect( - nameof(SelectionInteraction), - o => o.SelectionInteraction, - (o, v) => o.SelectionInteraction = v); - public static readonly DirectProperty ScrollProperty = AvaloniaProperty.RegisterDirect( nameof(Scroll), @@ -149,13 +142,6 @@ public IRows? Rows private set => SetAndRaise(RowsProperty, ref _rows, value); } - [Browsable(false)] - public ITreeDataGridSelectionInteraction? SelectionInteraction - { - get => _selection; - private set => SetAndRaise(SelectionInteractionProperty, ref _selection, value); - } - public TreeDataGridColumnHeadersPresenter? ColumnHeadersPresenter { get; private set; } public TreeDataGridRowsPresenter? RowsPresenter { get; private set; } @@ -171,7 +157,8 @@ public bool ShowColumnHeaders set => SetValue(ShowColumnHeadersProperty, value); } - public ITreeSelectionModel? RowSelection => Source?.Selection as ITreeSelectionModel; + public ITreeDataGridCellSelectionModel? ColumnSelection => Source?.Selection as ITreeDataGridCellSelectionModel; + public ITreeDataGridRowSelectionModel? RowSelection => Source?.Selection as ITreeDataGridRowSelectionModel; public ITreeDataGridSource? Source { @@ -180,38 +167,51 @@ public ITreeDataGridSource? Source { if (_source != value) { - if (value != null) - { - value.Sorted += Source_Sorted; - } - if (_source != null) { - _source.Sorted -= Source_Sorted; - } - - void Source_Sorted() - { - RowsPresenter?.RecycleAllElements(); - RowsPresenter?.InvalidateMeasure(); + _source.PropertyChanged -= OnSourcePropertyChanged; + _source.Sorted -= OnSourceSorted; } var oldSource = _source; _source = value; Columns = _source?.Columns; Rows = _source?.Rows; - SelectionInteraction = value?.Selection as ITreeDataGridSelectionInteraction; + SelectionInteraction = _source?.Selection as ITreeDataGridSelectionInteraction; + + if (_source != null) + { + _source.PropertyChanged += OnSourcePropertyChanged; + _source.Sorted += OnSourceSorted; + } + RaisePropertyChanged( SourceProperty, oldSource, - oldSource); + _source); + } + } + } + + internal ITreeDataGridSelectionInteraction? SelectionInteraction + { + get => _selection; + set + { + if (_selection != value) + { + if (_selection != null) + _selection.SelectionChanged -= OnSelectionInteractionChanged; + _selection = value; + if (_selection != null) + _selection.SelectionChanged += OnSelectionInteractionChanged; } } } public event EventHandler? CellClearing; public event EventHandler? CellPrepared; - + public event EventHandler? RowDragStarted { add => AddHandler(RowDragStartedEvent, value!); @@ -248,6 +248,20 @@ public event EventHandler? RowDrop return RowsPresenter?.TryGetElement(rowIndex) as TreeDataGridRow; } + public bool TryGetCell(Control? element, [MaybeNullWhen(false)] out TreeDataGridCell result) + { + if (element.FindAncestorOfType(true) is { } cell && + cell.ColumnIndex >= 0 && + cell.RowIndex >= 0) + { + result = cell; + return true; + } + + result = null; + return false; + } + public bool TryGetRow(Control? element, [MaybeNullWhen(false)] out TreeDataGridRow result) { if (element is TreeDataGridRow row && row.RowIndex >= 0) @@ -321,7 +335,7 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e } protected void OnPreviewKeyDown(object? o, KeyEventArgs e) - { + { _selection?.OnPreviewKeyDown(this, e); } @@ -398,8 +412,8 @@ internal void RaiseRowDragStarted(PointerEventArgs trigger) if (_source is null || RowSelection is null) return; - var allowedEffects = AutoDragDropRows && !_source.IsSorted ? - DragDropEffects.Move : + var allowedEffects = AutoDragDropRows && !_source.IsSorted ? + DragDropEffects.Move : DragDropEffects.None; var route = BuildEventRoute(RowDragStartedEvent); @@ -552,7 +566,7 @@ private void AutoScroll(bool direction) [MemberNotNullWhen(true, nameof(_source))] private bool CalculateAutoDragDrop( TreeDataGridRow targetRow, - DragEventArgs e, + DragEventArgs e, [NotNullWhen(true)] out DragInfo? data, out TreeDataGridRowDropPosition position) { @@ -630,7 +644,7 @@ private void OnDragLeave(RoutedEventArgs e) private void OnDrop(DragEventArgs e) { StopDrag(); - + if (!TryGetRow(e.Source as Control, out var row)) return; @@ -649,7 +663,7 @@ private void OnDrop(DragEventArgs e) position = ev.Position; } - if (autoDrop && + if (autoDrop && _source is not null && position != TreeDataGridRowDropPosition.None) { @@ -681,6 +695,26 @@ private void OnAutoScrollTick(object? sender, EventArgs e) } } + private void OnSelectionInteractionChanged(object? sender, EventArgs e) + { + RowsPresenter?.UpdateSelection(SelectionInteraction); + } + + private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ITreeDataGridSource.Selection)) + { + SelectionInteraction = Source?.Selection as ITreeDataGridSelectionInteraction; + RowsPresenter?.UpdateSelection(SelectionInteraction); + } + } + + private void OnSourceSorted() + { + RowsPresenter?.RecycleAllElements(); + RowsPresenter?.InvalidateMeasure(); + } + private static TreeDataGridRowDropPosition GetDropPosition( ITreeDataGridSource source, DragEventArgs e, diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs index e432825c..6b4d27f6 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/Selection/TreeSelectionModelBaseTests_Single.cs @@ -415,6 +415,20 @@ public void Selecting_Already_Selected_Item_Doesnt_Raise_SelectionChanged() Assert.Equal(0, raised); } + + [Fact] + public void Selecting_Item_Twice_Results_In_Correct_Count() + { + var target = CreateTarget(); + + using (target.BatchUpdate()) + { + target.SelectedIndex = new IndexPath(1); + target.Select(new IndexPath(1)); + } + + Assert.Equal(1, target.Count); + } } public class Deselect diff --git a/tests/Avalonia.Controls.TreeDataGrid.Tests/TestTemplates.cs b/tests/Avalonia.Controls.TreeDataGrid.Tests/TestTemplates.cs index b988b391..ab8d7be7 100644 --- a/tests/Avalonia.Controls.TreeDataGrid.Tests/TestTemplates.cs +++ b/tests/Avalonia.Controls.TreeDataGrid.Tests/TestTemplates.cs @@ -79,7 +79,6 @@ public static IControlTemplate TreeDataGridTemplate() [!TreeDataGridRowsPresenter.ColumnsProperty] = x[!TreeDataGrid.ColumnsProperty], [!TreeDataGridRowsPresenter.ElementFactoryProperty] = x[!TreeDataGrid.ElementFactoryProperty], [!TreeDataGridRowsPresenter.ItemsProperty] = x[!TreeDataGrid.RowsProperty], - [!TreeDataGridRowsPresenter.SelectionProperty] = x[!TreeDataGrid.SelectionInteractionProperty], }.RegisterInNameScope(ns), }.RegisterInNameScope(ns) }