// This source file is adapted from the Windows Presentation Foundation project. // (https://github.com/dotnet/wpf/) // // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. using System; using System.Diagnostics; using Avalonia.Collections; using Avalonia.Controls.Primitives; using Avalonia.Controls.Presenters; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Utilities; using Avalonia.VisualTree; namespace Avalonia.Controls { /// /// Represents the control that redistributes space between columns or rows of a control. /// public class GridSplitter : Thumb { /// /// Defines the property. /// public static readonly StyledProperty ResizeDirectionProperty = AvaloniaProperty.Register(nameof(ResizeDirection)); /// /// Defines the property. /// public static readonly StyledProperty ResizeBehaviorProperty = AvaloniaProperty.Register(nameof(ResizeBehavior)); /// /// Defines the property. /// public static readonly StyledProperty ShowsPreviewProperty = AvaloniaProperty.Register(nameof(ShowsPreview)); /// /// Defines the property. /// public static readonly StyledProperty KeyboardIncrementProperty = AvaloniaProperty.Register(nameof(KeyboardIncrement), 10d); /// /// Defines the property. /// public static readonly StyledProperty DragIncrementProperty = AvaloniaProperty.Register(nameof(DragIncrement), 1d); /// /// Defines the property. /// public static readonly StyledProperty> PreviewContentProperty = AvaloniaProperty.Register>(nameof(PreviewContent)); private static readonly Cursor s_columnSplitterCursor = new Cursor(StandardCursorType.SizeWestEast); private static readonly Cursor s_rowSplitterCursor = new Cursor(StandardCursorType.SizeNorthSouth); private ResizeData? _resizeData; private bool _isFocusEngaged; /// /// Indicates whether the Splitter resizes the Columns, Rows, or Both. /// public GridResizeDirection ResizeDirection { get => GetValue(ResizeDirectionProperty); set => SetValue(ResizeDirectionProperty, value); } /// /// Indicates which Columns or Rows the Splitter resizes. /// public GridResizeBehavior ResizeBehavior { get => GetValue(ResizeBehaviorProperty); set => SetValue(ResizeBehaviorProperty, value); } /// /// Indicates whether to Preview the column resizing without updating layout. /// public bool ShowsPreview { get => GetValue(ShowsPreviewProperty); set => SetValue(ShowsPreviewProperty, value); } /// /// The Distance to move the splitter when pressing the keyboard arrow keys. /// public double KeyboardIncrement { get => GetValue(KeyboardIncrementProperty); set => SetValue(KeyboardIncrementProperty, value); } /// /// Restricts splitter to move a multiple of the specified units. /// public double DragIncrement { get => GetValue(DragIncrementProperty); set => SetValue(DragIncrementProperty, value); } /// /// Gets or sets content that will be shown when is enabled and user starts resize operation. /// public ITemplate PreviewContent { get => GetValue(PreviewContentProperty); set => SetValue(PreviewContentProperty, value); } /// /// Converts BasedOnAlignment direction to Rows, Columns, or Both depending on its width/height. /// internal GridResizeDirection GetEffectiveResizeDirection() { GridResizeDirection direction = ResizeDirection; if (direction != GridResizeDirection.Auto) { return direction; } // When HorizontalAlignment is Left, Right or Center, resize Columns. if (HorizontalAlignment != HorizontalAlignment.Stretch) { direction = GridResizeDirection.Columns; } else if (VerticalAlignment != VerticalAlignment.Stretch) { direction = GridResizeDirection.Rows; } else if (Bounds.Width <= Bounds.Height) // Fall back to Width vs Height. { direction = GridResizeDirection.Columns; } else { direction = GridResizeDirection.Rows; } return direction; } /// /// Convert BasedOnAlignment to Next/Prev/Both depending on alignment and Direction. /// private GridResizeBehavior GetEffectiveResizeBehavior(GridResizeDirection direction) { GridResizeBehavior resizeBehavior = ResizeBehavior; if (resizeBehavior == GridResizeBehavior.BasedOnAlignment) { if (direction == GridResizeDirection.Columns) { switch (HorizontalAlignment) { case HorizontalAlignment.Left: resizeBehavior = GridResizeBehavior.PreviousAndCurrent; break; case HorizontalAlignment.Right: resizeBehavior = GridResizeBehavior.CurrentAndNext; break; default: resizeBehavior = GridResizeBehavior.PreviousAndNext; break; } } else { switch (VerticalAlignment) { case VerticalAlignment.Top: resizeBehavior = GridResizeBehavior.PreviousAndCurrent; break; case VerticalAlignment.Bottom: resizeBehavior = GridResizeBehavior.CurrentAndNext; break; default: resizeBehavior = GridResizeBehavior.PreviousAndNext; break; } } } return resizeBehavior; } /// /// Removes preview adorner from the grid. /// private void RemovePreviewAdorner() { if (_resizeData?.Adorner != null) { AdornerLayer layer = AdornerLayer.GetAdornerLayer(this)!; layer.Children.Remove(_resizeData.Adorner); } } /// /// Initialize the data needed for resizing. /// private void InitializeData(bool showsPreview) { // If not in a grid or can't resize, do nothing. var grid = GetParentGrid(); if (grid != null) { GridResizeDirection resizeDirection = GetEffectiveResizeDirection(); // Setup data used for resizing. _resizeData = new ResizeData { Grid = grid, ShowsPreview = showsPreview, ResizeDirection = resizeDirection, SplitterLength = Math.Min(Bounds.Width, Bounds.Height), ResizeBehavior = GetEffectiveResizeBehavior(resizeDirection), Scaling = this.GetLayoutRoot()?.LayoutScaling ?? 1, }; // Store the rows and columns to resize on drag events. if (!SetupDefinitionsToResize()) { // Unable to resize, clear data. _resizeData = null; return; } // Setup the preview in the adorner if ShowsPreview is true. SetupPreviewAdorner(); } } /// /// Returns true if GridSplitter can resize rows/columns. /// private bool SetupDefinitionsToResize() { // Get properties values from ContentPresenter if Grid it's used in ItemsControl as ItemsPanel otherwise directly from GridSplitter. var sourceControl = GetPropertiesValueSource(); int gridSpan = sourceControl.GetValue(_resizeData!.ResizeDirection == GridResizeDirection.Columns ? Grid.ColumnSpanProperty : Grid.RowSpanProperty); if (gridSpan == 1) { var splitterIndex = sourceControl.GetValue(_resizeData.ResizeDirection == GridResizeDirection.Columns ? Grid.ColumnProperty : Grid.RowProperty); // Select the columns based on behavior. int index1, index2; switch (_resizeData.ResizeBehavior) { case GridResizeBehavior.PreviousAndCurrent: // Get current and previous. index1 = splitterIndex - 1; index2 = splitterIndex; break; case GridResizeBehavior.CurrentAndNext: // Get current and next. index1 = splitterIndex; index2 = splitterIndex + 1; break; default: // GridResizeBehavior.PreviousAndNext. // Get previous and next. index1 = splitterIndex - 1; index2 = splitterIndex + 1; break; } // Get count of rows/columns in the resize direction. int count = _resizeData.ResizeDirection == GridResizeDirection.Columns ? _resizeData.Grid!.ColumnDefinitions.Count : _resizeData.Grid!.RowDefinitions.Count; if (index1 >= 0 && index2 < count) { _resizeData.SplitterIndex = splitterIndex; _resizeData.Definition1Index = index1; _resizeData.Definition1 = GetGridDefinition(_resizeData.Grid, index1, _resizeData.ResizeDirection); _resizeData.OriginalDefinition1Length = _resizeData.Definition1.UserSizeValueCache; // Save Size if user cancels. _resizeData.OriginalDefinition1ActualLength = GetActualLength(_resizeData.Definition1); _resizeData.Definition2Index = index2; _resizeData.Definition2 = GetGridDefinition(_resizeData.Grid, index2, _resizeData.ResizeDirection); _resizeData.OriginalDefinition2Length = _resizeData.Definition2.UserSizeValueCache; // Save Size if user cancels. _resizeData.OriginalDefinition2ActualLength = GetActualLength(_resizeData.Definition2); // Determine how to resize the columns. bool isStar1 = IsStar(_resizeData.Definition1); bool isStar2 = IsStar(_resizeData.Definition2); if (isStar1 && isStar2) { // If they are both stars, resize both. _resizeData.SplitBehavior = SplitBehavior.Split; } else { // One column is fixed width, resize the first one that is fixed. _resizeData.SplitBehavior = !isStar1 ? SplitBehavior.Resize1 : SplitBehavior.Resize2; } return true; } } return false; } /// /// Create the preview adorner and add it to the adorner layer. /// private void SetupPreviewAdorner() { if (_resizeData!.ShowsPreview) { // Get the adorner layer and add an adorner to it. var adornerLayer = AdornerLayer.GetAdornerLayer(_resizeData.Grid!); var previewContent = PreviewContent; // Can't display preview. if (adornerLayer == null) { return; } Control? builtPreviewContent = previewContent?.Build(); _resizeData.Adorner = new PreviewAdorner(builtPreviewContent); AdornerLayer.SetAdornedElement(_resizeData.Adorner, this); AdornerLayer.SetIsClipEnabled(_resizeData.Adorner, false); adornerLayer.Children.Add(_resizeData.Adorner); // Get constraints on preview's translation. GetDeltaConstraints(out _resizeData.MinChange, out _resizeData.MaxChange); } } /// /// Retrieves the that ultimately hosts this /// in the visual/logical tree. /// /// /// A splitter can be placed directly inside a or /// indirectly inside an that uses a /// as its . /// In the latter case the first logical parent is usually an /// (or the items control itself), /// so the method walks these intermediate containers to locate the /// underlying grid. /// /// /// The containing if one is found; otherwise /// null. /// protected virtual Grid? GetParentGrid() { // When GridSplitter is used inside an ItemsControl with Grid as // its ItemsPanel, its immediate parent is usually a ItemsControl or ContentPresenter. switch (Parent) { case Grid grid: { return grid; } case ItemsControl itemsControl: { if (itemsControl.ItemsPanelRoot is Grid grid) { return grid; } break; } case ContentPresenter { Parent: ItemsControl presenterItemsControl }: { if (presenterItemsControl.ItemsPanelRoot is Grid grid) { return grid; } break; } } return null; } /// /// Returns the element that carries the grid-attached properties /// (, , etc.) relevant /// to this . /// /// /// When the splitter is generated as part of an /// template, the attached properties are set on the surrounding /// rather than on the splitter itself. /// This helper selects that presenter when appropriate so subsequent /// property look-ups read the correct values; otherwise it simply /// returns this. /// /// /// The from which grid-attached properties /// should be read—either the parent or /// the splitter instance. /// protected virtual StyledElement GetPropertiesValueSource() { return Parent is ContentPresenter ? Parent : this; } protected override void OnPointerEntered(PointerEventArgs e) { base.OnPointerEntered(e); GridResizeDirection direction = GetEffectiveResizeDirection(); switch (direction) { case GridResizeDirection.Columns: Cursor = s_columnSplitterCursor; break; case GridResizeDirection.Rows: Cursor = s_rowSplitterCursor; break; } } protected override void OnLostFocus(FocusChangedEventArgs e) { base.OnLostFocus(e); if (_resizeData != null) { CancelResize(); } } protected override void OnDragStarted(VectorEventArgs e) { base.OnDragStarted(e); // TODO: Looks like that sometimes thumb will raise multiple drag started events. // Debug.Assert(_resizeData == null, "_resizeData is not null, DragCompleted was not called"); if (_resizeData != null) { return; } InitializeData(ShowsPreview); } protected override void OnDragDelta(VectorEventArgs e) { base.OnDragDelta(e); if (_resizeData != null) { double horizontalChange = e.Vector.X; double verticalChange = e.Vector.Y; // Round change to nearest multiple of DragIncrement. double dragIncrement = DragIncrement; horizontalChange = Math.Round(horizontalChange / dragIncrement) * dragIncrement; verticalChange = Math.Round(verticalChange / dragIncrement) * dragIncrement; if (_resizeData.ShowsPreview) { // Set the Translation of the Adorner to the distance from the thumb. if (_resizeData.ResizeDirection == GridResizeDirection.Columns) { _resizeData.Adorner!.OffsetX = Math.Min( Math.Max(horizontalChange, _resizeData.MinChange), _resizeData.MaxChange); } else { _resizeData.Adorner!.OffsetY = Math.Min( Math.Max(verticalChange, _resizeData.MinChange), _resizeData.MaxChange); } } else { // Directly update the grid. MoveSplitter(horizontalChange, verticalChange); } } } protected override void OnDragCompleted(VectorEventArgs e) { base.OnDragCompleted(e); if (_resizeData != null) { if (_resizeData.ShowsPreview) { // Update the grid. MoveSplitter(_resizeData.Adorner!.OffsetX, _resizeData.Adorner.OffsetY); RemovePreviewAdorner(); } _resizeData = null; } } protected override void OnKeyDown(KeyEventArgs e) { var usingXyNavigation = this.IsAllowedXYNavigationMode(e.KeyDeviceType); var allowArrowKeys = _isFocusEngaged || !usingXyNavigation; switch (e.Key) { case Key.Enter when usingXyNavigation: _isFocusEngaged = !_isFocusEngaged; e.Handled = true; break; case Key.Escape: _isFocusEngaged = false; if (_resizeData != null) { CancelResize(); e.Handled = true; } break; case Key.Left when allowArrowKeys: e.Handled = KeyboardMoveSplitter(-KeyboardIncrement, 0); break; case Key.Right when allowArrowKeys: e.Handled = KeyboardMoveSplitter(KeyboardIncrement, 0); break; case Key.Up when allowArrowKeys: e.Handled = KeyboardMoveSplitter(0, -KeyboardIncrement); break; case Key.Down when allowArrowKeys: e.Handled = KeyboardMoveSplitter(0, KeyboardIncrement); break; } } /// /// Cancels the resize operation. /// private void CancelResize() { // Restore original column/row lengths. if (_resizeData!.ShowsPreview) { RemovePreviewAdorner(); } else // Reset the columns/rows lengths to the saved values. { SetDefinitionLength(_resizeData.Definition1!, _resizeData.OriginalDefinition1Length); SetDefinitionLength(_resizeData.Definition2!, _resizeData.OriginalDefinition2Length); } _resizeData = null; } /// /// Returns true if the row/column has a star length. /// private static bool IsStar(DefinitionBase definition) { return definition.UserSizeValueCache.IsStar; } /// /// Gets Column or Row definition at index from grid based on resize direction. /// private static DefinitionBase GetGridDefinition(Grid grid, int index, GridResizeDirection direction) { return direction == GridResizeDirection.Columns ? (DefinitionBase)grid.ColumnDefinitions[index] : (DefinitionBase)grid.RowDefinitions[index]; } /// /// Retrieves the ActualWidth or ActualHeight of the definition depending on its type Column or Row. /// private static double GetActualLength(DefinitionBase definition) { var column = definition as ColumnDefinition; return column?.ActualWidth ?? ((RowDefinition)definition).ActualHeight; } /// /// Gets Column or Row definition at index from grid based on resize direction. /// private static void SetDefinitionLength(DefinitionBase definition, GridLength length) { definition.SetValue( definition is ColumnDefinition ? ColumnDefinition.WidthProperty : RowDefinition.HeightProperty, length); } /// /// Get the minimum and maximum Delta can be given definition constraints (MinWidth/MaxWidth). /// private void GetDeltaConstraints(out double minDelta, out double maxDelta) { double definition1Len = GetActualLength(_resizeData!.Definition1!); double definition1Min = _resizeData.Definition1!.UserMinSizeValueCache; double definition1Max = _resizeData.Definition1.UserMaxSizeValueCache; double definition2Len = GetActualLength(_resizeData.Definition2!); double definition2Min = _resizeData.Definition2!.UserMinSizeValueCache; double definition2Max = _resizeData.Definition2.UserMaxSizeValueCache; // Set MinWidths to be greater than width of splitter. if (_resizeData.SplitterIndex == _resizeData.Definition1Index) { definition1Min = Math.Max(definition1Min, _resizeData.SplitterLength); } else if (_resizeData.SplitterIndex == _resizeData.Definition2Index) { definition2Min = Math.Max(definition2Min, _resizeData.SplitterLength); } // Determine the minimum and maximum the columns can be resized. minDelta = -Math.Min(definition1Len - definition1Min, definition2Max - definition2Len); maxDelta = Math.Min(definition1Max - definition1Len, definition2Len - definition2Min); } /// /// Sets the length of definition1 and definition2. /// private void SetLengths(double definition1Pixels, double definition2Pixels) { // For the case where both definition1 and 2 are stars, update all star values to match their current pixel values. if (_resizeData!.SplitBehavior == SplitBehavior.Split) { var definitions = _resizeData.ResizeDirection == GridResizeDirection.Columns ? (IAvaloniaReadOnlyList)_resizeData.Grid!.ColumnDefinitions : (IAvaloniaReadOnlyList)_resizeData.Grid!.RowDefinitions; var definitionsCount = definitions.Count; for (var i = 0; i < definitionsCount; i++) { DefinitionBase definition = definitions[i]; // For each definition, if it is a star, set is value to ActualLength in stars // This makes 1 star == 1 pixel in length if (i == _resizeData.Definition1Index) { SetDefinitionLength(definition, new GridLength(definition1Pixels, GridUnitType.Star)); } else if (i == _resizeData.Definition2Index) { SetDefinitionLength(definition, new GridLength(definition2Pixels, GridUnitType.Star)); } else if (IsStar(definition)) { SetDefinitionLength(definition, new GridLength(GetActualLength(definition), GridUnitType.Star)); } } } else if (_resizeData.SplitBehavior == SplitBehavior.Resize1) { SetDefinitionLength(_resizeData.Definition1!, new GridLength(definition1Pixels)); } else { SetDefinitionLength(_resizeData.Definition2!, new GridLength(definition2Pixels)); } } /// /// Move the splitter by the given Delta's in the horizontal and vertical directions. /// private void MoveSplitter(double horizontalChange, double verticalChange) { Debug.Assert(_resizeData != null, "_resizeData should not be null when calling MoveSplitter"); // Calculate the offset to adjust the splitter. If layout rounding is enabled, we // need to round to an integer physical pixel value to avoid round-ups of children that // expand the bounds of the Grid. In practice this only happens in high dpi because // horizontal/vertical offsets here are never fractional (they correspond to mouse movement // across logical pixels). Rounding error only creeps in when converting to a physical // display with something other than the logical 96 dpi. double delta = _resizeData.ResizeDirection == GridResizeDirection.Columns ? horizontalChange : verticalChange; if (UseLayoutRounding) { delta = LayoutHelper.RoundLayoutValue(delta, LayoutHelper.GetLayoutScale(this)); } DefinitionBase? definition1 = _resizeData.Definition1; DefinitionBase? definition2 = _resizeData.Definition2; if (definition1 != null && definition2 != null) { double actualLength1 = GetActualLength(definition1); double actualLength2 = GetActualLength(definition2); double pixelLength = 1 / _resizeData.Scaling; double epsilon = pixelLength + LayoutHelper.LayoutEpsilon; // When splitting, Check to see if the total pixels spanned by the definitions // is the same as before starting resize. If not cancel the drag. We need to account for // layout rounding here, so ignore differences of less than a device pixel to avoid problems // that WPF has, such as https://stackoverflow.com/questions/28464843. if (_resizeData.SplitBehavior == SplitBehavior.Split && !MathUtilities.AreClose( actualLength1 + actualLength2, _resizeData.OriginalDefinition1ActualLength + _resizeData.OriginalDefinition2ActualLength, epsilon)) { CancelResize(); return; } GetDeltaConstraints(out var min, out var max); // Constrain Delta to Min/MaxWidth of columns delta = Math.Min(Math.Max(delta, min), max); double definition1LengthNew = actualLength1 + delta; double definition2LengthNew = actualLength1 + actualLength2 - definition1LengthNew; SetLengths(definition1LengthNew, definition2LengthNew); } } /// /// Move the splitter using the Keyboard (Don't show preview). /// private bool KeyboardMoveSplitter(double horizontalChange, double verticalChange) { // If moving with the mouse, ignore keyboard motion. if (_resizeData != null) { return false; // Don't handle the event. } // Don't show preview. InitializeData(false); // Check that we are actually able to resize. if (_resizeData == null) { return false; // Don't handle the event. } MoveSplitter(horizontalChange, verticalChange); _resizeData = null; return true; } /// /// This adorner draws the preview for the . /// It also positions the adorner. /// private sealed class PreviewAdorner : Decorator { private readonly TranslateTransform _translation; private readonly Decorator _decorator; [System.Diagnostics.CodeAnalysis.SuppressMessage("AvaloniaProperty", "AVP1012", Justification = "Private object")] public PreviewAdorner(Control? previewControl) { // Add a decorator to perform translations. _translation = new TranslateTransform(); _decorator = new Decorator { Child = previewControl, RenderTransform = _translation }; Child = _decorator; } /// /// The Preview's Offset in the X direction from the GridSplitter. /// public double OffsetX { get => _translation.X; set => _translation.X = value; } /// /// The Preview's Offset in the Y direction from the GridSplitter. /// public double OffsetY { get => _translation.Y; set => _translation.Y = value; } protected override Size ArrangeOverride(Size finalSize) { // Adorners always get clipped to the owner control. In this case we want // to constrain size to the splitter size but draw on top of the parent grid. Clip = null; return base.ArrangeOverride(finalSize); } } /// /// has special Behavior when columns are fixed. /// If the left column is fixed, splitter will only resize that column. /// Else if the right column is fixed, splitter will only resize the right column. /// private enum SplitBehavior { /// /// Both columns/rows are star lengths. /// Split, /// /// Resize 1 only. /// Resize1, /// /// Resize 2 only. /// Resize2 } /// /// Stores data during the resizing operation. /// private class ResizeData { public bool ShowsPreview; public PreviewAdorner? Adorner; // The constraints to keep the Preview within valid ranges. public double MinChange; public double MaxChange; // The grid to Resize. public Grid? Grid; // Cache of Resize Direction and Behavior. public GridResizeDirection ResizeDirection; public GridResizeBehavior ResizeBehavior; // The columns/rows to resize. public DefinitionBase? Definition1; public DefinitionBase? Definition2; // Are the columns/rows star lengths. public SplitBehavior SplitBehavior; // The index of the splitter. public int SplitterIndex; // The indices of the columns/rows. public int Definition1Index; public int Definition2Index; // The original lengths of Definition1 and Definition2 (to restore lengths if user cancels resize). public GridLength OriginalDefinition1Length; public GridLength OriginalDefinition2Length; public double OriginalDefinition1ActualLength; public double OriginalDefinition2ActualLength; // The minimum of Width/Height of Splitter. Used to ensure splitter // isn't hidden by resizing a row/column smaller than the splitter. public double SplitterLength; // The current layout scaling factor. public double Scaling; } } /// /// Enum to indicate whether resizes Columns or Rows. /// public enum GridResizeDirection { /// /// Determines whether to resize rows or columns based on its Alignment and /// width compared to height. /// Auto, /// /// Resize columns when dragging Splitter. /// Columns, /// /// Resize rows when dragging Splitter. /// Rows } /// /// Enum to indicate what Columns or Rows the resizes. /// public enum GridResizeBehavior { /// /// Determine which columns or rows to resize based on its Alignment. /// BasedOnAlignment, /// /// Resize the current and next Columns or Rows. /// CurrentAndNext, /// /// Resize the previous and current Columns or Rows. /// PreviousAndCurrent, /// /// Resize the previous and next Columns or Rows. /// PreviousAndNext } }