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

10

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

期望结果

还将有其他需要缩放的内容,例如图片和按钮(不在同一网格中),从我目前阅读的资料来看,使用Viewbox是正确的方法。

然而,当我在Stretch="Uniform"的Viewbox中包装我的网格时,文本框控件会折叠到它们的最小宽度,如下所示: 带有网格和折叠文本框的Viewbox

如果我增加容器宽度,所有内容都会按预期缩放(好),但文本框仍然折叠到最小可能宽度(不好): 增加的宽度

如果我在文本框中键入任何内容,它们将增加其宽度以包含文本: 文本框展开以适合内容

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

现在,我已经查看了各种SO问题,其中这个问题最接近我想要的: How to automatically scale font size for a group of controls?

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

我尝试了各种东西 - 不同的HorizontalAlignment="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>

1
Viewbox 使用其排列大小和 Child.DesiredSize 来计算子元素的缩放比例。因此,在 Viewbox 下设置适当的固定宽度和高度到 Grid 将会非常有效。 - Alex.Wei
2个回答

6
这种行为的原因是,Viewbox的子元素被赋予了无限空间来测量其所需大小。将TextBox拉伸到无限的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>

更新

我在理解无限网格宽度的原始问题方面遇到了困难。

通常使用无限空间方法来确定UIElementDesiredSize。简而言之,您为控件提供所有可能需要的空间(没有约束),然后测量它以检索其所需的大小。Viewbox使用此方法来测量其子项,但是我们的Grid的大小是动态的(代码中没有设置HeightWidth),因此Viewbox会进一步查看网格的子项,以查看是否可以通过将组件的总和取出来来确定大小。

但是,当这些组件的总和超过总可用大小时,就会遇到问题,如下所示。

Invade

我用标签FooBar替换了文本框,并将它们的背景颜色设置为灰色。现在我们可以看到Bar正在侵入Body的领地,这显然不是我们想要发生的事情。
同样,问题的根源在于Viewbox不知道如何将无限分成6个相等的份额(以映射到列宽1*2*1*2*),因此我们需要做的就是恢复与网格宽度的链接。在ToWidthConverter中,目的是将TextBoxWidth映射到GridColumnWidth2*,因此我使用了gridWidth * 2/6。现在Viewbox能够再次解决方程:每个TextBox获得gridwidth的三分之一,每个Label获得其中的一半(1*2*)。
当你引入新列时,需要注意保持组件总和与可用宽度同步。换句话说,方程需要可解。换成数学术语,所需大小的总和(例如我们示例中未约束的控件的大小和作为gridWidth部分的转换大小,如文本框)必须小于或等于可用大小(例如我们示例中的gridWidth)。
我发现,如果您对TextBox使用转换大小,并让星号大小的ColumnWidth处理其他大多数情况,则缩放效果良好。请记住要在总可用大小范围内操作。
增加某些灵活性的一种方法是将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表示按钮宽度)。

Parts

最后,根据您所追求的可重用性,处理此问题的其他方法:

  • 在根Grid上设置固定大小。缺点:
    • 每次更改组件时都需要进行微调(以实现所需的水平/垂直/字体大小比例)
    • 这种比例可能会在不同的主题、Windows版本等情况下破坏。
  • 添加一个Behavior。如在链接的FontSize帖子中的某个答案中所做的那样,但是将其实现为将列宽度映射到gridWidth的部分。
  • 创建自定义面板,如@Grx70所建议的。

1
所以如果我添加超过几列额外的列,这段代码就会停止工作。如果我添加2列,网格将完全消失。如果我添加3列,就会出现异常。我猜需要调整“2/6”,但是我尝试过的所有方法都不起作用... - jhilgeman
此外,当我在调试器中观察网格和视口宽度时,网格宽度开始超过视口宽度,并且一直增加直到出现错误。 - jhilgeman
1
如果我在那里添加一个硬编码的限制,比如“if(gridWidth > 800) { gridWidth = 800; }”,那么它就可以工作了,或者如果我将祖先类型更改为Viewbox,那么它也可以部分工作,但是我很难理解无限网格宽度的原始问题。 - jhilgeman
@jhilgeman 你需要保持宽度的总和与可用的 gridWidth 同步。硬编码的值 (2/6) 只是一个概念验证,增加了一些解释和灵活性。 - Funk
@Funk:恕我直言,您是否注意到,您的解决方案会导致测量和排列多次重做,直到所有列的宽度都大于其“DesiredSize”?实际上,如果将您的示例代码放入525*350窗口中,则会调用204次转换器(表示67次测量和排列)。更改宽度比例或内容将影响重做次数,但这将在“InitializeComponent”上引起严重的性能问题。此外,每个列元素都应使用转换器,您的示例之所以显示出良好的结果,只是因为有幸的“Field A”和“Field B”。 - Alex.Wei
@Alex.Wei 解决这个方程需要一些步骤,但在这种情况下,舍入误差导致了大部分问题。我成功地使用 return (int)(gridWidth * percentage); 将访问次数从 201 -> 24。此外,转换器是可选的。您可以通过在 Button 上删除 Width 赋值来进行验证。 - Funk

1
你的方法存在问题,因为Grid的工作方式与我们的直觉不完全相同。换句话说,只有在满足以下条件时,星号大小才能按预期工作:仅当
  • 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>

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