Collapse Grid Row in WPF

The sample above is technically wrong.

What it essentially does is that it tries to force the height of the row to be 0, which is not what you want or should do - the problem is that the tab key will go through the controls even if the height is 0, and Narrator will read those controls. Essentially those controls still exist and are completely clickable, functional and accessible just they are not presented on the window, but they can be still accessed in various ways and may affect the work of the application.

Second (and the thing that causes the problems that you describe as you did not describe the problems above though they are essential too and should not be ignored), you have GridSplitter and as said it remains functional even if you force its height to 0 (as explained above). GridSplitter means that at the end of the day you are not in the control of the layout, but the user.

What should be done instead is that you should use the plain RowDefinition and set its height to Auto and then set the Visibility of the content of the rows to Collapsed - of course you may use data binding and the converter.

EDIT: further clarification - in the code above you set the new properties called Collapsed and InvertCollapsed. Just because they are named like that they don't have any effect on the row being collapsed, they could be as well called Property1 and Property2. They are used in the DataTrigger in a fairly strange way - when their value is changed that value is converted to Visibility and then if that converted value is Collapsed the setters that force row height to be 0 are called. So someone played a lot of scenery to make it look like that he is collapsing something, but he does not, he only changes the height which is quite different thing to do. And that's where the problems originate from. I certainly suggest to avoid whole this approach, but if you find it is good for your application the minimal thing you need to do is to avoid that approach for the second row where GridSplitter is set up as if you don't your request becomes impossible.


All you need is something to cache the height(s) of the visible row. After that, you no longer need converters or to toggle visibility of contained controls.

CollapsibleRow

public class CollapsibleRow : RowDefinition
{
    #region Fields
    private GridLength cachedHeight;
    private double cachedMinHeight;
    #endregion

    #region Dependency Properties
    public static readonly DependencyProperty CollapsedProperty =
        DependencyProperty.Register("Collapsed", typeof(bool), typeof(CollapsibleRow), new PropertyMetadata(false, OnCollapsedChanged));

    private static void OnCollapsedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if(d is CollapsibleRow row && e.NewValue is bool collapsed)
        {
            if(collapsed)
            {
                if(row.MinHeight != 0)
                {
                    row.cachedMinHeight = row.MinHeight;
                    row.MinHeight = 0;
                }
                row.cachedHeight = row.Height;
            }
            else if(row.cachedMinHeight != 0)
            {
                row.MinHeight = row.cachedMinHeight;
            }
            row.Height = collapsed ? new GridLength(0) : row.cachedHeight;
        }
    }
    #endregion

    #region Properties
    public bool Collapsed
    {
        get => (bool)GetValue(CollapsedProperty);
        set => SetValue(CollapsedProperty, value);
    }
    #endregion
}

XAML

<Window x:Class="RowCollapsibleMCVE.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:RowCollapsibleMCVE"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <CheckBox Content="Collapse Row"
                  IsChecked="{Binding IsCollapsed}"/>
        <Grid Row="1">
            <Grid.RowDefinitions>
                <local:CollapsibleRow Height="3*" MinHeight="0.0001"/>
                <local:CollapsibleRow Collapsed="{Binding IsCollapsed}" Height="Auto" />
                <local:CollapsibleRow Collapsed="{Binding IsCollapsed}" Height="*" /> <!-- Using [MinHeight="50" MaxHeight="100"] behaves as expected -->
            </Grid.RowDefinitions>
            <StackPanel Background="Red"/>
            <GridSplitter Grid.Row="1" Height="10" HorizontalAlignment="Stretch" />
            <StackPanel Background="Blue" Grid.Row="2" />
        </Grid>
    </Grid>
</Window>

You should have either a MaxHeight on the collapsable row (the third one in our example) or a MinHeight on the non-collapsable row (the first) adjacent to the splitter. This to ensure the star sized row has a size when you put the splitter all the way up and toggle visibility. Only then it will be able to take over the remaining space.


UPDATE

As @Ivan mentioned in his post, the controls that are contained by collapsed rows will still be focusable, allowing users to access them when they shouldn't. Admittedly, it could be a pain setting the visibility for all controls by hand, especially for large XAMLs. So let's add some custom behavior to sync the collapsed rows with their controls.

  1. The Problem

First, run the example using the code above, then collapse the bottom rows by checking the checkbox. Now, press the TAB key once and use the ARROW UP key to move the GridSplitter. As you can see, even though the splitter isn't visible, the user can still access it.

  1. The Fix

Add a new file Extensions.cs to host the behavior.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using RowCollapsibleMCVE;

namespace Extensions
{
    [ValueConversion(typeof(bool), typeof(bool))]
    public class BooleanConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return !(bool)value;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return Binding.DoNothing;
        }
    }

    public class GridHelper : DependencyObject
    {
        #region Attached Property

        public static readonly DependencyProperty SyncCollapsibleRowsProperty =
            DependencyProperty.RegisterAttached(
                "SyncCollapsibleRows",
                typeof(Boolean),
                typeof(GridHelper),
                new FrameworkPropertyMetadata(false,
                    FrameworkPropertyMetadataOptions.AffectsRender,
                    new PropertyChangedCallback(OnSyncWithCollapsibleRows)
                ));

        public static void SetSyncCollapsibleRows(UIElement element, Boolean value)
        {
            element.SetValue(SyncCollapsibleRowsProperty, value);
        }

        private static void OnSyncWithCollapsibleRows(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (d is Grid grid)
            {
                grid.Loaded += (o,ev) => SetBindingForControlsInCollapsibleRows((Grid)o);
            }
        }

        #endregion

        #region Logic

        private static IEnumerable<UIElement> GetChildrenFromPanels(IEnumerable<UIElement> elements)
        {
            Queue<UIElement> queue = new Queue<UIElement>(elements);
            while (queue.Any())
            {
                var uiElement = queue.Dequeue();
                if (uiElement is Panel panel)
                {
                    foreach (UIElement child in panel.Children) queue.Enqueue(child);
                }
                else
                {
                    yield return uiElement;
                }
            }
        }

        private static IEnumerable<UIElement> ElementsInRow(Grid grid, int iRow)
        {
            var rowRootElements = grid.Children.OfType<UIElement>().Where(c => Grid.GetRow(c) == iRow);

            if (rowRootElements.Any(e => e is Panel))
            {
                return GetChildrenFromPanels(rowRootElements);
            }
            else
            {
                return rowRootElements;
            }
        }

        private static BooleanConverter MyBooleanConverter = new BooleanConverter();

        private static void SyncUIElementWithRow(UIElement uiElement, CollapsibleRow row)
        {
            BindingOperations.SetBinding(uiElement, UIElement.FocusableProperty, new Binding
            {
                Path = new PropertyPath(CollapsibleRow.CollapsedProperty),
                Source = row,
                Converter = MyBooleanConverter
            });
        }

        private static void SetBindingForControlsInCollapsibleRows(Grid grid)
        {
            for (int i = 0; i < grid.RowDefinitions.Count; i++)
            {
                if (grid.RowDefinitions[i] is CollapsibleRow row)
                {
                    ElementsInRow(grid, i).ToList().ForEach(uiElement => SyncUIElementWithRow(uiElement, row));
                }
            }
        }

        #endregion
    }
}
  1. More Testing

Change the XAML to add the behavior and some textboxes (which are also focusable).

<Window x:Class="RowCollapsibleMCVE.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:RowCollapsibleMCVE"
        xmlns:ext="clr-namespace:Extensions"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <CheckBox Content="Collapse Row" IsChecked="{Binding IsCollapsed}"/>
        <!-- Set the desired behavior through an Attached Property -->
        <Grid ext:GridHelper.SyncCollapsibleRows="True" Row="1">
            <Grid.RowDefinitions>
                <RowDefinition Height="3*" MinHeight="0.0001" />
                <local:CollapsibleRow Collapsed="{Binding IsCollapsed}" Height="Auto" />
                <local:CollapsibleRow Collapsed="{Binding IsCollapsed}" Height="*" />
            </Grid.RowDefinitions>
            <StackPanel Background="Red">
                <TextBox Width="100" Margin="40" />
            </StackPanel>
            <GridSplitter Grid.Row="1" Height="10" HorizontalAlignment="Stretch" />
            <StackPanel Grid.Row="2" Background="Blue">
                <TextBox Width="100" Margin="40" />
            </StackPanel>
        </Grid>
    </Grid>
</Window>

In the end:

  • The logic is completely hidden from XAML (clean).
  • We're still providing flexibility:

    • For each CollapsibleRow you could bind Collapsed to a different variable.

    • Rows that don't need the behavior can use base RowDefinition (apply on demand).


UPDATE 2

As @Ash pointed out in the comments, you can use WPF's native caching to store the height values. Resulting in very clean code with autonomous properties, each handling its own => robust code. For example, using the code below you won't be able to move the GridSplitter when rows are collapsed, even without the behavior being applied.

Of course the controls would still be accessible, allowing the user to trigger events. So we'd still need the behavior, but the CoerceValueCallback does provide a consistent linkage between the Collapsed and the various height dependency properties of our CollapsibleRow.

public class CollapsibleRow : RowDefinition
{
    public static readonly DependencyProperty CollapsedProperty;

    public bool Collapsed
    {
        get => (bool)GetValue(CollapsedProperty);
        set => SetValue(CollapsedProperty, value);
    }

    static CollapsibleRow()
    {
        CollapsedProperty = DependencyProperty.Register("Collapsed",
            typeof(bool), typeof(CollapsibleRow), new PropertyMetadata(false, OnCollapsedChanged));

        RowDefinition.HeightProperty.OverrideMetadata(typeof(CollapsibleRow),
            new FrameworkPropertyMetadata(new GridLength(1, GridUnitType.Star), null, CoerceHeight));

        RowDefinition.MinHeightProperty.OverrideMetadata(typeof(CollapsibleRow),
            new FrameworkPropertyMetadata(0.0, null, CoerceHeight));

        RowDefinition.MaxHeightProperty.OverrideMetadata(typeof(CollapsibleRow),
            new FrameworkPropertyMetadata(double.PositiveInfinity, null, CoerceHeight));
    }

    private static object CoerceHeight(DependencyObject d, object baseValue)
    {
        return (((CollapsibleRow)d).Collapsed) ? (baseValue is GridLength ? new GridLength(0) : 0.0 as object) : baseValue;
    }

    private static void OnCollapsedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        d.CoerceValue(RowDefinition.HeightProperty);
        d.CoerceValue(RowDefinition.MinHeightProperty);
        d.CoerceValue(RowDefinition.MaxHeightProperty);
    }
}