在 WPF 中折叠网格行

Collapse Grid Row in WPF

我创建了一个从 RowDefinition 扩展的自定义 WPF 元素,当元素的 Collapsed 属性 设置为 True 时,它应该折叠网格中的行。

它是通过在样式中使用转换器和数据触发器来将行高设置为0来实现的。它基于此SO Answer

在下面的示例中,当网格分离器位于 window 的一半以上时,此方法非常有效。但是,当它不到一半时,行仍然折叠,但第一行不会展开。相反,原来行所在的位置只有一个白色间隙。这可以在下图中看到。

同样,如果在折叠的任何行上设置了 MinHeightMaxHeight,它根本不再折叠该行。我试图通过在数据触发器中为这些属性添加设置器来解决这个问题,但没有解决。

我的问题是可以采取哪些不同的方式来使行的大小无关紧要,或者如果设置了 MinHeight / MaxHeight,它只能折叠行?


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>

上面的示例在技术上是错误的。

它本质上所做的是试图强制行高为 0,这不是您想要或应该做的 - 问题是 Tab 键将通过控件,即使高度为 0,讲述人将读取这些控件。本质上,这些控件仍然存在,并且完全可单击、正常运行和可访问,只是它们没有出现在 window 上,但它们仍然可以通过各种方式访问​​,并且可能会影响应用程序的工作。

其次(以及导致您描述的问题的事情,因为您没有描述上面的问题,尽管它们也是必不可少的并且不应被忽略),您有 GridSplitter 并且如前所述,它仍然可以正常工作即使你强制它的高度为 0(如上所述)。 GridSplitter 意味着在一天结束时,您不受布局的控制,而是用户。

应该做的是,您应该使用普通 RowDefinition 并将其高度设置为 Auto,然后将行内容的 Visibility 设置为 Collapsed - 当然你可以使用数据绑定和转换器。

编辑:进一步说明 - 在上面的代码中,您设置了名为 CollapsedInvertCollapsed 的新属性。正因为这样命名,它们对折叠的行没有任何影响,所以它们也可以称为 Property1 和 Property2。它们以一种相当奇怪的方式在 DataTrigger 中使用 - 当它们的值被更改时,该值被转换为 Visibility 然后如果该转换值是 Collapsed 强制行高的设置器被称为 0。所以有人玩了很多布景,让他看起来像是在倒塌什么东西,但他并没有,他只是改变了高度,这是完全不同的事情。这就是问题的根源。我当然建议避免使用这种方法,但是如果您发现它对您的应用程序有好处,那么您需要做的最少的事情就是避免在设置 GridSplitter 的第二行使用这种方法,就好像您不这样做一样,您的请求变得不可能.

您只需要缓存可见行的高度即可。之后,您不再需要转换器或切换包含控件的可见性。

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>

您应该在可折叠行(我们示例中的第三行)上有一个 MaxHeight,或者在与拆分器相邻的不可折叠行(第一行)上有一个 MinHeight。这是为了确保当您将拆分器一直向上并切换可见性时,星形大小的行具有大小。只有这样它才能接管剩下的space.


更新

正如@Ivan 在他的 post 中提到的那样,折叠行​​中包含的控件仍可聚焦,允许用户在不应该访问它们时访问它们。 诚然,手动设置所有控件的可见性可能很痛苦,尤其是对于大型 XAMLs。因此,让我们添加一些自定义行为以将折叠的行与其控件同步。

  1. 问题

首先,运行 使用上面代码的示例,然后通过选中复选框折叠底行。现在,按一次 TAB 键并使用向上箭头键移动 GridSplitter。如您所见,即使拆分器不可见,用户仍然可以访问它。

  1. 修复

添加一个新文件 Extensions.cs 来承载行为。

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. 更多测试

更改 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"
        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>

最后:

  • XAML 完全隐藏了逻辑(干净)。
  • 我们仍在提供灵活性:

    • 对于每个 CollapsibleRow,您可以将 Collapsed 绑定到不同的变量。

    • 不需要行为的行可以使用基础RowDefinition(按需应用)。


更新 2

正如@Ash 在评论中指出的那样,您可以使用 WPF's native caching 来存储高度值。产生具有自治属性的非常干净的代码,每个处理自己的 => 健壮的代码。例如,使用下面的代码,即使没有应用行为,您也无法在行折叠时移动 GridSplitter

当然,控件仍然可以访问,允许用户触发事件。所以我们仍然需要这种行为,但是 CoerceValueCallback 确实在 Collapsed 和我们的 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);
    }
}