diff --git a/docfx/articles/columns-and-editing.md b/docfx/articles/columns-and-editing.md index 1a543ccb..e3f9cee3 100644 --- a/docfx/articles/columns-and-editing.md +++ b/docfx/articles/columns-and-editing.md @@ -64,6 +64,60 @@ Double-click the column header resize handle to fit the column to visible conten CanUserReorderColumns="True" /> ``` +### Sharing widths across grids + +Use one `DataGridColumnWidthSharingScope` resource for every grid that should participate, then assign the same `WidthSharingGroup` to corresponding column definitions. The scope starts a group at its largest registered width and propagates later programmatic, automatic, and user-initiated width changes in either direction. + +```xml + + + + + + + + + + +``` + +```csharp +public sealed class ComparisonViewModel : ReactiveObject +{ + public DataGridColumnDefinitionList CurrentColumns { get; } = CreateColumns(); + public DataGridColumnDefinitionList BaselineColumns { get; } = CreateColumns(); + + private static DataGridColumnDefinitionList CreateColumns() => new() + { + new DataGridTextColumnDefinition + { + Header = "Name", + Width = DataGridLength.Auto, + WidthSharingGroup = "name" + }, + new DataGridNumericColumnDefinition + { + Header = "Amount", + Width = new DataGridLength(120), + WidthSharingGroup = "amount" + } + }; +} +``` + +For columns declared directly in XAML, set `sizing:DataGridColumnWidthSharing.Group="name"` instead. Group names are case-sensitive. Pixel columns remain pixel-sized, intrinsic sizing modes such as `Auto` are preserved, and star columns become pixel columns when synchronized because an exact width must be shared across grids with potentially different available space. The scope respects the common `MinWidth`/`MaxWidth` range; if constraints do not overlap, each column is clamped to its own range. Remove the group or scope to stop synchronization. Call `Synchronize()` when you need to recompute a group from the largest current width explicitly. + ## Headers, Sorting, and Filters Columns expose header content and sorting metadata: diff --git a/src/Avalonia.Controls.DataGrid.UnitTests/Sizing/DataGridColumnWidthSharingTests.cs b/src/Avalonia.Controls.DataGrid.UnitTests/Sizing/DataGridColumnWidthSharingTests.cs new file mode 100644 index 00000000..92e268a1 --- /dev/null +++ b/src/Avalonia.Controls.DataGrid.UnitTests/Sizing/DataGridColumnWidthSharingTests.cs @@ -0,0 +1,494 @@ +// Copyright (c) Wieslaw Soltes. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +using System.Collections.ObjectModel; +using Avalonia.Controls; +using Avalonia.Controls.DataGridSizing; +using Avalonia.Headless.XUnit; +using Avalonia.Markup.Xaml; +using Xunit; + +namespace Avalonia.Controls.DataGridTests.Sizing; + +public class DataGridColumnWidthSharingTests +{ + [Fact] + public void Scope_Uses_Largest_Initial_Width_And_Propagates_Later_Changes() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn firstColumn = CreateColumn(100, "name"); + DataGridTextColumn secondColumn = CreateColumn(180, "name"); + + DataGrid firstGrid = CreateGrid(scope, firstColumn); + DataGrid secondGrid = CreateGrid(scope, secondColumn); + + Assert.Equal(180, firstColumn.ActualWidth); + Assert.Equal(180, secondColumn.ActualWidth); + + firstColumn.Resize(firstColumn.Width, new DataGridLength(240), userInitiated: true); + + Assert.Equal(240, firstColumn.ActualWidth); + Assert.Equal(240, secondColumn.ActualWidth); + + secondColumn.Width = new DataGridLength(80); + + Assert.Equal(80, firstColumn.ActualWidth); + Assert.Equal(80, secondColumn.ActualWidth); + Assert.Same(scope, firstGrid.ColumnWidthSharingScope); + Assert.Same(scope, secondGrid.ColumnWidthSharingScope); + } + + [Fact] + public void Scope_Keeps_Different_Groups_Independent() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn nameColumn = CreateColumn(100, "name"); + DataGridTextColumn amountColumn = CreateColumn(160, "amount"); + CreateGrid(scope, nameColumn); + CreateGrid(scope, amountColumn); + + nameColumn.Width = new DataGridLength(225); + + Assert.Equal(225, nameColumn.ActualWidth); + Assert.Equal(160, amountColumn.ActualWidth); + } + + [Fact] + public void Removed_Column_Is_No_Longer_Synchronized() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn firstColumn = CreateColumn(100, "name"); + DataGridTextColumn removedColumn = CreateColumn(140, "name"); + CreateGrid(scope, firstColumn); + DataGrid secondGrid = CreateGrid(scope, removedColumn); + + secondGrid.Columns.Remove(removedColumn); + var independentGrid = new DataGrid(); + independentGrid.Columns.Add(removedColumn); + firstColumn.Width = new DataGridLength(260); + + Assert.Equal(260, firstColumn.ActualWidth); + Assert.Equal(140, removedColumn.ActualWidth); + } + + [Fact] + public void Changing_Group_Updates_Registration() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn firstColumn = CreateColumn(100, "name"); + DataGridTextColumn secondColumn = CreateColumn(160, "name"); + CreateGrid(scope, firstColumn); + CreateGrid(scope, secondColumn); + + DataGridColumnWidthSharing.SetGroup(secondColumn, "other"); + firstColumn.Width = new DataGridLength(210); + + Assert.Equal(210, firstColumn.ActualWidth); + Assert.Equal(160, secondColumn.ActualWidth); + + DataGridColumnWidthSharing.SetGroup(secondColumn, "name"); + + Assert.Equal(210, secondColumn.ActualWidth); + } + + [Fact] + public void Changing_Grid_Scope_Unregisters_Columns_From_The_Old_Scope() + { + var oldScope = new DataGridColumnWidthSharingScope(); + var newScope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn firstColumn = CreateColumn(100, "name"); + DataGridTextColumn secondColumn = CreateColumn(160, "name"); + CreateGrid(oldScope, firstColumn); + DataGrid secondGrid = CreateGrid(oldScope, secondColumn); + + secondGrid.ColumnWidthSharingScope = newScope; + firstColumn.Width = new DataGridLength(220); + + Assert.Equal(220, firstColumn.ActualWidth); + Assert.Equal(160, secondColumn.ActualWidth); + } + + [AvaloniaFact] + public void Reattached_Grid_Reregisters_Columns_Pruned_While_Detached() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn detachedColumn = CreateColumn(100, "name"); + DataGridTextColumn activeColumn = CreateColumn(150, "name"); + DataGrid detachedGrid = CreateGrid(scope, detachedColumn); + CreateGrid(scope, activeColumn); + var root = new Window { Content = detachedGrid }; + + try + { + root.Show(); + root.Content = null; + + activeColumn.Width = new DataGridLength(210); + root.Content = detachedGrid; + root.UpdateLayout(); + + Assert.Equal(210, detachedColumn.ActualWidth); + + activeColumn.Width = new DataGridLength(240); + + Assert.Equal(240, detachedColumn.ActualWidth); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void Reattached_Grid_Adopts_Active_Group_Width_Instead_Of_Stale_Width() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn detachedColumn = CreateColumn(200, "name"); + DataGridTextColumn activeColumn = CreateColumn(100, "name"); + DataGrid detachedGrid = CreateGrid(scope, detachedColumn); + CreateGrid(scope, activeColumn); + var root = new Window { Content = detachedGrid }; + + try + { + root.Show(); + root.Content = null; + + activeColumn.Width = new DataGridLength(100); + Assert.Equal(100, activeColumn.ActualWidth); + detachedColumn.Width = new DataGridLength(200); + + root.Content = detachedGrid; + root.UpdateLayout(); + + Assert.Equal(100, detachedColumn.ActualWidth); + Assert.Equal(100, activeColumn.ActualWidth); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void Width_Adjustment_Side_Effects_Are_Reported_To_Shared_Participants() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn leadingColumn = CreateColumn(100, group: null); + DataGridTextColumn adjustedColumn = CreateColumn(160, "name"); + DataGridTextColumn peerColumn = CreateColumn(160, "name"); + DataGrid firstGrid = CreateGrid(scope, leadingColumn); + firstGrid.Columns.Add(adjustedColumn); + DataGrid secondGrid = CreateGrid(scope, peerColumn); + firstGrid.CanUserResizeColumns = true; + firstGrid.Height = 120; + secondGrid.Height = 120; + var root = new Window + { + Width = 500, + Height = 300, + Content = new StackPanel + { + Children = + { + firstGrid, + secondGrid + } + } + }; + + try + { + root.Show(); + root.UpdateLayout(); + + double startingWidth = adjustedColumn.ActualWidth; + double remaining = firstGrid.DecreaseColumnWidths( + adjustedColumn.DisplayIndex, + amount: -40, + userInitiated: false); + + Assert.Equal(0, remaining); + Assert.Equal(startingWidth - 40, adjustedColumn.ActualWidth); + Assert.Equal(adjustedColumn.ActualWidth, peerColumn.ActualWidth); + } + finally + { + root.Close(); + } + } + + [AvaloniaFact] + public void Detached_Grid_Scope_Change_Removes_Old_Scope_Registration() + { + var oldScope = new DataGridColumnWidthSharingScope(); + var newScope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn oldScopeColumn = CreateColumn(100, "name"); + DataGridTextColumn movedColumn = CreateColumn(150, "name"); + DataGridTextColumn newScopeColumn = CreateColumn(175, "name"); + CreateGrid(oldScope, oldScopeColumn); + DataGrid movedGrid = CreateGrid(oldScope, movedColumn); + CreateGrid(newScope, newScopeColumn); + var root = new Window { Content = movedGrid }; + + try + { + root.Show(); + root.Content = null; + movedGrid.ColumnWidthSharingScope = newScope; + root.Content = movedGrid; + root.UpdateLayout(); + + Assert.Equal(175, movedColumn.ActualWidth); + + oldScopeColumn.Width = new DataGridLength(220); + + Assert.Equal(175, movedColumn.ActualWidth); + + newScopeColumn.Width = new DataGridLength(240); + + Assert.Equal(240, movedColumn.ActualWidth); + } + finally + { + root.Close(); + } + } + + [Fact] + public void Scope_Uses_Common_Constraints_And_Preserves_Auto_Sizing_Mode() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn constrainedColumn = CreateColumn(100, "name"); + constrainedColumn.MaxWidth = 150; + DataGridTextColumn autoColumn = CreateColumn(DataGridLength.Auto, "name"); + CreateGrid(scope, constrainedColumn); + CreateGrid(scope, autoColumn); + + autoColumn.Width = new DataGridLength(220); + + Assert.Equal(150, constrainedColumn.ActualWidth); + Assert.Equal(150, autoColumn.ActualWidth); + + autoColumn.Width = DataGridLength.Auto; + scope.Synchronize(); + + Assert.True(autoColumn.Width.IsAuto); + Assert.Equal(constrainedColumn.ActualWidth, autoColumn.ActualWidth); + } + + [Fact] + public void Scope_Converts_Equal_Width_Star_Column_To_Synchronized_Pixels() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn pixelColumn = CreateColumn(100, "name"); + DataGridTextColumn starColumn = CreateColumn( + new DataGridLength(1, DataGridLengthUnitType.Star, 100, 100), + "name"); + + CreateGrid(scope, pixelColumn); + CreateGrid(scope, starColumn); + + Assert.True(starColumn.Width.IsAbsolute); + Assert.Equal(100, starColumn.ActualWidth); + Assert.Equal(pixelColumn.ActualWidth, starColumn.ActualWidth); + } + + [Fact] + public void Scope_Does_Not_Convert_Unmeasured_Star_Columns_During_Registration() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn firstColumn = CreateColumn( + new DataGridLength(1, DataGridLengthUnitType.Star), + "name"); + DataGridTextColumn secondColumn = CreateColumn( + new DataGridLength(1, DataGridLengthUnitType.Star), + "name"); + + CreateGrid(scope, firstColumn); + Assert.True(firstColumn.Width.IsStar); + + CreateGrid(scope, secondColumn); + + Assert.True(firstColumn.Width.IsStar); + Assert.True(secondColumn.Width.IsStar); + } + + [Fact] + public void Unmeasured_Star_Column_Does_Not_Clamp_Measured_Participant() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn measuredColumn = CreateColumn(10, "name"); + measuredColumn.MinWidth = 0; + DataGridTextColumn unmeasuredStarColumn = CreateColumn( + new DataGridLength(1, DataGridLengthUnitType.Star), + "name"); + + CreateGrid(scope, measuredColumn); + CreateGrid(scope, unmeasuredStarColumn); + + Assert.Equal(10, measuredColumn.ActualWidth); + Assert.True(unmeasuredStarColumn.Width.IsStar); + + unmeasuredStarColumn.SetWidthInternalNoCallback( + new DataGridLength(1, DataGridLengthUnitType.Star, 30, 30)); + unmeasuredStarColumn.IsInitialDesiredWidthDetermined = true; + + Assert.Equal(30, measuredColumn.ActualWidth); + Assert.Equal(30, unmeasuredStarColumn.ActualWidth); + } + + [Fact] + public void First_Measure_Completion_Reports_Previously_Unmeasured_Star_Columns() + { + var scope = new DataGridColumnWidthSharingScope(); + DataGridTextColumn firstColumn = CreateColumn( + new DataGridLength(1, DataGridLengthUnitType.Star), + "name"); + DataGridTextColumn secondColumn = CreateColumn( + new DataGridLength(1, DataGridLengthUnitType.Star), + "name"); + CreateGrid(scope, firstColumn); + CreateGrid(scope, secondColumn); + + firstColumn.SetWidthInternalNoCallback( + new DataGridLength(1, DataGridLengthUnitType.Star, 100, 100)); + firstColumn.IsInitialDesiredWidthDetermined = true; + secondColumn.SetWidthInternalNoCallback( + new DataGridLength(1, DataGridLengthUnitType.Star, 180, 180)); + secondColumn.IsInitialDesiredWidthDetermined = true; + + Assert.True(firstColumn.Width.IsAbsolute); + Assert.True(secondColumn.Width.IsAbsolute); + Assert.Equal(180, firstColumn.ActualWidth); + Assert.Equal(firstColumn.ActualWidth, secondColumn.ActualWidth); + } + + [Fact] + public void Inherited_Grid_ColumnWidth_Changes_Are_Shared() + { + var scope = new DataGridColumnWidthSharingScope(); + var firstColumn = new DataGridTextColumn(); + var secondColumn = new DataGridTextColumn(); + DataGridColumnWidthSharing.SetGroup(firstColumn, "name"); + DataGridColumnWidthSharing.SetGroup(secondColumn, "name"); + DataGrid firstGrid = CreateGrid(scope, firstColumn); + DataGrid secondGrid = CreateGrid(scope, secondColumn); + + Assert.True(firstColumn.InheritsWidth); + Assert.True(secondColumn.InheritsWidth); + + firstGrid.ColumnWidth = new DataGridLength(165); + + Assert.Equal(165, firstColumn.ActualWidth); + Assert.Equal(165, secondColumn.ActualWidth); + + secondGrid.ColumnWidth = new DataGridLength(210); + + Assert.Equal(210, firstColumn.ActualWidth); + Assert.Equal(210, secondColumn.ActualWidth); + } + + [Fact] + public void Column_Definitions_Apply_And_Update_Width_Sharing_Group() + { + var scope = new DataGridColumnWidthSharingScope(); + var firstDefinition = new DataGridTextColumnDefinition + { + Width = new DataGridLength(100), + WidthSharingGroup = "name" + }; + var secondDefinition = new DataGridTextColumnDefinition + { + Width = new DataGridLength(175), + WidthSharingGroup = "name" + }; + DataGrid firstGrid = CreateGrid(scope, firstDefinition); + DataGrid secondGrid = CreateGrid(scope, secondDefinition); + DataGridColumn firstColumn = firstGrid.Columns[0]; + DataGridColumn secondColumn = secondGrid.Columns[0]; + + Assert.Equal("name", DataGridColumnWidthSharing.GetGroup(firstColumn)); + Assert.Equal(175, firstColumn.ActualWidth); + Assert.Equal(175, secondColumn.ActualWidth); + + secondDefinition.WidthSharingGroup = "other"; + firstColumn.Width = new DataGridLength(230); + + Assert.Equal("other", DataGridColumnWidthSharing.GetGroup(secondColumn)); + Assert.Equal(230, firstColumn.ActualWidth); + Assert.Equal(175, secondColumn.ActualWidth); + } + + [Fact] + public void Scope_And_Attached_Group_Can_Be_Configured_In_Xaml() + { + const string xaml = """ + + + + + + + + + + + + + + + + """; + + StackPanel panel = AvaloniaRuntimeXamlLoader.Parse(xaml, typeof(DataGrid).Assembly); + DataGrid firstGrid = Assert.IsType(panel.Children[0]); + DataGrid secondGrid = Assert.IsType(panel.Children[1]); + + Assert.Same(firstGrid.ColumnWidthSharingScope, secondGrid.ColumnWidthSharingScope); + Assert.Equal(180, firstGrid.Columns[0].ActualWidth); + Assert.Equal(180, secondGrid.Columns[0].ActualWidth); + } + + private static DataGridTextColumn CreateColumn(double width, string group) + { + return CreateColumn(new DataGridLength(width), group); + } + + private static DataGridTextColumn CreateColumn(DataGridLength width, string group) + { + var column = new DataGridTextColumn + { + Width = width + }; + DataGridColumnWidthSharing.SetGroup(column, group); + return column; + } + + private static DataGrid CreateGrid( + DataGridColumnWidthSharingScope scope, + DataGridColumn column) + { + var grid = new DataGrid + { + ColumnWidthSharingScope = scope + }; + grid.Columns.Add(column); + return grid; + } + + private static DataGrid CreateGrid( + DataGridColumnWidthSharingScope scope, + DataGridColumnDefinition definition) + { + return new DataGrid + { + ColumnWidthSharingScope = scope, + ColumnDefinitionsSource = new ObservableCollection { definition } + }; + } +} diff --git a/src/Avalonia.Controls.DataGrid/ColumnDefinitions/DataGridColumnDefinition.cs b/src/Avalonia.Controls.DataGrid/ColumnDefinitions/DataGridColumnDefinition.cs index ea4a613b..92ae3156 100644 --- a/src/Avalonia.Controls.DataGrid/ColumnDefinitions/DataGridColumnDefinition.cs +++ b/src/Avalonia.Controls.DataGrid/ColumnDefinitions/DataGridColumnDefinition.cs @@ -13,6 +13,7 @@ using Avalonia.Utilities; using Avalonia.Controls.DataGridSearching; using Avalonia.Controls.DataGridSorting; +using Avalonia.Controls.DataGridSizing; using Avalonia.Controls.Templates; using Avalonia.Layout; using Avalonia.Styling; @@ -90,6 +91,7 @@ abstract class DataGridColumnDefinition : INotifyPropertyChanged private string _sortMemberPath; private object _tag; private object _columnKey; + private string _widthSharingGroup; private System.Collections.IComparer _customSortComparer; private IDataGridColumnValueAccessor _valueAccessor; private Type _valueType; @@ -273,6 +275,15 @@ public object ColumnKey set => SetProperty(ref _columnKey, value); } + /// + /// Gets or sets the name of the column width-sharing group. + /// + public string WidthSharingGroup + { + get => _widthSharingGroup; + set => SetProperty(ref _widthSharingGroup, value); + } + public System.Collections.IComparer CustomSortComparer { get => _customSortComparer; @@ -397,6 +408,7 @@ protected virtual void ApplyBaseProperties(DataGridColumn column, DataGridColumn { column.Header = Header; column.ColumnKey = ColumnKey; + DataGridColumnWidthSharing.SetGroup(column, WidthSharingGroup); column.SortMemberPath = SortMemberPath; column.Tag = Tag; column.CustomSortComparer = CustomSortComparer; @@ -609,6 +621,9 @@ private bool ApplyBasePropertyChange(DataGridColumn column, DataGridColumnDefini case nameof(Tag): column.Tag = Tag; return true; + case nameof(WidthSharingGroup): + DataGridColumnWidthSharing.SetGroup(column, WidthSharingGroup); + return true; case nameof(CustomSortComparer): column.CustomSortComparer = CustomSortComparer; return true; diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.ColumnWidthSharing.cs b/src/Avalonia.Controls.DataGrid/DataGrid.ColumnWidthSharing.cs new file mode 100644 index 00000000..8a3a6de3 --- /dev/null +++ b/src/Avalonia.Controls.DataGrid/DataGrid.ColumnWidthSharing.cs @@ -0,0 +1,66 @@ +// Copyright (c) Wieslaw Soltes. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +#nullable disable + +using Avalonia.Controls.DataGridSizing; + +namespace Avalonia.Controls +{ +#if !DATAGRID_INTERNAL + public +#else + internal +#endif + partial class DataGrid + { + private void OnColumnWidthSharingScopeChanged(AvaloniaPropertyChangedEventArgs e) + { + DataGridColumnWidthSharingScope oldScope = e.OldValue as DataGridColumnWidthSharingScope; + DataGridColumnWidthSharingScope newScope = e.NewValue as DataGridColumnWidthSharingScope; + + oldScope?.UnregisterGrid(this); + newScope?.RegisterGrid(this); + } + + internal void OnColumnWidthSharingGroupChanged( + DataGridColumn column, + string oldGroup, + string newGroup) + { + ColumnWidthSharingScope?.ChangeColumnGroup(column, oldGroup, newGroup); + } + + internal void OnColumnWidthSharingColumnAttached(DataGridColumn column) + { + ColumnWidthSharingScope?.RegisterColumn(column); + } + + internal void OnColumnWidthSharingColumnDetached(DataGridColumn column) + { + ColumnWidthSharingScope?.UnregisterColumn(column); + } + + internal void OnColumnWidthSharingColumnMeasured(DataGridColumn column) + { + ColumnWidthSharingScope?.ReportWidth(column); + } + + internal void OnColumnWidthSharingSideEffect(DataGridColumn column) + { + ColumnWidthSharingScope?.ReportWidth(column); + } + + internal void RefreshColumnWidthSharingRegistration() + { + DataGridColumnWidthSharingScope scope = ColumnWidthSharingScope; + if (scope == null) + { + return; + } + + scope.UnregisterGrid(this); + scope.RegisterGrid(this, adoptExistingWidths: true); + } + } +} diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.Columns.Width.cs b/src/Avalonia.Controls.DataGrid/DataGrid.Columns.Width.cs index d3aa6d6f..b1442ab6 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.Columns.Width.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.Columns.Width.cs @@ -42,6 +42,7 @@ private static double DecreaseNonStarColumnWidth(DataGridColumn column, double t Math.Max(targetWidth - column.Width.DisplayValue, amount)); column.SetWidthDisplayValue(column.Width.DisplayValue + adjustment); + column.OwningGrid?.OnColumnWidthSharingSideEffect(column); return amount - adjustment; } @@ -109,6 +110,7 @@ private static double IncreaseNonStarColumnWidth(DataGridColumn column, double t Math.Min(targetWidth - column.Width.DisplayValue, amount)); column.SetWidthDisplayValue(column.Width.DisplayValue + adjustment); + column.OwningGrid?.OnColumnWidthSharingSideEffect(column); return amount - adjustment; } diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.Columns.cs b/src/Avalonia.Controls.DataGrid/DataGrid.Columns.cs index d4bfd4b2..08407425 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.Columns.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.Columns.cs @@ -604,6 +604,8 @@ internal void OnColumnWidthChanged(DataGridColumn updatedColumn) EnsureHorizontalLayout(); UpdateSummaryRowLayout(); } + + ColumnWidthSharingScope?.ReportWidth(updatedColumn); } internal void OnFillerColumnWidthNeeded(double finalWidth) @@ -1017,6 +1019,7 @@ private void NormalizeStarColumnWidths(int displayIndex, bool userInitiated) var expected = totalStarColumnsWidth * column.Width.Value / totalStarWeights; column.SetWidthDesiredValue(expected); column.SetWidthDisplayValue(expected); + OnColumnWidthSharingSideEffect(column); } } @@ -1078,6 +1081,7 @@ private double AdjustStarColumnWidths(int displayIndex, double remainingAdjustme remainingAdjustment -= adjustment; totalStarWeights -= starColumnPair.Key.Width.Value; starColumnPair.Key.SetWidthDisplayValue(Math.Max(DataGrid.DATAGRID_minimumStarColumnWidth, starColumnPair.Key.Width.DisplayValue + adjustment)); + OnColumnWidthSharingSideEffect(starColumnPair.Key); } return remainingAdjustment; @@ -1397,7 +1401,8 @@ private void OnColumnWidthChanged(AvaloniaPropertyChangedEventArgs e) { if (column.InheritsWidth) { - column.SetWidthInternalNoCallback(value); + column.SetWidthInternalNoCallback(value, preserveInheritance: true); + ColumnWidthSharingScope?.ReportWidth(column); } } diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.Properties.cs b/src/Avalonia.Controls.DataGrid/DataGrid.Properties.cs index 5c1ecfbe..3dbfe303 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.Properties.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.Properties.cs @@ -11,6 +11,7 @@ using Avalonia.Controls.DataGridSorting; using Avalonia.Controls.DataGridSearching; using Avalonia.Controls.DataGridFilling; +using Avalonia.Controls.DataGridSizing; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Media; @@ -465,6 +466,21 @@ DataGridLength ColumnWidth set { SetValue(ColumnWidthProperty, value); } } + /// + /// Identifies the dependency property. + /// + public static readonly StyledProperty ColumnWidthSharingScopeProperty = + AvaloniaProperty.Register(nameof(ColumnWidthSharingScope)); + + /// + /// Gets or sets the scope that synchronizes columns with matching width-sharing group names. + /// + public DataGridColumnWidthSharingScope ColumnWidthSharingScope + { + get { return GetValue(ColumnWidthSharingScopeProperty); } + set { SetValue(ColumnWidthSharingScopeProperty, value); } + } + /// /// Identifies the dependency property. /// diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.Template.cs b/src/Avalonia.Controls.DataGrid/DataGrid.Template.cs index 6a57d976..1397ca01 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.Template.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.Template.cs @@ -164,6 +164,7 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) } } NormalizeColumnDisplayIndexesAfterDetachedMutations(); + RefreshColumnWidthSharingRegistration(); if (_columnHeadersPresenter != null && _columnHeadersPresenter.OwningGrid == null) { _columnHeadersPresenter.OwningGrid = this; diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.cs b/src/Avalonia.Controls.DataGrid/DataGrid.cs index a9b7c705..04eee92f 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.cs @@ -517,6 +517,7 @@ static DataGrid() ItemsSourceProperty.Changed.AddClassHandler((x, e) => x.OnItemsSourcePropertyChanged(e)); CanUserResizeColumnsProperty.Changed.AddClassHandler((x, e) => x.OnCanUserResizeColumnsChanged(e)); ColumnWidthProperty.Changed.AddClassHandler((x, e) => x.OnColumnWidthChanged(e)); + ColumnWidthSharingScopeProperty.Changed.AddClassHandler((x, e) => x.OnColumnWidthSharingScopeChanged(e)); FrozenColumnCountProperty.Changed.AddClassHandler((x, e) => x.OnFrozenColumnCountChanged(e)); FrozenColumnCountRightProperty.Changed.AddClassHandler((x, e) => x.OnFrozenColumnCountRightChanged(e)); GridLinesVisibilityProperty.Changed.AddClassHandler((x, e) => x.OnGridLinesVisibilityChanged(e)); diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumn.Width.cs b/src/Avalonia.Controls.DataGrid/DataGridColumn.Width.cs index e60f9de0..5a6700a6 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumn.Width.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumn.Width.cs @@ -126,7 +126,7 @@ internal void ComputeLayoutRoundedWidth(double leftEdge) /// internal void EnsureWidth() { - SetWidthInternalNoCallback(CoerceWidth(this, Width)); + SetWidthInternalNoCallback(CoerceWidth(this, Width), preserveInheritance: InheritsWidth); } /// @@ -255,17 +255,21 @@ internal void SetWidthInternal(DataGridLength width) /// Sets the column's Width directly, without any callback effects. /// /// The new Width. - internal void SetWidthInternalNoCallback(DataGridLength width) + /// Whether the internal update should retain grid-level width inheritance. + internal void SetWidthInternalNoCallback(DataGridLength width, bool preserveInheritance = false) { - var originalValue = _setWidthInternalNoCallback; + bool originalNoCallback = _setWidthInternalNoCallback; + bool originalInternal = _settingWidthInternally; _setWidthInternalNoCallback = true; + _settingWidthInternally |= preserveInheritance; try { SetCurrentValue(WidthProperty, width); } finally { - _setWidthInternalNoCallback = originalValue; + _setWidthInternalNoCallback = originalNoCallback; + _settingWidthInternally = originalInternal; } } diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumn.cs b/src/Avalonia.Controls.DataGrid/DataGridColumn.cs index 5e3b023e..d1cc9ecd 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumn.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumn.cs @@ -235,10 +235,24 @@ internal bool InheritsWidth /// internal bool IsInitialDesiredWidthDetermined { - get; - set; + get => _isInitialDesiredWidthDetermined; + set + { + if (_isInitialDesiredWidthDetermined == value) + { + return; + } + + _isInitialDesiredWidthDetermined = value; + if (value) + { + OwningGrid?.OnColumnWidthSharingColumnMeasured(this); + } + } } + private bool _isInitialDesiredWidthDetermined; + internal double LayoutRoundedWidth { get; diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumnCollection.cs b/src/Avalonia.Controls.DataGrid/DataGridColumnCollection.cs index ec68c492..ba259394 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumnCollection.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumnCollection.cs @@ -163,6 +163,7 @@ protected override void ClearItems() for (int columnIndex = 0; columnIndex < ItemsInternal.Count; columnIndex++) { // Detach the column... + _owningGrid.OnColumnWidthSharingColumnDetached(ItemsInternal[columnIndex]); ItemsInternal[columnIndex].OwningGrid = null; ItemsInternal[columnIndex].ClearElementCache(); } @@ -209,6 +210,7 @@ protected override void InsertItem(int columnIndex, DataGridColumn dataGridColum ItemsInternal.Insert(columnIndexWithFiller, dataGridColumn); dataGridColumn.Index = columnIndexWithFiller; dataGridColumn.OwningGrid = _owningGrid; + _owningGrid.OnColumnWidthSharingColumnAttached(dataGridColumn); dataGridColumn.RemoveEditingElement(); if (dataGridColumn != RowGroupSpacerColumn && dataGridColumn != FillerColumn) { @@ -612,6 +614,7 @@ private void RemoveItemPrivate(int columnIndex, bool isSpacer) VisibleEdgedColumnsWidth -= dataGridColumn.ActualWidth; } // continue with the base remove + _owningGrid.OnColumnWidthSharingColumnDetached(dataGridColumn); dataGridColumn.OwningGrid = null; _owningGrid.OnRemovedColumn_PreNotification(dataGridColumn, headerCell); _owningGrid.OnColumnCollectionChanged_PreNotification(false /*columnsGrew*/); diff --git a/src/Avalonia.Controls.DataGrid/Sizing/DataGridColumnWidthSharing.cs b/src/Avalonia.Controls.DataGrid/Sizing/DataGridColumnWidthSharing.cs new file mode 100644 index 00000000..3a0ede30 --- /dev/null +++ b/src/Avalonia.Controls.DataGrid/Sizing/DataGridColumnWidthSharing.cs @@ -0,0 +1,58 @@ +// Copyright (c) Wieslaw Soltes. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +#nullable disable + +using Avalonia; +using Avalonia.Controls; + +namespace Avalonia.Controls.DataGridSizing +{ + /// + /// Provides the attached column group used by . + /// +#if !DATAGRID_INTERNAL + public +#else + internal +#endif + static class DataGridColumnWidthSharing + { + /// + /// Identifies the Group attached property. + /// + public static readonly AttachedProperty GroupProperty = + AvaloniaProperty.RegisterAttached( + "Group", + typeof(DataGridColumnWidthSharing)); + + static DataGridColumnWidthSharing() + { + GroupProperty.Changed.AddClassHandler( + (column, args) => column.OwningGrid?.OnColumnWidthSharingGroupChanged( + column, + args.OldValue as string, + args.NewValue as string)); + } + + /// + /// Gets the width-sharing group assigned to a column. + /// + /// The target column. + /// The group name, or when sharing is disabled. + public static string GetGroup(AvaloniaObject target) + { + return target.GetValue(GroupProperty); + } + + /// + /// Sets the width-sharing group assigned to a column. + /// + /// The target column. + /// The group name, or to disable sharing. + public static void SetGroup(AvaloniaObject target, string value) + { + target.SetValue(GroupProperty, value); + } + } +} diff --git a/src/Avalonia.Controls.DataGrid/Sizing/DataGridColumnWidthSharingScope.cs b/src/Avalonia.Controls.DataGrid/Sizing/DataGridColumnWidthSharingScope.cs new file mode 100644 index 00000000..0713b312 --- /dev/null +++ b/src/Avalonia.Controls.DataGrid/Sizing/DataGridColumnWidthSharingScope.cs @@ -0,0 +1,323 @@ +// Copyright (c) Wieslaw Soltes. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Avalonia.Controls.DataGridSizing +{ + /// + /// Synchronizes the display widths of columns that share the same group name across DataGrid controls. + /// +#if !DATAGRID_INTERNAL + public +#else + internal +#endif + sealed class DataGridColumnWidthSharingScope + { + private readonly Dictionary>> _groups = + new(StringComparer.Ordinal); + private bool _isSynchronizing; + + /// + /// Initializes a new instance of the class. + /// + public DataGridColumnWidthSharingScope() + { + } + + /// + /// Recomputes every registered group using its largest current column width. + /// + public void Synchronize() + { + if (_isSynchronizing) + { + return; + } + + foreach (string group in new List(_groups.Keys)) + { + SynchronizeLargestWidth(group); + } + } + + internal void RegisterGrid(DataGrid grid, bool adoptExistingWidths = false) + { + if (grid == null) + { + return; + } + + foreach (DataGridColumn column in grid.ColumnsInternal.ItemsInternal) + { + RegisterColumn(column, adoptExistingWidths); + } + } + + internal void UnregisterGrid(DataGrid grid) + { + if (grid == null) + { + return; + } + + foreach (string group in new List(_groups.Keys)) + { + RemoveMatching( + group, + column => column.OwningGrid == grid || + grid.ColumnsInternal.ItemsInternal.Contains(column)); + } + } + + internal void RegisterColumn(DataGridColumn column, bool adoptExistingWidth = false) + { + if (column?.OwningGrid == null) + { + return; + } + + string group = DataGridColumnWidthSharing.GetGroup(column); + if (string.IsNullOrWhiteSpace(group)) + { + return; + } + + List> participants = GetOrCreateGroup(group); + Prune(participants); + double existingWidth = adoptExistingWidth + ? GetLargestMeasuredWidth(participants) + : 0; + foreach (WeakReference reference in participants) + { + if (reference.TryGetTarget(out DataGridColumn existing) && ReferenceEquals(existing, column)) + { + return; + } + } + + participants.Add(new WeakReference(column)); + if (existingWidth > 0) + { + ApplyWidth(participants, existingWidth); + } + else + { + SynchronizeLargestWidth(group); + } + } + + internal void UnregisterColumn(DataGridColumn column, string group = null) + { + if (column == null) + { + return; + } + + group ??= DataGridColumnWidthSharing.GetGroup(column); + if (!string.IsNullOrWhiteSpace(group)) + { + RemoveMatching(group, candidate => ReferenceEquals(candidate, column)); + } + } + + internal void ChangeColumnGroup(DataGridColumn column, string oldGroup, string newGroup) + { + UnregisterColumn(column, oldGroup); + if (!string.IsNullOrWhiteSpace(newGroup)) + { + RegisterColumn(column); + } + } + + internal void ReportWidth(DataGridColumn source) + { + if (_isSynchronizing || source?.OwningGrid == null || IsUnmeasuredStar(source)) + { + return; + } + + string group = DataGridColumnWidthSharing.GetGroup(source); + if (string.IsNullOrWhiteSpace(group) || + !_groups.TryGetValue(group, out List> participants)) + { + return; + } + + Prune(participants); + bool isRegistered = false; + foreach (WeakReference reference in participants) + { + if (reference.TryGetTarget(out DataGridColumn column) && + ReferenceEquals(column, source)) + { + isRegistered = true; + break; + } + } + + if (!isRegistered) + { + RemoveGroupIfEmpty(group, participants); + return; + } + + ApplyWidth(participants, source.ActualWidth); + RemoveGroupIfEmpty(group, participants); + } + + private void SynchronizeLargestWidth(string group) + { + if (!_groups.TryGetValue(group, out List> participants)) + { + return; + } + + Prune(participants); + double width = GetLargestMeasuredWidth(participants); + + if (width > 0) + { + ApplyWidth(participants, width); + } + + RemoveGroupIfEmpty(group, participants); + } + + private static double GetLargestMeasuredWidth( + List> participants) + { + double width = 0; + foreach (WeakReference reference in participants) + { + if (reference.TryGetTarget(out DataGridColumn column) && !IsUnmeasuredStar(column)) + { + width = Math.Max(width, column.ActualWidth); + } + } + + return width; + } + + private void ApplyWidth(List> participants, double requestedWidth) + { + if (_isSynchronizing || double.IsNaN(requestedWidth) || double.IsInfinity(requestedWidth)) + { + return; + } + + Prune(participants); + double minimum = 0; + double maximum = double.PositiveInfinity; + foreach (WeakReference reference in participants) + { + if (reference.TryGetTarget(out DataGridColumn column) && + !IsUnmeasuredStar(column)) + { + minimum = Math.Max(minimum, column.ActualMinWidth); + maximum = Math.Min(maximum, column.ActualMaxWidth); + } + } + + double sharedWidth = minimum <= maximum + ? Math.Clamp(requestedWidth, minimum, maximum) + : requestedWidth; + + _isSynchronizing = true; + try + { + foreach (WeakReference reference in participants) + { + if (!reference.TryGetTarget(out DataGridColumn column) || + column.OwningGrid == null || + IsUnmeasuredStar(column)) + { + continue; + } + + double width = minimum <= maximum + ? sharedWidth + : Math.Clamp(sharedWidth, column.ActualMinWidth, column.ActualMaxWidth); + DataGridLength current = column.Width; + if (Math.Abs(column.ActualWidth - width) <= 0.001 && !current.IsStar) + { + continue; + } + + DataGridLength synchronized = current.IsAbsolute || current.IsStar + ? new DataGridLength(width) + : new DataGridLength(current.Value, current.UnitType, width, width); + column.SetWidthInternalNoCallback(synchronized, preserveInheritance: column.InheritsWidth); + column.OwningGrid.OnColumnWidthChanged(column); + } + } + finally + { + _isSynchronizing = false; + } + } + + private static bool IsUnmeasuredStar(DataGridColumn column) + { + if (!column.Width.IsStar || column.IsInitialDesiredWidthDetermined) + { + return false; + } + + return double.IsNaN(column.Width.DisplayValue) || + column.Width.DisplayValue <= column.ActualMinWidth + 0.001; + } + + private List> GetOrCreateGroup(string group) + { + if (!_groups.TryGetValue(group, out List> participants)) + { + participants = new List>(); + _groups.Add(group, participants); + } + + return participants; + } + + private void RemoveMatching(string group, Predicate predicate) + { + if (!_groups.TryGetValue(group, out List> participants)) + { + return; + } + + for (int index = participants.Count - 1; index >= 0; index--) + { + if (!participants[index].TryGetTarget(out DataGridColumn column) || predicate(column)) + { + participants.RemoveAt(index); + } + } + + RemoveGroupIfEmpty(group, participants); + } + + private static void Prune(List> participants) + { + for (int index = participants.Count - 1; index >= 0; index--) + { + if (!participants[index].TryGetTarget(out DataGridColumn column) || column.OwningGrid == null) + { + participants.RemoveAt(index); + } + } + } + + private void RemoveGroupIfEmpty(string group, List> participants) + { + if (participants.Count == 0) + { + _groups.Remove(group); + } + } + } +}