diff --git a/src/Avalonia.Controls/ColumnDefinition.cs b/src/Avalonia.Controls/ColumnDefinition.cs index a6b34f8a16..d316881a05 100644 --- a/src/Avalonia.Controls/ColumnDefinition.cs +++ b/src/Avalonia.Controls/ColumnDefinition.cs @@ -88,4 +88,4 @@ namespace Avalonia.Controls set { SetValue(WidthProperty, value); } } } -} \ No newline at end of file +} diff --git a/src/Avalonia.Controls/Grid.cs b/src/Avalonia.Controls/Grid.cs index 1a07ccaf7e..b51583d8b3 100644 --- a/src/Avalonia.Controls/Grid.cs +++ b/src/Avalonia.Controls/Grid.cs @@ -3,10 +3,13 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Reactive.Linq; using System.Runtime.CompilerServices; using Avalonia.Collections; using Avalonia.Controls.Utils; +using Avalonia.VisualTree; using JetBrains.Annotations; namespace Avalonia.Controls @@ -44,6 +47,24 @@ namespace Avalonia.Controls public static readonly AttachedProperty RowSpanProperty = AvaloniaProperty.RegisterAttached("RowSpan", 1); + public static readonly AttachedProperty IsSharedSizeScopeProperty = + AvaloniaProperty.RegisterAttached("IsSharedSizeScope", false); + + protected override void OnMeasureInvalidated() + { + base.OnMeasureInvalidated(); + _sharedSizeHost?.InvalidateMeasure(this); + } + + private SharedSizeScopeHost _sharedSizeHost; + + /// + /// Defines the SharedSizeScopeHost private property. + /// The ampersands are used to make accessing the property via xaml inconvenient. + /// + internal static readonly AttachedProperty s_sharedSizeScopeHostProperty = + AvaloniaProperty.RegisterAttached("&&SharedSizeScopeHost"); + private ColumnDefinitions _columnDefinitions; private RowDefinitions _rowDefinitions; @@ -51,6 +72,13 @@ namespace Avalonia.Controls static Grid() { AffectsParentMeasure(ColumnProperty, ColumnSpanProperty, RowProperty, RowSpanProperty); + IsSharedSizeScopeProperty.Changed.AddClassHandler(IsSharedSizeScopeChanged); + } + + public Grid() + { + this.AttachedToVisualTree += Grid_AttachedToVisualTree; + this.DetachedFromVisualTree += Grid_DetachedFromVisualTree; } /// @@ -77,6 +105,7 @@ namespace Avalonia.Controls _columnDefinitions = value; _columnDefinitions.TrackItemPropertyChanged(_ => InvalidateMeasure()); + _columnDefinitions.CollectionChanged += (_, __) => InvalidateMeasure(); } } @@ -104,6 +133,7 @@ namespace Avalonia.Controls _rowDefinitions = value; _rowDefinitions.TrackItemPropertyChanged(_ => InvalidateMeasure()); + _rowDefinitions.CollectionChanged += (_, __) => InvalidateMeasure(); } } @@ -271,6 +301,11 @@ namespace Avalonia.Controls _rowLayoutCache = rowLayout; _columnLayoutCache = columnLayout; + if (_sharedSizeHost?.ParticipatesInScope(this) ?? false) + { + _sharedSizeHost.UpdateMeasureStatus(this, rowResult, columnResult); + } + return new Size(columnResult.DesiredLength, rowResult.DesiredLength); // Measure each child only once. @@ -319,9 +354,21 @@ namespace Avalonia.Controls var (safeColumns, safeRows) = GetSafeColumnRows(); var columnLayout = _columnLayoutCache; var rowLayout = _rowLayoutCache; + + var rowCache = _rowMeasureCache; + var columnCache = _columnMeasureCache; + + if (_sharedSizeHost?.ParticipatesInScope(this) ?? false) + { + (rowCache, columnCache) = _sharedSizeHost.HandleArrange(this, _rowMeasureCache, _columnMeasureCache); + + rowCache = rowLayout.Measure(finalSize.Height, rowCache.LeanLengthList); + columnCache = columnLayout.Measure(finalSize.Width, columnCache.LeanLengthList); + } + // Calculate for arrange result. - var columnResult = columnLayout.Arrange(finalSize.Width, _columnMeasureCache); - var rowResult = rowLayout.Arrange(finalSize.Height, _rowMeasureCache); + var columnResult = columnLayout.Arrange(finalSize.Width, columnCache); + var rowResult = rowLayout.Arrange(finalSize.Height, rowCache); // Arrange the children. foreach (var child in Children.OfType()) { @@ -350,6 +397,73 @@ namespace Avalonia.Controls return finalSize; } + /// + /// Tests whether this grid belongs to a shared size scope. + /// + /// True if the grid is registered in a shared size scope. + internal bool HasSharedSizeScope() + { + return _sharedSizeHost != null; + } + + /// + /// Called when the SharedSizeScope for a given grid has changed. + /// Unregisters the grid from it's current scope and finds a new one (if any) + /// + /// + /// This method, while not efficient, correctly handles nested scopes, with any order of scope changes. + /// + internal void SharedScopeChanged() + { + _sharedSizeHost?.UnegisterGrid(this); + + _sharedSizeHost = null; + var scope = this.GetVisualAncestors().OfType() + .FirstOrDefault(c => c.GetValue(IsSharedSizeScopeProperty)); + + if (scope != null) + { + _sharedSizeHost = scope.GetValue(s_sharedSizeScopeHostProperty); + _sharedSizeHost.RegisterGrid(this); + } + + InvalidateMeasure(); + } + + /// + /// Callback when a grid is attached to the visual tree. Finds the innermost SharedSizeScope and registers the grid + /// in it. + /// + /// The source of the event. + /// The event arguments. + private void Grid_AttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs e) + { + var scope = + new Control[] { this }.Concat(this.GetVisualAncestors().OfType()) + .FirstOrDefault(c => c.GetValue(IsSharedSizeScopeProperty)); + + if (_sharedSizeHost != null) + throw new AvaloniaInternalException("Shared size scope already present when attaching to visual tree!"); + + if (scope != null) + { + _sharedSizeHost = scope.GetValue(s_sharedSizeScopeHostProperty); + _sharedSizeHost.RegisterGrid(this); + } + } + + /// + /// Callback when a grid is detached from the visual tree. Unregisters the grid from its SharedSizeScope if any. + /// + /// The source of the event. + /// The event arguments. + private void Grid_DetachedFromVisualTree(object sender, VisualTreeAttachmentEventArgs e) + { + _sharedSizeHost?.UnegisterGrid(this); + _sharedSizeHost = null; + } + + /// /// Get the safe column/columnspan and safe row/rowspan. /// This method ensures that none of the children has a column/row outside the bounds of the definitions. @@ -426,5 +540,41 @@ namespace Avalonia.Controls return value; } + + /// + /// Called when the value of changes for a control. + /// + /// The control that triggered the change. + /// Change arguments. + private static void IsSharedSizeScopeChanged(Control source, AvaloniaPropertyChangedEventArgs arg2) + { + var shouldDispose = (arg2.OldValue is bool d) && d; + if (shouldDispose) + { + var host = source.GetValue(s_sharedSizeScopeHostProperty) as SharedSizeScopeHost; + if (host == null) + throw new AvaloniaInternalException("SharedScopeHost wasn't set when IsSharedSizeScope was true!"); + host.Dispose(); + source.ClearValue(s_sharedSizeScopeHostProperty); + } + + var shouldAssign = (arg2.NewValue is bool a) && a; + if (shouldAssign) + { + if (source.GetValue(s_sharedSizeScopeHostProperty) != null) + throw new AvaloniaInternalException("SharedScopeHost was already set when IsSharedSizeScope is only now being set to true!"); + source.SetValue(s_sharedSizeScopeHostProperty, new SharedSizeScopeHost()); + } + + // if the scope has changed, notify the descendant grids that they need to update. + if (source.GetVisualRoot() != null && shouldAssign || shouldDispose) + { + var participatingGrids = new[] { source }.Concat(source.GetVisualDescendants()).OfType(); + + foreach (var grid in participatingGrids) + grid.SharedScopeChanged(); + + } + } } } diff --git a/src/Avalonia.Controls/GridSplitter.cs b/src/Avalonia.Controls/GridSplitter.cs index 1e4c6f2c2a..304a760216 100644 --- a/src/Avalonia.Controls/GridSplitter.cs +++ b/src/Avalonia.Controls/GridSplitter.cs @@ -44,26 +44,52 @@ namespace Avalonia.Controls protected override void OnDragDelta(VectorEventArgs e) { + // WPF doesn't change anything when spliter is in the last row/column + // but resizes the splitter row/column when it's the first one. + // this is different, but more internally consistent. + if (_prevDefinition == null || _nextDefinition == null) + return; + var delta = _orientation == Orientation.Vertical ? e.Vector.X : e.Vector.Y; double max; double min; GetDeltaConstraints(out min, out max); delta = Math.Min(Math.Max(delta, min), max); - foreach (var definition in _definitions) + + var prevIsStar = IsStar(_prevDefinition); + var nextIsStar = IsStar(_nextDefinition); + + if (prevIsStar && nextIsStar) { - if (definition == _prevDefinition) - { - SetLengthInStars(_prevDefinition, GetActualLength(_prevDefinition) + delta); - } - else if (definition == _nextDefinition) - { - SetLengthInStars(_nextDefinition, GetActualLength(_nextDefinition) - delta); - } - else if (IsStar(definition)) + foreach (var definition in _definitions) { - SetLengthInStars(definition, GetActualLength(definition)); // same size but in stars. + if (definition == _prevDefinition) + { + SetLengthInStars(_prevDefinition, GetActualLength(_prevDefinition) + delta); + } + else if (definition == _nextDefinition) + { + SetLengthInStars(_nextDefinition, GetActualLength(_nextDefinition) - delta); + } + else if (IsStar(definition)) + { + SetLengthInStars(definition, GetActualLength(definition)); // same size but in stars. + } } } + else if (prevIsStar) + { + SetLength(_nextDefinition, GetActualLength(_nextDefinition) - delta); + } + else if (nextIsStar) + { + SetLength(_prevDefinition, GetActualLength(_prevDefinition) + delta); + } + else + { + SetLength(_prevDefinition, GetActualLength(_prevDefinition) + delta); + SetLength(_nextDefinition, GetActualLength(_nextDefinition) - delta); + } } private double GetActualLength(DefinitionBase definition) @@ -71,7 +97,7 @@ namespace Avalonia.Controls if (definition == null) return 0; var columnDefinition = definition as ColumnDefinition; - return columnDefinition?.ActualWidth ?? ((RowDefinition) definition).ActualHeight; + return columnDefinition?.ActualWidth ?? ((RowDefinition)definition).ActualHeight; } private double GetMinLength(DefinitionBase definition) @@ -79,7 +105,7 @@ namespace Avalonia.Controls if (definition == null) return 0; var columnDefinition = definition as ColumnDefinition; - return columnDefinition?.MinWidth ?? ((RowDefinition) definition).MinHeight; + return columnDefinition?.MinWidth ?? ((RowDefinition)definition).MinHeight; } private double GetMaxLength(DefinitionBase definition) @@ -87,13 +113,13 @@ namespace Avalonia.Controls if (definition == null) return 0; var columnDefinition = definition as ColumnDefinition; - return columnDefinition?.MaxWidth ?? ((RowDefinition) definition).MaxHeight; + return columnDefinition?.MaxWidth ?? ((RowDefinition)definition).MaxHeight; } private bool IsStar(DefinitionBase definition) { var columnDefinition = definition as ColumnDefinition; - return columnDefinition?.Width.IsStar ?? ((RowDefinition) definition).Height.IsStar; + return columnDefinition?.Width.IsStar ?? ((RowDefinition)definition).Height.IsStar; } private void SetLengthInStars(DefinitionBase definition, double value) @@ -105,7 +131,20 @@ namespace Avalonia.Controls } else { - ((RowDefinition) definition).Height = new GridLength(value, GridUnitType.Star); + ((RowDefinition)definition).Height = new GridLength(value, GridUnitType.Star); + } + } + + private void SetLength(DefinitionBase definition, double value) + { + var columnDefinition = definition as ColumnDefinition; + if (columnDefinition != null) + { + columnDefinition.Width = new GridLength(value); + } + else + { + ((RowDefinition)definition).Height = new GridLength(value); } } @@ -160,7 +199,7 @@ namespace Avalonia.Controls } if (_grid.Children.OfType() // Decision based on other controls in the same column .Where(c => Grid.GetColumn(c) == col) - .Any(c => c.GetType() != typeof (GridSplitter))) + .Any(c => c.GetType() != typeof(GridSplitter))) { return Orientation.Horizontal; } diff --git a/src/Avalonia.Controls/Utils/GridLayout.cs b/src/Avalonia.Controls/Utils/GridLayout.cs index 363428b289..7704228a4e 100644 --- a/src/Avalonia.Controls/Utils/GridLayout.cs +++ b/src/Avalonia.Controls/Utils/GridLayout.cs @@ -143,14 +143,17 @@ namespace Avalonia.Controls.Utils /// /// The container length. Usually, it is the constraint of the method. /// + /// + /// Overriding conventions that allows the algorithm to handle external inputa + /// /// /// The measured result that containing the desired size and all the column/row lengths. /// [NotNull, Pure] - internal MeasureResult Measure(double containerLength) + internal MeasureResult Measure(double containerLength, IReadOnlyList conventions = null) { // Prepare all the variables that this method needs to use. - var conventions = _conventions.Select(x => x.Clone()).ToList(); + conventions = conventions ?? _conventions.Select(x => x.Clone()).ToList(); var starCount = conventions.Where(x => x.Length.IsStar).Sum(x => x.Length.Value); var aggregatedLength = 0.0; double starUnitLength; @@ -248,7 +251,7 @@ namespace Avalonia.Controls.Utils // | min | max | | | min | | min max | max | // |#des#| fix |#des#| fix | fix | fix | fix | #des# |#des#| - var desiredStarMin = AggregateAdditionalConventionsForStars(conventions); + var (minLengths, desiredStarMin) = AggregateAdditionalConventionsForStars(conventions); aggregatedLength += desiredStarMin; // M6/7. Determine the desired length of the grid for current container length. Its value is stored in desiredLength. @@ -282,7 +285,7 @@ namespace Avalonia.Controls.Utils // Returns the measuring result. return new MeasureResult(containerLength, desiredLength, greedyDesiredLength, - conventions, dynamicConvention); + conventions, dynamicConvention, minLengths); } /// @@ -306,14 +309,14 @@ namespace Avalonia.Controls.Utils if (finalLength - measure.ContainerLength > LayoutTolerance) { // If the final length is larger, we will rerun the whole measure. - measure = Measure(finalLength); + measure = Measure(finalLength, measure.LeanLengthList); } else if (finalLength - measure.ContainerLength < -LayoutTolerance) { // If the final length is smaller, we measure the M6/6 procedure only. var dynamicConvention = ExpandStars(measure.LeanLengthList, finalLength); measure = new MeasureResult(finalLength, measure.DesiredLength, measure.GreedyDesiredLength, - measure.LeanLengthList, dynamicConvention); + measure.LeanLengthList, dynamicConvention, measure.MinLengths); } return new ArrangeResult(measure.LengthList); @@ -370,7 +373,7 @@ namespace Avalonia.Controls.Utils /// All the conventions that have almost been fixed except the rest *. /// The total desired length of all the * length. [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)] - private double AggregateAdditionalConventionsForStars( + private (List, double) AggregateAdditionalConventionsForStars( IReadOnlyList conventions) { // 1. Determine all one-span column's desired widths or row's desired heights. @@ -403,7 +406,7 @@ namespace Avalonia.Controls.Utils lengthList[group.Key] = Math.Max(lengthList[group.Key], length > 0 ? length : 0); } - return lengthList.Sum() - fixedLength; + return (lengthList, lengthList.Sum() - fixedLength); } /// @@ -638,13 +641,14 @@ namespace Avalonia.Controls.Utils /// Initialize a new instance of . /// internal MeasureResult(double containerLength, double desiredLength, double greedyDesiredLength, - IReadOnlyList leanConventions, IReadOnlyList expandedConventions) + IReadOnlyList leanConventions, IReadOnlyList expandedConventions, IReadOnlyList minLengths) { ContainerLength = containerLength; DesiredLength = desiredLength; GreedyDesiredLength = greedyDesiredLength; LeanLengthList = leanConventions; LengthList = expandedConventions; + MinLengths = minLengths; } /// @@ -674,6 +678,7 @@ namespace Avalonia.Controls.Utils /// Gets the length list for each column/row. /// public IReadOnlyList LengthList { get; } + public IReadOnlyList MinLengths { get; } } /// diff --git a/src/Avalonia.Controls/Utils/SharedSizeScopeHost.cs b/src/Avalonia.Controls/Utils/SharedSizeScopeHost.cs new file mode 100644 index 0000000000..8553165e4b --- /dev/null +++ b/src/Avalonia.Controls/Utils/SharedSizeScopeHost.cs @@ -0,0 +1,651 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Subjects; +using Avalonia.Collections; +using Avalonia.Controls.Utils; +using Avalonia.Layout; +using Avalonia.VisualTree; + +namespace Avalonia.Controls +{ + /// + /// Shared size scope implementation. + /// Shares the size information between participating grids. + /// An instance of this class is attached to every that has its + /// IsSharedSizeScope property set to true. + /// + internal sealed class SharedSizeScopeHost : IDisposable + { + private enum MeasurementState + { + Invalidated, + Measuring, + Cached + } + + /// + /// Class containing the measured rows/columns for a single grid. + /// Monitors changes to the row/column collections as well as the SharedSizeGroup changes + /// for the individual items in those collections. + /// Notifies the of SharedSizeGroup changes. + /// + private sealed class MeasurementCache : IDisposable + { + readonly CompositeDisposable _subscriptions; + readonly Subject<(string, string, MeasurementResult)> _groupChanged = new Subject<(string, string, MeasurementResult)>(); + + public ISubject<(string oldName, string newName, MeasurementResult result)> GroupChanged => _groupChanged; + + public MeasurementCache(Grid grid) + { + Grid = grid; + Results = grid.RowDefinitions.Cast() + .Concat(grid.ColumnDefinitions) + .Select(d => new MeasurementResult(grid, d)) + .ToList(); + + grid.RowDefinitions.CollectionChanged += DefinitionsCollectionChanged; + grid.ColumnDefinitions.CollectionChanged += DefinitionsCollectionChanged; + + + _subscriptions = new CompositeDisposable( + Disposable.Create(() => grid.RowDefinitions.CollectionChanged -= DefinitionsCollectionChanged), + Disposable.Create(() => grid.ColumnDefinitions.CollectionChanged -= DefinitionsCollectionChanged), + grid.RowDefinitions.TrackItemPropertyChanged(DefinitionPropertyChanged), + grid.ColumnDefinitions.TrackItemPropertyChanged(DefinitionPropertyChanged)); + + } + + // method to be hooked up once RowDefinitions/ColumnDefinitions collections can be replaced on a grid + private void DefinitionsChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + // route to collection changed as a Reset. + DefinitionsCollectionChanged(null, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + private void DefinitionPropertyChanged(Tuple propertyChanged) + { + if (propertyChanged.Item2.PropertyName == nameof(DefinitionBase.SharedSizeGroup)) + { + var result = Results.Single(mr => ReferenceEquals(mr.Definition, propertyChanged.Item1)); + var oldName = result.SizeGroup?.Name; + var newName = (propertyChanged.Item1 as DefinitionBase).SharedSizeGroup; + _groupChanged.OnNext((oldName, newName, result)); + } + } + + private void DefinitionsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + int offset = 0; + if (sender is ColumnDefinitions) + offset = Grid.RowDefinitions.Count; + + var newItems = e.NewItems?.OfType().Select(db => new MeasurementResult(Grid, db)).ToList() ?? new List(); + var oldItems = e.OldStartingIndex >= 0 + ? Results.GetRange(e.OldStartingIndex + offset, e.OldItems.Count) + : new List(); + + void NotifyNewItems() + { + foreach (var item in newItems) + { + if (string.IsNullOrEmpty(item.Definition.SharedSizeGroup)) + continue; + + _groupChanged.OnNext((null, item.Definition.SharedSizeGroup, item)); + } + } + + void NotifyOldItems() + { + foreach (var item in oldItems) + { + if (string.IsNullOrEmpty(item.Definition.SharedSizeGroup)) + continue; + + _groupChanged.OnNext((item.Definition.SharedSizeGroup, null, item)); + } + } + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Results.InsertRange(e.NewStartingIndex + offset, newItems); + NotifyNewItems(); + break; + + case NotifyCollectionChangedAction.Remove: + Results.RemoveRange(e.OldStartingIndex + offset, oldItems.Count); + NotifyOldItems(); + break; + + case NotifyCollectionChangedAction.Move: + Results.RemoveRange(e.OldStartingIndex + offset, oldItems.Count); + Results.InsertRange(e.NewStartingIndex + offset, oldItems); + break; + + case NotifyCollectionChangedAction.Replace: + Results.RemoveRange(e.OldStartingIndex + offset, oldItems.Count); + Results.InsertRange(e.NewStartingIndex + offset, newItems); + + NotifyOldItems(); + NotifyNewItems(); + + break; + + case NotifyCollectionChangedAction.Reset: + oldItems = Results; + newItems = Results = Grid.RowDefinitions.Cast() + .Concat(Grid.ColumnDefinitions) + .Select(d => new MeasurementResult(Grid, d)) + .ToList(); + NotifyOldItems(); + NotifyNewItems(); + + break; + } + } + + + /// + /// Updates the Results collection with Grid Measure results. + /// + /// Result of the GridLayout.Measure method for the RowDefinitions in the grid. + /// Result of the GridLayout.Measure method for the ColumnDefinitions in the grid. + public void UpdateMeasureResult(GridLayout.MeasureResult rowResult, GridLayout.MeasureResult columnResult) + { + MeasurementState = MeasurementState.Cached; + for (int i = 0; i < Grid.RowDefinitions.Count; i++) + { + Results[i].MeasuredResult = rowResult.LengthList[i]; + Results[i].MinLength = rowResult.MinLengths[i]; + } + + for (int i = 0; i < Grid.ColumnDefinitions.Count; i++) + { + Results[i + Grid.RowDefinitions.Count].MeasuredResult = columnResult.LengthList[i]; + Results[i + Grid.RowDefinitions.Count].MinLength = columnResult.MinLengths[i]; + } + } + + /// + /// Clears the measurement cache, in preparation for the Measure pass. + /// + public void InvalidateMeasure() + { + var newItems = new List(); + var oldItems = new List(); + + MeasurementState = MeasurementState.Invalidated; + + Results.ForEach(r => + { + r.MeasuredResult = double.NaN; + r.SizeGroup?.Reset(); + }); + } + + /// + /// Clears the subscriptions. + /// + public void Dispose() + { + _subscriptions.Dispose(); + _groupChanged.OnCompleted(); + } + + /// + /// Gets the for which this cache has been created. + /// + public Grid Grid { get; } + + /// + /// Gets the of this cache. + /// + public MeasurementState MeasurementState { get; private set; } + + /// + /// Gets the list of instances. + /// + /// + /// The list is a 1-1 map of the concatenation of RowDefinitions and ColumnDefinitions + /// + public List Results { get; private set; } + } + + + /// + /// Class containing the Measure result for a single Row/Column in a grid. + /// + private class MeasurementResult + { + public MeasurementResult(Grid owningGrid, DefinitionBase definition) + { + OwningGrid = owningGrid; + Definition = definition; + MeasuredResult = double.NaN; + } + + /// + /// Gets the / related to this + /// + public DefinitionBase Definition { get; } + + /// + /// Gets or sets the actual result of the Measure operation for this column. + /// + public double MeasuredResult { get; set; } + + /// + /// Gets or sets the Minimum constraint for a Row/Column - relevant for star Rows/Columns in unconstrained grids. + /// + public double MinLength { get; set; } + + /// + /// Gets or sets the that this result belongs to. + /// + public Group SizeGroup { get; set; } + + /// + /// Gets the Grid that is the parent of the Row/Column + /// + public Grid OwningGrid { get; } + + /// + /// Calculates the effective length that this Row/Column wishes to enforce in the SharedSizeGroup. + /// + /// A tuple of length and the priority in the shared size group. + public (double length, int priority) GetPriorityLength() + { + var length = (Definition as ColumnDefinition)?.Width ?? ((RowDefinition)Definition).Height; + + if (length.IsAbsolute) + return (MeasuredResult, 1); + if (length.IsAuto) + return (MeasuredResult, 2); + if (MinLength > 0) + return (MinLength, 3); + return (MeasuredResult, 4); + } + } + + /// + /// Visitor class used to gather the final length for a given SharedSizeGroup. + /// + /// + /// The values are applied according to priorities defined in . + /// + private class LentgthGatherer + { + /// + /// Gets the final Length to be applied to every Row/Column in a SharedSizeGroup + /// + public double Length { get; private set; } + private int gatheredPriority = 6; + + /// + /// Visits the applying the result of to its internal cache. + /// + /// The instance to visit. + public void Visit(MeasurementResult result) + { + var (length, priority) = result.GetPriorityLength(); + + if (gatheredPriority < priority) + return; + + gatheredPriority = priority; + if (gatheredPriority == priority) + { + Length = Math.Max(length,Length); + } + else + { + Length = length; + } + } + } + + /// + /// Representation of a SharedSizeGroup, containing Rows/Columns with the same SharedSizeGroup property value. + /// + private class Group + { + private double? cachedResult; + private List _results = new List(); + + /// + /// Gets the name of the SharedSizeGroup. + /// + public string Name { get; } + + public Group(string name) + { + Name = name; + } + + /// + /// Gets the collection of the instances. + /// + public IReadOnlyList Results => _results; + + /// + /// Gets the final, calculated length for all Rows/Columns in the SharedSizeGroup. + /// + public double CalculatedLength => (cachedResult ?? (cachedResult = Gather())).Value; + + /// + /// Clears the previously cached result in preparation for measurement. + /// + public void Reset() + { + cachedResult = null; + } + + /// + /// Ads a measurement result to this group and sets it's property + /// to this instance. + /// + /// The to include in this group. + public void Add(MeasurementResult result) + { + if (_results.Contains(result)) + throw new AvaloniaInternalException( + $"SharedSizeScopeHost: Invalid call to Group.Add - The SharedSizeGroup {Name} already contains the passed result"); + + result.SizeGroup = this; + _results.Add(result); + } + + /// + /// Removes the measurement result from this group and clears its value. + /// + /// The to clear. + public void Remove(MeasurementResult result) + { + if (!_results.Contains(result)) + throw new AvaloniaInternalException( + $"SharedSizeScopeHost: Invalid call to Group.Remove - The SharedSizeGroup {Name} does not contain the passed result"); + result.SizeGroup = null; + _results.Remove(result); + } + + + private double Gather() + { + var visitor = new LentgthGatherer(); + + _results.ForEach(visitor.Visit); + + return visitor.Length; + } + } + + private readonly AvaloniaList _measurementCaches = new AvaloniaList(); + private readonly Dictionary _groups = new Dictionary(); + private bool _invalidating; + + /// + /// Removes the SharedSizeScope and notifies all affected grids of the change. + /// + public void Dispose() + { + while (_measurementCaches.Any()) + _measurementCaches[0].Grid.SharedScopeChanged(); + } + + /// + /// Registers the grid in this SharedSizeScope, to be called when the grid is added to the visual tree. + /// + /// The to add to this scope. + internal void RegisterGrid(Grid toAdd) + { + if (_measurementCaches.Any(mc => ReferenceEquals(mc.Grid, toAdd))) + throw new AvaloniaInternalException("SharedSizeScopeHost: tried to register a grid twice!"); + + var cache = new MeasurementCache(toAdd); + _measurementCaches.Add(cache); + AddGridToScopes(cache); + } + + /// + /// Removes the registration for a grid in this SharedSizeScope. + /// + /// The to remove. + internal void UnegisterGrid(Grid toRemove) + { + var cache = _measurementCaches.FirstOrDefault(mc => ReferenceEquals(mc.Grid, toRemove)); + if (cache == null) + throw new AvaloniaInternalException("SharedSizeScopeHost: tried to unregister a grid that wasn't registered before!"); + + _measurementCaches.Remove(cache); + RemoveGridFromScopes(cache); + cache.Dispose(); + } + + /// + /// Helper method to check if a grid needs to forward its Mesure results to, and requrest Arrange results from this scope. + /// + /// The that should be checked. + /// True if the grid should forward its calls. + internal bool ParticipatesInScope(Grid toCheck) + { + return _measurementCaches.FirstOrDefault(mc => ReferenceEquals(mc.Grid, toCheck)) + ?.Results.Any(r => r.SizeGroup != null) ?? false; + } + + /// + /// Notifies the SharedSizeScope that a grid had requested its measurement to be invalidated. + /// Forwards the same call to all affected grids in this scope. + /// + /// The that had it's Measure invalidated. + internal void InvalidateMeasure(Grid grid) + { + // prevent stack overflow + if (_invalidating) + return; + _invalidating = true; + + InvalidateMeasureImpl(grid); + + _invalidating = false; + } + + /// + /// Updates the measurement cache with the results of the measurement pass. + /// + /// The that has been measured. + /// Measurement result for the grid's + /// Measurement result for the grid's + internal void UpdateMeasureStatus(Grid grid, GridLayout.MeasureResult rowResult, GridLayout.MeasureResult columnResult) + { + var cache = _measurementCaches.FirstOrDefault(mc => ReferenceEquals(mc.Grid, grid)); + if (cache == null) + throw new AvaloniaInternalException("SharedSizeScopeHost: Attempted to update measurement status for a grid that wasn't registered!"); + + cache.UpdateMeasureResult(rowResult, columnResult); + } + + /// + /// Calculates the measurement result including the impact of any SharedSizeGroups that might affect this grid. + /// + /// The that is being Arranged + /// The 's cached measurement result. + /// The 's cached measurement result. + /// Row and column measurement result updated with the SharedSizeScope constraints. + internal (GridLayout.MeasureResult, GridLayout.MeasureResult) HandleArrange(Grid grid, GridLayout.MeasureResult rowResult, GridLayout.MeasureResult columnResult) + { + return ( + Arrange(grid.RowDefinitions, rowResult), + Arrange(grid.ColumnDefinitions, columnResult) + ); + } + + /// + /// Invalidates the measure of all grids affected by the SharedSizeGroups contained within. + /// + /// The that is being invalidated. + private void InvalidateMeasureImpl(Grid grid) + { + var cache = _measurementCaches.FirstOrDefault(mc => ReferenceEquals(mc.Grid, grid)); + + if (cache == null) + throw new AvaloniaInternalException( + $"SharedSizeScopeHost: InvalidateMeasureImpl - called with a grid not present in the internal cache"); + + // already invalidated the cache, early out. + if (cache.MeasurementState == MeasurementState.Invalidated) + return; + + // we won't calculate, so we should not invalidate. + if (!ParticipatesInScope(grid)) + return; + + cache.InvalidateMeasure(); + + // maybe there is a condition to only call arrange on some of the calls? + grid.InvalidateMeasure(); + + // find all the scopes within the invalidated grid + var scopeNames = cache.Results + .Where(mr => mr.SizeGroup != null) + .Select(mr => mr.SizeGroup.Name) + .Distinct(); + // find all grids related to those scopes + var otherGrids = scopeNames.SelectMany(sn => _groups[sn].Results) + .Select(r => r.OwningGrid) + .Where(g => g.IsMeasureValid) + .Distinct(); + + // invalidate them as well + foreach (var otherGrid in otherGrids) + { + InvalidateMeasureImpl(otherGrid); + } + } + + /// + /// callback notifying the scope that a has changed its + /// SharedSizeGroup + /// + /// Old and New name (either can be null) of the SharedSizeGroup, as well as the result. + private void SharedGroupChanged((string oldName, string newName, MeasurementResult result) change) + { + RemoveFromGroup(change.oldName, change.result); + AddToGroup(change.newName, change.result); + } + + /// + /// Handles the impact of SharedSizeGroups on the Arrange of / + /// + /// Rows/Columns that were measured + /// The initial measurement result. + /// Modified measure result + private GridLayout.MeasureResult Arrange(IReadOnlyList definitions, GridLayout.MeasureResult measureResult) + { + var conventions = measureResult.LeanLengthList.ToList(); + var lengths = measureResult.LengthList.ToList(); + var desiredLength = 0.0; + for (int i = 0; i < definitions.Count; i++) + { + var definition = definitions[i]; + + // for empty SharedSizeGroups pass on unmodified result. + if (string.IsNullOrEmpty(definition.SharedSizeGroup)) + { + desiredLength += measureResult.LengthList[i]; + continue; + } + + var group = _groups[definition.SharedSizeGroup]; + // Length calculated over all Definitions participating in a SharedSizeGroup. + var length = group.CalculatedLength; + + conventions[i] = new GridLayout.LengthConvention( + new GridLength(length), + measureResult.LeanLengthList[i].MinLength, + measureResult.LeanLengthList[i].MaxLength + ); + lengths[i] = length; + desiredLength += length; + } + + return new GridLayout.MeasureResult( + measureResult.ContainerLength, + desiredLength, + measureResult.GreedyDesiredLength,//?? + conventions, + lengths, + measureResult.MinLengths); + } + + /// + /// Adds all measurement results for a grid to their repsective scopes. + /// + /// The for a grid to be added. + private void AddGridToScopes(MeasurementCache cache) + { + cache.GroupChanged.Subscribe(SharedGroupChanged); + + foreach (var result in cache.Results) + { + var scopeName = result.Definition.SharedSizeGroup; + AddToGroup(scopeName, result); + } + } + + /// + /// Handles adding the to a SharedSizeGroup. + /// Does nothing for empty SharedSizeGroups. + /// + /// The name (can be null or empty) of the group to add the to. + /// The to add to a scope. + private void AddToGroup(string scopeName, MeasurementResult result) + { + if (string.IsNullOrEmpty(scopeName)) + return; + + if (!_groups.TryGetValue(scopeName, out var group)) + _groups.Add(scopeName, group = new Group(scopeName)); + + group.Add(result); + } + + /// + /// Removes all measurement results for a grid from their respective scopes. + /// + /// The for a grid to be removed. + private void RemoveGridFromScopes(MeasurementCache cache) + { + foreach (var result in cache.Results) + { + var scopeName = result.Definition.SharedSizeGroup; + RemoveFromGroup(scopeName, result); + } + } + + /// + /// Handles removing the from a SharedSizeGroup. + /// Does nothing for empty SharedSizeGroups. + /// + /// The name (can be null or empty) of the group to remove the from. + /// The to remove from a scope. + private void RemoveFromGroup(string scopeName, MeasurementResult result) + { + if (string.IsNullOrEmpty(scopeName)) + return; + + if (!_groups.TryGetValue(scopeName, out var group)) + throw new AvaloniaInternalException($"SharedSizeScopeHost: The scope {scopeName} wasn't found in the shared size scope"); + + group.Remove(result); + if (!group.Results.Any()) + _groups.Remove(scopeName); + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/SharedSizeScopeTests.cs b/tests/Avalonia.Controls.UnitTests/SharedSizeScopeTests.cs new file mode 100644 index 0000000000..715e9da880 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/SharedSizeScopeTests.cs @@ -0,0 +1,284 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Platform; +using Avalonia.UnitTests; + +using Moq; + +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class SharedSizeScopeTests + { + public SharedSizeScopeTests() + { + } + + [Fact] + public void All_Descendant_Grids_Are_Registered_When_Added_After_Setting_Scope() + { + var grids = new[] { new Grid(), new Grid(), new Grid() }; + var scope = new Panel(); + scope.Children.AddRange(grids); + + var root = new TestRoot(); + root.SetValue(Grid.IsSharedSizeScopeProperty, true); + root.Child = scope; + + Assert.All(grids, g => Assert.True(g.HasSharedSizeScope())); + } + + [Fact] + public void All_Descendant_Grids_Are_Registered_When_Setting_Scope() + { + var grids = new[] { new Grid(), new Grid(), new Grid() }; + var scope = new Panel(); + scope.Children.AddRange(grids); + + var root = new TestRoot(); + root.Child = scope; + root.SetValue(Grid.IsSharedSizeScopeProperty, true); + + Assert.All(grids, g => Assert.True(g.HasSharedSizeScope())); + } + + [Fact] + public void All_Descendant_Grids_Are_Unregistered_When_Resetting_Scope() + { + var grids = new[] { new Grid(), new Grid(), new Grid() }; + var scope = new Panel(); + scope.Children.AddRange(grids); + + var root = new TestRoot(); + root.SetValue(Grid.IsSharedSizeScopeProperty, true); + root.Child = scope; + + Assert.All(grids, g => Assert.True(g.HasSharedSizeScope())); + root.SetValue(Grid.IsSharedSizeScopeProperty, false); + Assert.All(grids, g => Assert.False(g.HasSharedSizeScope())); + Assert.Equal(null, root.GetValue(Grid.s_sharedSizeScopeHostProperty)); + } + + [Fact] + public void Size_Is_Propagated_Between_Grids() + { + var grids = new[] { CreateGrid("A", null),CreateGrid(("A",new GridLength(30)), (null, new GridLength()))}; + var scope = new Panel(); + scope.Children.AddRange(grids); + + var root = new TestRoot(); + root.SetValue(Grid.IsSharedSizeScopeProperty, true); + root.Child = scope; + + root.Measure(new Size(50, 50)); + root.Arrange(new Rect(new Point(), new Point(50, 50))); + Assert.Equal(30, grids[0].ColumnDefinitions[0].ActualWidth); + } + + [Fact] + public void Size_Propagation_Is_Constrained_To_Innermost_Scope() + { + var grids = new[] { CreateGrid("A", null), CreateGrid(("A", new GridLength(30)), (null, new GridLength())) }; + var innerScope = new Panel(); + innerScope.Children.AddRange(grids); + innerScope.SetValue(Grid.IsSharedSizeScopeProperty, true); + + var outerGrid = CreateGrid(("A", new GridLength(0))); + var outerScope = new Panel(); + outerScope.Children.AddRange(new[] { outerGrid, innerScope }); + + var root = new TestRoot(); + root.SetValue(Grid.IsSharedSizeScopeProperty, true); + root.Child = outerScope; + + root.Measure(new Size(50, 50)); + root.Arrange(new Rect(new Point(), new Point(50, 50))); + Assert.Equal(0, outerGrid.ColumnDefinitions[0].ActualWidth); + } + + [Fact] + public void Size_Is_Propagated_Between_Rows_And_Columns() + { + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,30"), + RowDefinitions = new RowDefinitions("*,10") + }; + + grid.ColumnDefinitions[1].SharedSizeGroup = "A"; + grid.RowDefinitions[1].SharedSizeGroup = "A"; + + var root = new TestRoot(); + root.SetValue(Grid.IsSharedSizeScopeProperty, true); + root.Child = grid; + + root.Measure(new Size(50, 50)); + root.Arrange(new Rect(new Point(), new Point(50, 50))); + Assert.Equal(30, grid.RowDefinitions[1].ActualHeight); + } + + [Fact] + public void Size_Group_Changes_Are_Tracked() + { + var grids = new[] { + CreateGrid((null, new GridLength(0, GridUnitType.Auto)), (null, new GridLength())), + CreateGrid(("A", new GridLength(30)), (null, new GridLength())) }; + var scope = new Panel(); + scope.Children.AddRange(grids); + + var root = new TestRoot(); + root.SetValue(Grid.IsSharedSizeScopeProperty, true); + root.Child = scope; + + root.Measure(new Size(50, 50)); + root.Arrange(new Rect(new Point(), new Point(50, 50))); + Assert.Equal(0, grids[0].ColumnDefinitions[0].ActualWidth); + + grids[0].ColumnDefinitions[0].SharedSizeGroup = "A"; + + root.Measure(new Size(51, 51)); + root.Arrange(new Rect(new Point(), new Point(51, 51))); + Assert.Equal(30, grids[0].ColumnDefinitions[0].ActualWidth); + + grids[0].ColumnDefinitions[0].SharedSizeGroup = null; + + root.Measure(new Size(52, 52)); + root.Arrange(new Rect(new Point(), new Point(52, 52))); + Assert.Equal(0, grids[0].ColumnDefinitions[0].ActualWidth); + } + + [Fact] + public void Collection_Changes_Are_Tracked() + { + var grid = CreateGrid( + ("A", new GridLength(20)), + ("A", new GridLength(30)), + ("A", new GridLength(40)), + (null, new GridLength())); + + var scope = new Panel(); + scope.Children.Add(grid); + + var root = new TestRoot(); + root.SetValue(Grid.IsSharedSizeScopeProperty, true); + root.Child = scope; + + grid.Measure(new Size(200, 200)); + grid.Arrange(new Rect(new Point(), new Point(200, 200))); + Assert.All(grid.ColumnDefinitions.Where(cd => cd.SharedSizeGroup == "A"), cd => Assert.Equal(40, cd.ActualWidth)); + + grid.ColumnDefinitions.RemoveAt(2); + + grid.Measure(new Size(200, 200)); + grid.Arrange(new Rect(new Point(), new Point(200, 200))); + Assert.All(grid.ColumnDefinitions.Where(cd => cd.SharedSizeGroup == "A"), cd => Assert.Equal(30, cd.ActualWidth)); + + grid.ColumnDefinitions.Insert(1, new ColumnDefinition { Width = new GridLength(35), SharedSizeGroup = "A" }); + + grid.Measure(new Size(200, 200)); + grid.Arrange(new Rect(new Point(), new Point(200, 200))); + Assert.All(grid.ColumnDefinitions.Where(cd => cd.SharedSizeGroup == "A"), cd => Assert.Equal(35, cd.ActualWidth)); + + grid.ColumnDefinitions[1] = new ColumnDefinition { Width = new GridLength(10), SharedSizeGroup = "A" }; + + grid.Measure(new Size(200, 200)); + grid.Arrange(new Rect(new Point(), new Point(200, 200))); + Assert.All(grid.ColumnDefinitions.Where(cd => cd.SharedSizeGroup == "A"), cd => Assert.Equal(30, cd.ActualWidth)); + + grid.ColumnDefinitions[1] = new ColumnDefinition { Width = new GridLength(50), SharedSizeGroup = "A" }; + + grid.Measure(new Size(200, 200)); + grid.Arrange(new Rect(new Point(), new Point(200, 200))); + Assert.All(grid.ColumnDefinitions.Where(cd => cd.SharedSizeGroup == "A"), cd => Assert.Equal(50, cd.ActualWidth)); + } + + [Fact] + public void Size_Priorities_Are_Maintained() + { + var sizers = new List(); + var grid = CreateGrid( + ("A", new GridLength(20)), + ("A", new GridLength(20, GridUnitType.Auto)), + ("A", new GridLength(1, GridUnitType.Star)), + ("A", new GridLength(1, GridUnitType.Star)), + (null, new GridLength())); + for (int i = 0; i < 3; i++) + sizers.Add(AddSizer(grid, i, 6 + i * 6)); + var scope = new Panel(); + scope.Children.Add(grid); + + var root = new TestRoot(); + root.SetValue(Grid.IsSharedSizeScopeProperty, true); + root.Child = scope; + + grid.Measure(new Size(100, 100)); + grid.Arrange(new Rect(new Point(), new Point(100, 100))); + // all in group are equal to the first fixed column + Assert.All(grid.ColumnDefinitions.Where(cd => cd.SharedSizeGroup == "A"), cd => Assert.Equal(20, cd.ActualWidth)); + + grid.ColumnDefinitions[0].SharedSizeGroup = null; + + grid.Measure(new Size(100, 100)); + grid.Arrange(new Rect(new Point(), new Point(100, 100))); + // all in group are equal to width (MinWidth) of the sizer in the second column + Assert.All(grid.ColumnDefinitions.Where(cd => cd.SharedSizeGroup == "A"), cd => Assert.Equal(6 + 1 * 6, cd.ActualWidth)); + + grid.ColumnDefinitions[1].SharedSizeGroup = null; + + grid.Measure(new Size(double.PositiveInfinity, 100)); + grid.Arrange(new Rect(new Point(), new Point(100, 100))); + // with no constraint star columns default to the MinWidth of the sizer in the column + Assert.All(grid.ColumnDefinitions.Where(cd => cd.SharedSizeGroup == "A"), cd => Assert.Equal(6 + 2 * 6, cd.ActualWidth)); + } + + // grid creators + private Grid CreateGrid(params string[] columnGroups) + { + return CreateGrid(columnGroups.Select(s => (s, ColumnDefinition.WidthProperty.GetDefaultValue(typeof(ColumnDefinition)))).ToArray()); + } + + private Grid CreateGrid(params (string name, GridLength width)[] columns) + { + return CreateGrid(columns.Select(c => + (c.name, c.width, ColumnDefinition.MinWidthProperty.GetDefaultValue(typeof(ColumnDefinition)))).ToArray()); + } + + private Grid CreateGrid(params (string name, GridLength width, double minWidth)[] columns) + { + return CreateGrid(columns.Select(c => + (c.name, c.width, c.minWidth, ColumnDefinition.MaxWidthProperty.GetDefaultValue(typeof(ColumnDefinition)))).ToArray()); + } + + private Grid CreateGrid(params (string name, GridLength width, double minWidth, double maxWidth)[] columns) + { + var columnDefinitions = new ColumnDefinitions(); + + columnDefinitions.AddRange( + columns.Select(c => new ColumnDefinition + { + SharedSizeGroup = c.name, + Width = c.width, + MinWidth = c.minWidth, + MaxWidth = c.maxWidth + }) + ); + var grid = new Grid + { + ColumnDefinitions = columnDefinitions + }; + + return grid; + } + + private Control AddSizer(Grid grid, int column, double size = 30) + { + var ctrl = new Control { MinWidth = size, MinHeight = size }; + ctrl.SetValue(Grid.ColumnProperty,column); + grid.Children.Add(ctrl); + return ctrl; + } + } +}