WPF中折叠网格行

11
我创建了一个自定义的WPF元素,它是从RowDefinition扩展而来的,当元素的Collapsed属性设置为True时,应该会折叠网格中的行。
它通过在样式中使用转换器和数据触发器来将行的高度设置为0来实现。它基于这个SO Answer
在下面的示例中,当网格分隔符超过窗口的一半时,这个方法可以完美地工作。然而,当它小于一半时,行仍然会折叠,但第一行不会展开。相反,只有一个白色的间隙,原来的行消失了。可以在下面的图片中看到。

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

同样地,如果任何被折叠的行上设置了MinHeightMaxHeight,它将不再折叠该行。我尝试通过在数据触发器中添加这些属性的setter来修复此问题,但并没有解决它。
我的问题是有什么不同的方法可以做到不考虑行的大小或是否设置了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>

3
https://github.com/KBurov/Testing/blob/master/Common/Extended/RowDefinitionExtended.cs - ASh
@ASh 感谢您的评论。它非常有用,似乎已经被整合到Funk的答案中了。 - Dan
2个回答

6
你只需要一个缓存可见行高度的工具。之后,你就不再需要转换器或切换包含控件的可见性。
可折叠行
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。这样可以确保星号大小的行在将分隔符完全移至顶部并切换可见性时具有大小。只有这样,它才能接管剩余的空间。

更新

正如@Ivan在他的帖子中提到的那样,被折叠行包含的控件仍然是可聚焦的,这使得用户可以在不应该访问它们时访问它们。诚然,手动为所有控件设置可见性可能会很麻烦,特别是对于大型XAML文件。因此,让我们添加一些自定义行为来同步折叠的行与其控件。

  1. 问题

首先,使用上面的代码运行示例,然后通过选中复选框折叠底部行。现在,按一次TAB键并使用ARROW UP键移动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的本地缓存来存储高度值。这将产生非常干净的代码,具有自治属性,每个属性都处理自己的=>健壮的代码。例如,使用下面的代码,即使未应用行为,当行被折叠时,您也无法移动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);
    }
}

哇,感谢您的回答和额外处理行子元素可见性的代码。这似乎非常有效,我很可能会在本周晚些时候授予您赏金。 - Dan
1
很高兴你喜欢它!随意将赏金保持开放,直到最后,可能会出现更好的东西 ;) - Funk

4
上面的示例在技术上是错误的。它实际上试图强制行的高度为0,这不是您想要或应该做的 - 问题在于即使高度为0,Tab键仍将穿过控件,并且Narrator将读取这些控件。本质上,这些控件仍然存在并且完全可点击,功能和可访问,只是它们没有显示在窗口上,但是它们仍然可以通过各种方式访问,并可能影响应用程序的工作。
第二个问题(也是您未描述的问题,尽管它们也很重要,不应忽略)是您有一个GridSplitter,正如前面所说,即使您强制其高度为0(如上所述),它仍然是可用的。 GridSplitter意味着最终你无法控制布局,而是用户。
相反,应该使用普通的RowDefinition并将其高度设置为Auto,然后将行内容的Visibility设置为Collapsed - 当然,您可以使用数据绑定和转换器。
编辑:进一步澄清 - 在上面的代码中,您设置了名为Collapsed和InvertCollapsed的新属性。仅仅因为它们被命名为这样,它们对折叠的行没有任何影响,它们可能也被称为Property1和Property2。它们以相当奇怪的方式在DataTrigger中使用 - 当它们的值被更改时,该值会转换为Visibility,然后如果该转换值为Collapsed,则调用强制行高度为0的setter。因此,有人进行了大量的布景来使其看起来像他正在折叠某些内容,但实际上他没有这样做,他只是更改了高度,这是完全不同的事情。问题就是从那里产生的。我肯定建议避免整个这种方法,但如果您发现它对您的应用程序有好处,则需要做的最小化的事情是避免在设置了GridSplitter的第二行中使用该方法,否则您的请求将变得不可能。

1
嗨,我试图解释这不像在其他情况下那样完全折叠,您可以添加控件并使用建议的标签键进行检查。关键部分是将GridSplitter Visibility设置为Collapsed而不是其行,这将使它适用于您即使使用相同的代码作为应用程序的其余部分。但是,由你的方法引起的问题仍然存在,但如果您对它们感到满意,则可以使用它。 - Ivan Ičin
@Dan,我更新了进一步的澄清,因为我发现对你来说不够清楚,那么我猜其他人也不太清楚,而答案是正确的。 - Ivan Ičin
@Ivan 很好地发现了+1。然而,对于大型XAML文件,手动设置所有控件的可见性可能会非常麻烦。我添加了行为来处理控件绘制焦点的问题。 - Funk
@Funk,这就是数据绑定的作用,让你可以通过一行代码改变所有内容...现在Dan说他不能这样做的原因可能无法从一个评论中理解,但他可以手动在一行上完成这个操作,并将这种错误的方法保留到最后... - Ivan Ičin
嗨Ivan,谢谢你的回答。我不认为你理解我的评论,通常我也将网格分隔符设置为折叠状态,并更新了问题以显示这一点。我甚至从该行中删除了折叠状态,因为您正确关于自动处理该行大小。同样,我在问题中也将堆栈面板设置为折叠状态。尽管如此,如果从代码中删除网格分隔符,只剩下两个堆栈面板,问题仍然存在。 - Dan
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接