Search code examples
wpfxamlgridsplitter

Creating a four-way grid splitter in WPF


In my WPF app, I have four separate quadrants, each with it's own grid and data. The four grids are separated by GridSplitters. The GridSplitters allow the user to resize each box by selecting either a horizontal or vertical splitter.

I am trying to allow the user to resize the grids by selecting the center point (circled in red). Four quadrants separated by Grid Splitters

I expected to have a four-way mouse pointer that could be used to drag up, down, left, and right. But, I only have the option to move windows up and down... or left and right.

Four way pointer


What I've tried:

<Grid> <!-- Main Grid that holds A, B, C, and D -->
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="5"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="*" />
        <RowDefinition Height="5"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>

        <Grid x:Name="gridA" Grid.Column="0" Grid.Row="0"/>
        <GridSplitter Grid.Column="0" Grid.Row="1" Height="5" HorizontalAlignment="Stretch"/>

        <Grid x:Name="gridC" Grid.Column="2" Grid.Row="0"/>
        <GridSplitter Grid.Column="3" Grid.Row="1" Height="5" HorizontalAlignment="Stretch"/>

        <Grid x:Name="gridB" Grid.Column="0" Grid.Row="2"/>
        <GridSplitter Grid.Column="1" Grid.Row="0" Width="5" HorizontalAlignment="Stretch"/>

        <Grid x:Name="gridD" Grid.Column="2" Grid.Row="2"/>
        <GridSplitter Grid.Column="1" Grid.Row="2" Width="5" HorizontalAlignment="Stretch"/>
</Grid>

Simple example showing four quadrants with the example code


Solution

  • Let me begin by changing your XAML a little bit, since right now we have four distinct GridSplitters, but two is enough:

    <Grid Name="SplitGrid">    
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="5"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="5"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
    
        <Grid x:Name="GridA" Grid.Column="0" Grid.Row="0" Background="Red" />
        <Grid x:Name="GridC" Grid.Column="2" Grid.Row="0" Background="Orange" />
        <Grid x:Name="GridB" Grid.Column="0" Grid.Row="2" Background="Green" />
        <Grid x:Name="GridD" Grid.Column="2" Grid.Row="2" Background="Yellow" />
    
        <GridSplitter x:Name="VerticalSplitter" 
                      Grid.Column="1" 
                      Grid.Row="0" 
                      Grid.RowSpan="3"     
                      HorizontalAlignment="Stretch"
                      VerticalAlignment="Stretch" 
                      Width="5" 
                      Background="Black" />
    
        <GridSplitter x:Name="HorizontalSplitter" 
                      Grid.Column="0" 
                      Grid.Row="1" 
                      Grid.ColumnSpan="3" 
                      Height="5" 
                      HorizontalAlignment="Stretch" 
                      Background="Black" />
    </Grid>
    

    What is more important about this markup is that we now have an intersection point between two splitters:

    In order to drag two splitters at a time, we need to know when should we. For that purpose, let's define a Boolean flag:

    public partial class View : Window
    {
        private bool _mouseIsDownOnBothSplitters;
    }
    

    We need to update the flag whenever the user clicks on either of the splitters (note that Preview events are used - GridSplitter implementation marks Mouse events as Handled):

    void UpdateMouseStatusOnSplittersHandler(object sender, MouseButtonEventArgs e)
    {
        UpdateMouseStatusOnSplitters(e);
    }
    
    VerticalSplitter.PreviewMouseLeftButtonDown += UpdateMouseStatusOnSplittersHandler;
    HorizontalSplitter.PreviewMouseLeftButtonDown += UpdateMouseStatusOnSplittersHandler;
    
    VerticalSplitter.PreviewMouseLeftButtonUp += UpdateMouseStatusOnSplittersHandler;
    HorizontalSplitter.PreviewMouseLeftButtonUp += UpdateMouseStatusOnSplittersHandler;
    

    The UpdateMouseStatusOnSplitters is the core method here. WPF does not provide multiple layer hit testing "out of the box", so we'll have to do a custom one:

    private void UpdateMouseStatusOnSplitters(MouseButtonEventArgs e)
    {    
        bool horizontalSplitterWasHit = false;
        bool verticalSplitterWasHit = false;
    
        HitTestResultBehavior HitTestAllElements(HitTestResult hitTestResult)
        {
            return HitTestResultBehavior.Continue;
        }
    
        //We determine whether we hit our splitters in a filter function because only it tests the visual tree 
        //HitTestAllElements apparently only tests the logical tree
        HitTestFilterBehavior IgnoreNonGridSplitters(DependencyObject hitObject)
        {
            if (hitObject == SplitGrid)
            {
                return HitTestFilterBehavior.Continue;
            }
    
            if (hitObject is GridSplitter)
            {
                if (hitObject == HorizontalSplitter)
                {
                    horizontalSplitterWasHit = true;
    
                    return HitTestFilterBehavior.ContinueSkipChildren;
                }
                if (hitObject == VerticalSplitter)
                {
                    verticalSplitterWasHit = true;
    
                    return HitTestFilterBehavior.ContinueSkipChildren;
                }
            }
    
            return HitTestFilterBehavior.ContinueSkipSelfAndChildren;
        }
    
        VisualTreeHelper.HitTest(SplitGrid, IgnoreNonGridSplitters, HitTestAllElements, new PointHitTestParameters(e.GetPosition(SplitGrid)));
    
        _mouseIsDownOnBothSplitters = horizontalSplitterWasHit && verticalSplitterWasHit;
    }
    

    Now we can implement the concurrent dragging. This will be done via a handler for DragDelta. However, there are a few caveats:

    1. We only need to implement the handler for the splitter that is on top (in my case that'll be the HorizontalSplitter)
    2. The Change value in DragDeltaEventArgs is bugged, the _lastHorizontalSplitterHorizontalDragChange is a workaround
    3. To actually "drag" the other splitter, we'll have to change the dimensions of our Column/RowDefinitions. In order to avoid weird clipping behavior (the splitter dragging the column/row with it), we'll have to use the size of it in pixels as the the size of it in stars

    So, with that out of the way, here's the relevant handler:

    private void HorizontalSplitter_DragDelta(object sender, DragDeltaEventArgs e)
    {
        if (_mouseIsDownOnBothSplitters)
        {
            var firstColumn = SplitGrid.ColumnDefinitions[0];
            var thirdColumn = SplitGrid.ColumnDefinitions[2];
    
            var horizontalOffset = e.HorizontalChange - _lastHorizontalSplitterHorizontalDragChange;
    
            var maximumColumnWidth = firstColumn.ActualWidth + thirdColumn.ActualWidth;
    
            var newProposedFirstColumnWidth = firstColumn.ActualWidth + horizontalOffset;
            var newProposedThirdColumnWidth = thirdColumn.ActualWidth - horizontalOffset;
    
            var newActualFirstColumnWidth = newProposedFirstColumnWidth < 0 ? 0 : newProposedFirstColumnWidth;
    
            var newActualThirdColumnWidth = newProposedThirdColumnWidth < 0 ? 0 : newProposedThirdColumnWidth;
    
            firstColumn.Width = new GridLength(newActualFirstColumnWidth, GridUnitType.Star);
            thirdColumn.Width = new GridLength(newActualThirdColumnWidth, GridUnitType.Star);
    
            _lastHorizontalSplitterHorizontalDragChange = e.HorizontalChange;
        }
    }
    

    Now, this is almost a full solution. It, however, suffers from the fact that even if you move your mouse horizontally outside of the grid, the VerticalSplitter still moves with it, which is inconsistent with the default behavior. In order to counteract this, let's add this check to the handler's code:

    if (_mouseIsDownOnBothSplitters)
    {
       var mousePositionRelativeToGrid = Mouse.GetPosition(SplitGrid);
       if (mousePositionRelativeToGrid.X > 0 && mousePositionRelativeToGrid.X < SplitGrid.ActualWidth)
       {
           //The rest of the handler's code
       }
    }
    

    Finally, we need to reset our _lastHorizontalSplitterHorizontalDragChange to zero when the dragging is over:

    HorizontalSplitter.DragCompleted += (o, e) => _lastHorizontalSplitterHorizontalDragChange = 0;
    

    I hope it is not too daring of me to leave the implementation of the cursor's image change to you.