Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using Viewbox to scale Grid containing Labels and Textboxes

So I'm trying to build out a form that will automatically scale proportionally up and down based on the available width of the parent container, and the same column percentage ratios, like this:

Desired result

There will be other surrounding content that needs to scale as well, like images and buttons (which will not be in the same grid), and from what I've read so far, using a Viewbox is the way to go.

However, when I wrap my grid in a Viewbox with Stretch="Uniform", the Textbox controls each collapse down to their minimum width, which looks like this: Viewbox with grid and collapsed textboxes

If I increase the container width, everything scales as expected (good), but the textboxes are still collapsed to their minimum-possible width (bad): Increased width

If I type any content into the Textboxes, they will increase their width to contain the text: Textboxes expand to fit content

...but I don't want that behavior - I want the Textbox element widths to be tied to the grid column widths, NOT to be dependent on the content.

Now, I've looked at a variety of SO questions, and this one comes closest to what I'm after: How to automatically scale font size for a group of controls?

...but it still didn't really deal with the textbox width behavior specifically (when it interacts with the Viewbox beahvior), which seems to be the primary problem.

I've tried a variety of things - different HorizontalAlignment="Stretch" settings and so on, but nothing has worked so far. Here is my 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>
like image 955
jhilgeman Avatar asked May 21 '18 22:05

jhilgeman


2 Answers

The problem with your approach is that Grid does not work exactly how we intuitively think it does. Namely, the star size works as expected only if these conditions are met:

  • The Grid has its horizontal alignment set to Stretch
  • The Grid is contained in a finite size container, i.e. its Measure method receives a constraint with finite Width (not double.PositiveInfinity)

This pertains to column sizing; row sizing is symmetrical. In your case the second condition is not met. I am not aware of any simple tricks to make Grid work as you expect, so my solution would be to create custom Panel that would do the job. That way you are in full control of how the controls are laid out. It's not really that hard to accomplish, although it requires some level of understanding how WPF layout system works.

Here's an example implementation that does your bidding. For the sake of brevity it only works horizontally, but it's not difficult to extend it to also work vertically.

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

And the usage looks like this:

<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>
like image 45
Grx70 Avatar answered Oct 15 '22 10:10

Grx70


The reason for this behavior is that a Viewbox child is given infinite space to measure its desired size. Stretching the TextBoxes to infinite Width wouldn't make much sense, as that couldn't be rendered anyway, so their default size is returned.

You can use a converter to achieve the desired effect.

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

You can hook everything up adding these resources.

<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>

UPDATE

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

The infinite space approach is often used to determine the DesiredSize of a UIElement. In short, you give the control all the space it could possibly need (no constraints) and then measure it to retrieve its desired size. Viewbox uses this approach to measure its child, but our Grid is dynamic in size (no Height or Width are set in code), so the Viewbox goes down another level at the grids children to see if it can determine a size by taking the sum of the components.

However, you can run in to problems when this sum of components exceeds the total available size, as shown below.

Invade

I replaced the textboxes with labels Foo and Bar and set their backgroundcolor to gray. Now we can see Bar is invading Body territory, this is clearly not something we meant to happen.

Again, the root of the problem comes from Viewbox not knowing how to divide infinity in to 6 equal shares (to map to columnwidths 1*, 2*, 1*,2*), so all we need to do is restore the link with the grids width. In ToWidthConverter the aim was to map the TextBox' Width to the Grids ColumnWidth of 2*, so I used gridWidth * 2/6. Now Viewbox is able to solve the equation again: each TextBox gets one third of gridwidth, and each Label one half of that (1* vs 2*).

Of course, when you scramble things up, by introducing new columns, you'll have to take care to keep the sum of the components in sync with the total available width. In other words, the equation needs to be solvable. Put in math, the sum of the desired sizes (of the controls you haven't constrained, labels in our example) and the converted sizes (as parts of gridWidth, textboxes in our example) needs to be less than or equal to the available size (gridWidth in our example).

I found the scaling to behave well if you use the converted sizes for TextBoxes, and let the star sized ColumnWidths handle most others. Keeping in mind to stay within the total available size.

One way to add some flexibility is to add a ConverterParameter to the mix.

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

An example that divides gridWidth over 10 equal shares, and assigns these shares to the components accordingly.

<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>

Note the shares for each control, grouped as 2 - 2 - 2 - 3 - 1 (with 1 for buttonwidth).

Parts

Finally, depending on the reusability you're after, some other ways to handle this:

  • Set fixed size(s) on your root Grid. Downsides:
    • Needs to be finetuned each time you change components (to achieve the desired horizontal / vertical / fontsize ratio)
    • This ratio might break on different themes, Windows versions,...
  • Add a Behavior. As done in one of the answers in your linked FontSize post, but instead implemented to map the column widths to parts of gridWidth.
  • Create a custom panel, as proposed by @Grx70.
like image 72
Funk Avatar answered Oct 15 '22 09:10

Funk