使用 Viewbox 缩放包含标签和文本框的网格

Using Viewbox to scale Grid containing Labels and Textboxes

所以我正在尝试构建一个表单,它会根据父容器的可用宽度和相同的列百分比自动按比例上下缩放,如下所示:

周围的其他内容也需要缩放,例如图像和按钮(它们不会在同一个网格中),根据我目前所读的内容,使用 Viewbox 是一种方法去吧。

但是,当我使用 Stretch="Uniform" 将我的网格包裹在一个 Viewbox 中时,Textbox 控制每个折叠到它们的最小宽度,看起来像这样:

如果我增加容器宽度,一切都按预期缩放(好),但文本框仍然折叠到它们的最小可能宽度(坏):

如果我在文本框中键入任何内容,它们会增加宽度以包含文本:

...但我不想要这种行为 - 我希望文本框元素宽度与网格列宽相关联,而不是依赖于内容。

现在,我查看了各种 SO 问题,这个问题最接近我所追求的问题: How to automatically scale font size for a group of controls?

...但它仍然没有真正专门处理文本框宽度行为(当它与 Viewbox 行为交互时),这似乎是主要问题。

我尝试了多种方法 - 不同的 Horizo​​ntalAlignment="Stretch" 设置等等,但到目前为止都没有任何效果。这是我的 XAML:

<Window x:Class="WpfApp1.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:WpfApp1"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*" />
            <ColumnDefinition Width="5" />
            <ColumnDefinition Width="2*" />
        </Grid.ColumnDefinitions>

        <StackPanel Grid.Column="0">
            <StackPanel.Background>
                <LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
                    <GradientStop Color="Silver" Offset="0"/>
                    <GradientStop Color="White" Offset="1"/>
                </LinearGradientBrush>
            </StackPanel.Background>

            <Viewbox Stretch="Uniform" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
                <Grid Background="White">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="1*" />
                        <ColumnDefinition Width="2*" />
                        <ColumnDefinition Width="1*" />
                        <ColumnDefinition Width="2*" />
                    </Grid.ColumnDefinitions>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="*" />
                        <RowDefinition Height="Auto" />
                    </Grid.RowDefinitions>
                    <Label Content="Field A" Grid.Column="0" Grid.Row="0" />
                    <TextBox Grid.Column="1" Grid.Row="0" HorizontalAlignment="Stretch"></TextBox>
                    <Label Content="Field B" Grid.Column="2" Grid.Row="0"  />
                    <TextBox Grid.Column="3" Grid.Row="0" HorizontalAlignment="Stretch"></TextBox>
                </Grid>
            </Viewbox>
            <Label Content="Other Stuff"/>
        </StackPanel>
        <GridSplitter Grid.Column="1" HorizontalAlignment="Stretch" Height="100" Width="5"/>
        <StackPanel Grid.Column="2">
            <Label Content="Body"/>
        </StackPanel>
    </Grid>
</Window>

此行为的原因是 Viewbox child 被赋予无限 space 以测量其所需的大小。将 TextBoxes 拉伸到无限 Width 没有多大意义,因为无论如何都无法渲染,因此返回它们的默认大小。

您可以使用转换器来达到所需的效果。

public class ToWidthConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        double gridWidth = (double)value;
        return gridWidth * 2/6;
    }

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

您可以将所有内容都添加到这些资源中。

<Viewbox.Resources>
    <local:ToWidthConverter x:Key="ToWidthConverter"/>
    <Style TargetType="{x:Type TextBox}">
        <Setter Property="Width" 
                Value="{Binding ActualWidth, 
                    RelativeSource={RelativeSource AncestorType={x:Type Grid}}, 
                    Converter={StaticResource ToWidthConverter}}"/>
    </Style>
</Viewbox.Resources>

更新

I'm having trouble understanding the original problem of the infinite grid width.

无限space 方法通常用于确定UIElementDesiredSize。简而言之,您为控件提供它可能需要的所有 space(无约束),然后对其进行测量以检索其所需的大小。 Viewbox 使用这种方法来测量它的 child,但是我们的 Grid 的大小是动态的(代码中没有设置 HeightWidth),所以 Viewbox 在网格 children 下一个级别,看看它是否可以通过取组件的总和来确定大小。

但是,当组件总和超过可用总大小时,您可能 运行 会遇到问题,如下所示。

我用标签 FooBar 替换了文本框,并将它们的背景颜色设置为灰色。现在我们可以看到 Bar 正在入侵 Body 领土,这显然不是我们想要发生的事情。

同样,问题的根源来自 Viewbox 不知道如何将无穷大分成 6 等份(映射到列宽 1*2*1*,2*), 所以我们需要做的就是用网格宽度恢复 link。在 ToWidthConverter 中,目的是将 TextBox' Width 映射到 2*Grids ColumnWidth,所以我使用了 gridWidth * 2/6.现在 Viewbox 又可以解方程了:每个 TextBox 得到 gridwidth 的三分之一,每个 Label 得到一半(1* vs 2*).

当然,当您通过引入新列来打乱事物时,您必须注意使组件的总和与总可用宽度保持同步。换句话说,方程需要是可解的。 计算所需大小(您未约束的控件的总和,在我们的示例中为标签)和转换后的大小(作为 [=45= 的一部分],在我们的示例中为文本框)需要小于或等于可用大小(在我们的示例中为 gridWidth)。

我发现如果您对 TextBoxes 使用转换后的大小,并且让星形大小的 ColumnWidths 处理大多数其他大小,则缩放效果很好。请记住保持在总可用大小之内。

增加灵活性的一种方法是在组合中添加 ConverterParameter

public class PercentageToWidthConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        double gridWidth = (double)value;
        double percentage = ParsePercentage(parameter);
        return gridWidth * percentage;
    }

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

    private double ParsePercentage(object parameter)
    {
        // I chose to let it fail if parameter isn't in right format            
        string[] s = ((string)parameter).Split('/');
        double percentage = Double.Parse(s[0]) / Double.Parse(s[1]);
        return percentage;
    }
}

gridWidth 平均分配 10 份,并将这些份额相应地分配给组件的示例。

<Viewbox Stretch="Uniform">
    <Viewbox.Resources>
        <local:PercentageToWidthConverter x:Key="PercentageToWidthConverter"/>
    </Viewbox.Resources>
    <Grid Background="White">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="2*" />
            <ColumnDefinition Width="2*" />
            <ColumnDefinition Width="2*" />
            <ColumnDefinition Width="3*" />
            <ColumnDefinition Width="1*" />
        </Grid.ColumnDefinitions>
        <Label Content="Field A" Grid.Column="0" />
        <TextBox Grid.Column="1"
                 Width="{Binding ActualWidth, 
                     RelativeSource={RelativeSource AncestorType={x:Type Grid}}, 
                     Converter={StaticResource PercentageToWidthConverter}, 
                     ConverterParameter=2/10}" />
        <Label Content="Field B" Grid.Column="2"  />
        <TextBox Grid.Column="3"
                 Width="{Binding ActualWidth, 
                     RelativeSource={RelativeSource AncestorType={x:Type Grid}}, 
                     Converter={StaticResource PercentageToWidthConverter}, 
                     ConverterParameter=3/10}" />
        <Button Content="Ok" Grid.Column="4"
                Width="{Binding ActualWidth, 
                    RelativeSource={RelativeSource AncestorType={x:Type Grid}}, 
                    Converter={StaticResource PercentageToWidthConverter}, 
                    ConverterParameter=1/10}" />
    </Grid>
</Viewbox>

注意每个控件的份额,分组为 2 - 2 - 2 - 3 - 1(按钮宽度为 1)。

最后,根据您所追求的可重用性,还有其他一些处理方法:

  • 在根 Grid 上设置固定大小。缺点:
    • 每次更改组件时都需要微调(以实现 所需的水平/垂直/字体大小比例)
    • 这个比例可能会因不同的主题、Windows 个版本、...
    • 而中断
  • 加一个Behavior。正如您 linked FontSize post 中的一个答案所做的那样,而是实现了将列宽映射到 gridWidth.
  • 的部分
  • 按照@Grx70 的建议创建自定义面板。

您的方法存在的问题是 Grid 与我们直觉上认为的不完全一样。即,star 大小按预期工作 如果满足这些条件:

  • Grid 的水平对齐设置为 Stretch
  • Grid 包含在一个有限大小的容器中,即它的 Measure 方法接收到有限 Width(不是 double.PositiveInfinity
  • 的约束

这与列大小有关;行大小是对称的。在您的情况下,不满足第二个条件。我不知道有什么简单的技巧可以使 Grid 像您期望的那样工作,所以我的解决方案是创建自定义的 Panel 来完成这项工作。这样您就可以完全控制控件的布局方式。实现起来并不难,但需要对 WPF 布局系统的工作原理有一定程度的了解。

这是一个执行您的命令的实施示例。为了简洁起见,它只能水平工作,但不难扩展它也能垂直工作。

public class ProportionalPanel : Panel
{
    protected override Size MeasureOverride(Size constraint)
    {
        /* Here we measure all children and return minimal size this panel
         * needs to be to show all children without clipping while maintaining
         * the desired proportions between them. We should try, but are not
         * obliged to, fit into the given constraint (available size) */

        var desiredSize = new Size();
        if (Children.Count > 0)
        {
            var children = Children.Cast<UIElement>().ToList();
            var weights = children.Select(GetWeight).ToList();
            var totalWeight = weights.Sum();
            var unitWidth = 0d;
            if (totalWeight == 0)
            {
                //We should handle the situation when all children have weights set
                //to 0. One option is to measure them with 0 available space. To do
                //so we simply set totalWeight to something other than 0 to avoid
                //division by 0 later on.
                totalWeight = children.Count;

                //We could also assume they are to be arranged uniformly, so we
                //simply coerce their weights to 1
                for (var i = 0; i < weights.Count; i++)
                    weights[i] = 1;
            }
            for (var i = 0; i < children.Count; i++)
            {
                var child = children[i];
                child.Measure(new Size
                {
                    Width = constraint.Width * weights[i] / totalWeight,
                    Height = constraint.Height
                });
                desiredSize.Width += child.DesiredSize.Width;
                desiredSize.Height =
                    Math.Max(desiredSize.Height, child.DesiredSize.Height);
                if (weights[i] != 0)
                    unitWidth =
                        Math.Max(unitWidth, child.DesiredSize.Width / weights[i]);
            }
            if (double.IsPositiveInfinity(constraint.Width))
            {
                //If there's unlimited space (e.g. when the panel is nested in a Viewbox
                //or a StackPanel) we need to adjust the desired width so that no child
                //is given less than desired space while maintaining the desired
                //proportions between them
                desiredSize.Width = totalWeight * unitWidth;
            }
        }
        return desiredSize;
    }

    protected override Size ArrangeOverride(Size constraint)
    {
        /* Here we arrange all children into their places and return the
         * actual size this panel is. The constraint will never be smaller
         * than the value of DesiredSize property, which is determined in 
         * the MeasureOverride method. If the desired size is larger than
         * the size of parent element, the panel will simply be clipped 
         * or appear "outside" of the parent element */

        var size = new Size();
        if (Children.Count > 0)
        {
            var children = Children.Cast<UIElement>().ToList();
            var weights = children.Select(GetWeight).ToList();
            var totalWeight = weights.Sum();
            if (totalWeight == 0)
            {
                //We perform same routine as in MeasureOverride
                totalWeight = children.Count;
                for (var i = 0; i < weights.Count; i++)
                    weights[i] = 1;
            }
            var offset = 0d;
            for (var i = 0; i < children.Count; i++)
            {
                var width = constraint.Width * weights[i] / totalWeight;
                children[i].Arrange(new Rect
                {
                    X = offset,
                    Width = width,
                    Height = constraint.Height,
                });
                offset += width;
                size.Width += children[i].RenderSize.Width;
                size.Height = Math.Max(size.Height, children[i].RenderSize.Height);
            }
        }
        return size;
    }

    public static readonly DependencyProperty WeightProperty =
        DependencyProperty.RegisterAttached(
            name: "Weight",
            propertyType: typeof(double),
            ownerType: typeof(ProportionalPanel),
            defaultMetadata: new FrameworkPropertyMetadata
            {
                AffectsParentArrange = true, //because it's set on children and is used
                                             //in parent panel's ArrageOverride method
                AffectsParentMeasure = true, //because it's set on children and is used
                                             //in parent panel's MeasuerOverride method
                DefaultValue = 1d,
            },
            validateValueCallback: ValidateWeight);

    private static bool ValidateWeight(object value)
    {
        //We want the value to be not less than 0 and finite
        return value is double d
            && d >= 0 //this excludes double.NaN and double.NegativeInfinity
            && !double.IsPositiveInfinity(d);
    }

    public static double GetWeight(UIElement d)
        => (double)d.GetValue(WeightProperty);

    public static void SetWeight(UIElement d, double value)
        => d.SetValue(WeightProperty, value);
}

用法如下:

<local:ProportionalPanel>
    <Label Content="Field A" local:ProportionalPanel.Weight="1" />
    <TextBox local:ProportionalPanel.Weight="2" />
    <Label Content="Field B" local:ProportionalPanel.Weight="1" />
    <TextBox local:ProportionalPanel.Weight="2" />
</local:ProportionalPanel>