Browse Source

Fix usage of GridSplitter inside ItemsControl as ItemsPanel (#19200)

* Get parent Grid when its used in ItemsControl as ItemsPanel

* Get properties values from ContentPresenter if its used in ItemsControl

* Add unit tests

* Move test types

* Adjust GetParentGrid switch and expose helper methods for override
Wiesław Šoltés 2 months ago
parent
commit
0aa0a09202

+ 83 - 3
src/Avalonia.Controls/GridSplitter.cs

@@ -7,6 +7,7 @@ using System;
 using System.Diagnostics;
 using System.Diagnostics;
 using Avalonia.Collections;
 using Avalonia.Collections;
 using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Presenters;
 using Avalonia.Input;
 using Avalonia.Input;
 using Avalonia.Interactivity;
 using Avalonia.Interactivity;
 using Avalonia.Layout;
 using Avalonia.Layout;
@@ -211,7 +212,9 @@ namespace Avalonia.Controls
         private void InitializeData(bool showsPreview)
         private void InitializeData(bool showsPreview)
         {
         {
             // If not in a grid or can't resize, do nothing.
             // If not in a grid or can't resize, do nothing.
-            if (Parent is Grid grid)
+            var grid = GetParentGrid();
+
+            if (grid != null)
             {
             {
                 GridResizeDirection resizeDirection = GetEffectiveResizeDirection();
                 GridResizeDirection resizeDirection = GetEffectiveResizeDirection();
 
 
@@ -244,13 +247,15 @@ namespace Avalonia.Controls
         /// </summary>
         /// </summary>
         private bool SetupDefinitionsToResize()
         private bool SetupDefinitionsToResize()
         {
         {
-            int gridSpan = GetValue(_resizeData!.ResizeDirection == GridResizeDirection.Columns ?
+            // 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.ColumnSpanProperty :
                 Grid.RowSpanProperty);
                 Grid.RowSpanProperty);
 
 
             if (gridSpan == 1)
             if (gridSpan == 1)
             {
             {
-                var splitterIndex = GetValue(_resizeData.ResizeDirection == GridResizeDirection.Columns ?
+                var splitterIndex = sourceControl.GetValue(_resizeData.ResizeDirection == GridResizeDirection.Columns ?
                     Grid.ColumnProperty :
                     Grid.ColumnProperty :
                     Grid.RowProperty);
                     Grid.RowProperty);
 
 
@@ -351,6 +356,81 @@ namespace Avalonia.Controls
             }
             }
         }
         }
 
 
+        /// <summary>
+        /// Retrieves the <see cref="Grid"/> that ultimately hosts this
+        /// <see cref="GridSplitter"/> in the visual/logical tree.
+        /// </summary>
+        /// <remarks>
+        /// A splitter can be placed directly inside a <see cref="Grid"/> or
+        /// indirectly inside an <see cref="ItemsControl"/> that uses a
+        /// <see cref="Grid"/> as its <see cref="ItemsControl.ItemsPanel"/>.
+        /// In the latter case the first logical parent is usually an
+        /// <see cref="ContentPresenter"/> (or the items control itself),
+        /// so the method walks these intermediate containers to locate the
+        /// underlying grid.
+        /// </remarks>
+        /// <returns>
+        /// The containing <see cref="Grid"/> if one is found; otherwise
+        /// <c>null</c>.
+        /// </returns>
+        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;
+        }
+
+        /// <summary>
+        /// Returns the element that carries the grid-attached properties
+        /// (<see cref="Grid.RowProperty"/>, <see cref="Grid.ColumnProperty"/>, etc.) relevant
+        /// to this <see cref="GridSplitter"/>.
+        /// </summary>
+        /// <remarks>
+        /// When the splitter is generated as part of an <see cref="ItemsControl"/>
+        /// template, the attached properties are set on the surrounding
+        /// <see cref="ContentPresenter"/> 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 <c>this</c>.
+        /// </remarks>
+        /// <returns>
+        /// The <see cref="StyledElement"/> from which grid-attached properties
+        /// should be read—either the parent <see cref="ContentPresenter"/> or
+        /// the splitter instance.
+        /// </returns>
+        protected virtual StyledElement GetPropertiesValueSource()
+        {
+            return Parent is ContentPresenter 
+                ? Parent 
+                : this;
+        }
+
         protected override void OnPointerEntered(PointerEventArgs e)
         protected override void OnPointerEntered(PointerEventArgs e)
         {
         {
             base.OnPointerEntered(e);
             base.OnPointerEntered(e);

+ 162 - 0
tests/Avalonia.Controls.UnitTests/GridSplitterTests.cs

@@ -1,5 +1,8 @@
+using System.Collections.Generic;
+using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Primitives;
 using Avalonia.Input;
 using Avalonia.Input;
+using Avalonia.Markup.Xaml;
 using Avalonia.Platform;
 using Avalonia.Platform;
 using Avalonia.UnitTests;
 using Avalonia.UnitTests;
 using Moq;
 using Moq;
@@ -380,5 +383,164 @@ namespace Avalonia.Controls.UnitTests
             Assert.Equal(columnDefinitions[0].Width, new GridLength(1, GridUnitType.Star));
             Assert.Equal(columnDefinitions[0].Width, new GridLength(1, GridUnitType.Star));
             Assert.Equal(columnDefinitions[2].Width, new GridLength(1, GridUnitType.Star));
             Assert.Equal(columnDefinitions[2].Width, new GridLength(1, GridUnitType.Star));
         }
         }
+  
+        [Fact]
+        public void Works_In_ItemsControl_ItemsSource()
+        {
+            using var app = UnitTestApplication.Start(TestServices.StyledWindow);
+
+            var xaml = @"<ItemsControl xmlns='https://github.com/avaloniaui'
+                                  xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+                                  xmlns:local='clr-namespace:Avalonia.Controls.UnitTests'>
+    <ItemsControl.Resources>
+      <ControlTheme x:Key='{x:Type ItemsControl}' TargetType='ItemsControl'>
+        <Setter Property='Template'>
+          <ControlTemplate>
+            <Border Background='{TemplateBinding Background}'
+                    BorderBrush='{TemplateBinding BorderBrush}'
+                    BorderThickness='{TemplateBinding BorderThickness}'
+                    CornerRadius='{TemplateBinding CornerRadius}'
+                    Padding='{TemplateBinding Padding}'>
+              <ItemsPresenter Name='PART_ItemsPresenter'
+                              ItemsPanel='{TemplateBinding ItemsPanel}'/>
+            </Border>
+          </ControlTemplate>
+        </Setter>
+      </ControlTheme>
+    </ItemsControl.Resources>
+    <ItemsControl.Styles>
+        <Style Selector='ItemsControl > ContentPresenter'>
+            <Setter Property='(Grid.Column)' Value='{Binding Column}'/>
+        </Style>
+    </ItemsControl.Styles>
+    <ItemsControl.DataTemplates>
+        <DataTemplate DataType='local:TextItem'>
+            <Border><TextBlock Text='{Binding Text}'/></Border>
+        </DataTemplate>
+        <DataTemplate DataType='local:SplitterItem'>
+            <GridSplitter ResizeDirection='Columns'/>
+        </DataTemplate>
+    </ItemsControl.DataTemplates>
+    <ItemsControl.ItemsPanel>
+        <ItemsPanelTemplate>
+            <Grid ColumnDefinitions='*,10,*'/>
+        </ItemsPanelTemplate>
+    </ItemsControl.ItemsPanel>
+</ItemsControl>";
+
+            var itemsControl = AvaloniaRuntimeXamlLoader.Parse<ItemsControl>(xaml);
+            itemsControl.ItemsSource = new List<IGridItem>
+            {
+                new TextItem { Column = 0, Text = "A" },
+                new SplitterItem { Column = 1 },
+                new TextItem { Column = 2, Text = "B" },
+            };
+
+            var root = new TestRoot { Child = itemsControl };
+            root.Measure(new Size(200, 100));
+            root.Arrange(new Rect(0, 0, 200, 100));
+
+            var panel = Assert.IsType<Grid>(itemsControl.ItemsPanelRoot);
+            var cp = Assert.IsType<ContentPresenter>(panel.Children[1]);
+            cp.UpdateChild();
+            var splitter = Assert.IsType<GridSplitter>(cp.Child);
+
+            splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragStartedEvent });
+            splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent, Vector = new Vector(-20, 0) });
+            splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragCompletedEvent });
+
+            Assert.NotEqual(panel.ColumnDefinitions[0].Width, panel.ColumnDefinitions[2].Width);
+        }
+
+        [Fact]
+        public void Works_In_ItemsControl_Items()
+        {
+            using var app = UnitTestApplication.Start(TestServices.StyledWindow);
+
+            var xaml = @"<ItemsControl xmlns='https://github.com/avaloniaui'
+                                  xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
+    <ItemsControl.Resources>
+      <ControlTheme x:Key='{x:Type ItemsControl}' TargetType='ItemsControl'>
+        <Setter Property='Template'>
+          <ControlTemplate>
+            <Border Background='{TemplateBinding Background}'
+                    BorderBrush='{TemplateBinding BorderBrush}'
+                    BorderThickness='{TemplateBinding BorderThickness}'
+                    CornerRadius='{TemplateBinding CornerRadius}'
+                    Padding='{TemplateBinding Padding}'>
+              <ItemsPresenter Name='PART_ItemsPresenter'
+                              ItemsPanel='{TemplateBinding ItemsPanel}'/>
+            </Border>
+          </ControlTemplate>
+        </Setter>
+      </ControlTheme>
+    </ItemsControl.Resources>
+    <ItemsControl.Items>
+        <Border Grid.Column='0'/>
+        <GridSplitter Grid.Column='1' ResizeDirection='Columns'/>
+        <Border Grid.Column='2'/>
+    </ItemsControl.Items>
+    <ItemsControl.ItemsPanel>
+        <ItemsPanelTemplate>
+            <Grid ColumnDefinitions='*,10,*'/>
+        </ItemsPanelTemplate>
+    </ItemsControl.ItemsPanel>
+</ItemsControl>";
+
+            var itemsControl = AvaloniaRuntimeXamlLoader.Parse<ItemsControl>(xaml);
+            var root = new TestRoot { Child = itemsControl };
+            root.Measure(new Size(200, 100));
+            root.Arrange(new Rect(0, 0, 200, 100));
+
+            var panel = Assert.IsType<Grid>(itemsControl.ItemsPanelRoot);
+            var splitter = Assert.IsType<GridSplitter>(panel.Children[1]);
+
+            splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragStartedEvent });
+            splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent, Vector = new Vector(-20, 0) });
+            splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragCompletedEvent });
+
+            Assert.NotEqual(panel.ColumnDefinitions[0].Width, panel.ColumnDefinitions[2].Width);
+        }
+
+        [Fact]
+        public void Works_In_Grid()
+        {
+            using var app = UnitTestApplication.Start(TestServices.StyledWindow);
+
+            var xaml = @"<Grid xmlns='https://github.com/avaloniaui' ColumnDefinitions='*,10,*'>
+    <Border Grid.Column='0'/>
+    <GridSplitter Grid.Column='1' ResizeDirection='Columns'/>
+    <Border Grid.Column='2'/>
+</Grid>";
+
+            var grid = AvaloniaRuntimeXamlLoader.Parse<Grid>(xaml);
+            var root = new TestRoot { Child = grid };
+            root.Measure(new Size(200, 100));
+            root.Arrange(new Rect(0, 0, 200, 100));
+
+            var splitter = Assert.IsType<GridSplitter>(grid.Children[1]);
+
+            splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragStartedEvent });
+            splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent, Vector = new Vector(-20, 0) });
+            splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragCompletedEvent });
+
+            Assert.NotEqual(grid.ColumnDefinitions[0].Width, grid.ColumnDefinitions[2].Width);
+        }
+    }
+
+    public interface IGridItem
+    {
+        int Column { get; set; }
+    }
+
+    public class TextItem : IGridItem
+    {
+        public int Column { get; set; }
+        public string? Text { get; set; }
+    }
+
+    public class SplitterItem : IGridItem
+    {
+        public int Column { get; set; }
     }
     }
 }
 }