Search code examples
c#wpfxamldatatriggerwpf-grid

Collapse Grid Row in WPF


I have created a custom WPF element extended from RowDefinition that should collapse rows in a grid when the Collapsed property of the element is set to True.

It does it by using a converter and a datatrigger in a style to set the height of the row to 0. It is based on this SO Answer.

In the example below, this works perfectly when the grid splitter is over half way up the window. However, when it is less than half way, the rows still collapse, but the first row does not expand. Instead, there is just a white gap where the rows used to be. This can be seen in the image below.

Picture shows under half, the bottom row doesn't disappear, but over half it does

Similarly, if MinHeight or MaxHeight is set on any of the rows that are collapsed, it no longer collapses the row at all. I tried to fix this by adding setters for these properties in the data trigger but it did not fix it.

My question is what can be done differently so that it does not matter about the size of the rows or if MinHeight / MaxHeight are set, it is just able to collapse the rows?


MCVE

MainWindow.xaml.cs

using System;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;

namespace RowCollapsibleMCVE
{
    public partial class MainWindow : INotifyPropertyChanged
    {
        public MainWindow()
        {
            InitializeComponent();
            DataContext = this;
        }

        public event PropertyChangedEventHandler PropertyChanged;

        private void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        private bool isCollapsed;

        public bool IsCollapsed
        {
            get => isCollapsed;
            set
            {
                isCollapsed = value;
                OnPropertyChanged();
            }
        }
    }

    public class CollapsibleRow : RowDefinition
    {
        #region Default Values
        private const bool COLLAPSED_DEFAULT = false;
        private const bool INVERT_COLLAPSED_DEFAULT = false;
        #endregion

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

        public static readonly DependencyProperty InvertCollapsedProperty =
            DependencyProperty.Register("InvertCollapsed", typeof(bool), typeof(CollapsibleRow), new PropertyMetadata(INVERT_COLLAPSED_DEFAULT));
        #endregion

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

        public bool InvertCollapsed {
            get => (bool)GetValue(InvertCollapsedProperty);
            set => SetValue(InvertCollapsedProperty, value);
        }
        #endregion
    }

    public class BoolVisibilityConverter : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            if (values.Length > 0 && values[0] is bool collapsed)
            {
                if (values.Length > 1 && values[1] is bool invert && invert)
                {
                    collapsed = !collapsed;
                }

                return collapsed ? Visibility.Collapsed : Visibility.Visible;
            }

            return Visibility.Collapsed;
        }

        public object[] ConvertBack(object value, Type[] targetType, object parameter, CultureInfo culture)
        {
            throw new NotSupportedException();
        }
    }
}

MainWindow.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">
    <Window.Resources>
        <Visibility x:Key="CollapsedVisibilityVal">Collapsed</Visibility>
        <local:BoolVisibilityConverter x:Key="BoolVisibilityConverter"/>

        <Style TargetType="{x:Type local:CollapsibleRow}">
            <Style.Triggers>
                <DataTrigger Value="{StaticResource CollapsedVisibilityVal}">
                    <DataTrigger.Binding>
                        <MultiBinding Converter="{StaticResource BoolVisibilityConverter}">
                            <Binding Path="Collapsed"
                                     RelativeSource="{RelativeSource Self}"/>
                            <Binding Path="InvertCollapsed"
                                     RelativeSource="{RelativeSource Self}"/>
                        </MultiBinding>
                    </DataTrigger.Binding>
                    <DataTrigger.Setters>
                        <Setter Property="MinHeight" Value="0"/>
                        <Setter Property="Height" Value="0"/>
                        <Setter Property="MaxHeight" Value="0"/>
                    </DataTrigger.Setters>
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </Window.Resources>

    <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*" />
                <local:CollapsibleRow Height="Auto" />
                <local:CollapsibleRow Collapsed="{Binding IsCollapsed}" Height="*" /> <!-- Using [MaxHeight="300"] breaks this completely -->
            </Grid.RowDefinitions>
            <StackPanel Background="Red"/>

            <GridSplitter Grid.Row="1"
                          Height="10"
                          HorizontalAlignment="Stretch">
                <GridSplitter.Visibility>
                    <MultiBinding Converter="{StaticResource BoolVisibilityConverter}" >
                        <Binding Path="IsCollapsed"/>
                    </MultiBinding>
                </GridSplitter.Visibility>
            </GridSplitter>

            <StackPanel Background="Blue"
                        Grid.Row="2">
                <StackPanel.Visibility>
                    <MultiBinding Converter="{StaticResource BoolVisibilityConverter}" >
                        <Binding Path="IsCollapsed"/>
                    </MultiBinding>
                </StackPanel.Visibility>
            </StackPanel>
        </Grid>
    </Grid>
</Window>

Solution

  • 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);
        }
    }