Search code examples
avaloniauiavalonia

In Avalonia or Xaml in general, how would I implement the ability to use percentage based width or heights in controls


I find the grid control to be very messy, counter-intuitive, verboose, and breaking the idea of xml that position in the document is important to layout. I spent a lot of time programming in the Adobe Flex framework and found I was incredibly fast at UI development with that ability, and the UI is way easier to parse later on as well to update and maintain. With that in mind how do we bring the ability to make controls like stackpanel, and button that can tolerate percentage widths and heights?


Solution

  • Documenting this here so it might help someone. I came from Adobe Flex, and using percentage based widths and heights is a breeze and I find the grid control to be messy and ruins half of the point of using XML to define a UI by breaking the layout order and style and adds a lot of code for little value. Here is an example:

    <Window xmlns="https://github.com/avaloniaui"
            xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
            xmlns:s="clr-namespace:Sandbox.Spark"
            x:Class="Sandbox.MainWindow" Padding="5">
      <s:VGroup>
        <Border Background="LightBlue" CornerRadius="5" PercentHeight="30" PercentWidth="50">
          <Button Content="Test" HorizontalAlignment="Center"/>
        </Border>
        <Border Background="Green" CornerRadius="5" Height="200"  PercentWidth="75" Padding="5">
          <s:VGroup>
            <Button Content="Test5" PercentWidth="50"/>
            <Button Content="Test8"/>
          </s:VGroup>
        </Border>
        <Border Background="LightGray" CornerRadius="5" PercentHeight="100" PercentWidth="100">
          <s:HGroup>
            <Button Content="Test2"/>
            <Button Content="Test3"/>
          </s:HGroup>
        </Border>
      </s:VGroup>
    </Window>
    

    I Created the classes Group, VGroup, and HGroup, which are similar to StackPanel's but better suited to dealing with percentage based layout. Here they are:

    /// <summary>
    /// A Panel control similar to StackPanel but with greater support for PercentWidth and PercentHeight
    /// </summary>
    public class Group : Panel
    {
        public static readonly StyledProperty<Orientation> OrientationProperty = AvaloniaProperty.Register<Group, Orientation>(
            "Orientation", Orientation.Vertical);
        
        public Orientation Orientation
        {
            get => GetValue(OrientationProperty);
            set => SetValue(OrientationProperty, value);
        }
        
        public static readonly StyledProperty<double> GapProperty = AvaloniaProperty.Register<Group, double>(
            "Gap", 10);
        
        public double Gap
        {
            get => GetValue(GapProperty);
            set => SetValue(GapProperty, value);
        }
    
        protected override Size MeasureOverride(Size availableSize)
        {
            return GroupUtils.Measure(availableSize, Children, Orientation, Gap);
        }
        
        protected override Size ArrangeOverride(Size finalSize)
        {
            return GroupUtils.ArrangeGroup(finalSize, Children, Orientation, Gap);
        }    
    }
    
    public class VGroup : Panel
    {
        public static readonly StyledProperty<double> GapProperty = AvaloniaProperty.Register<Group, double>(
            "Gap", 10);
        
        public double Gap
        {
            get => GetValue(GapProperty);
            set => SetValue(GapProperty, value);
        }
        
        protected override Size MeasureOverride(Size availableSize)
        {
            return GroupUtils.Measure(availableSize, Children, Orientation.Vertical, Gap);
        }
        
        protected override Size ArrangeOverride(Size finalSize)
        {
            return GroupUtils.ArrangeGroup(finalSize, Children, Orientation.Vertical, Gap);
        }
    }
    
    public class HGroup : Panel
    {
        public static readonly StyledProperty<double> GapProperty = AvaloniaProperty.Register<Group, double>(
            "Gap", 10);
        
        public double Gap
        {
            get => GetValue(GapProperty);
            set => SetValue(GapProperty, value);
        }
        
        protected override Size MeasureOverride(Size availableSize)
        {
            return GroupUtils.Measure(availableSize, Children, Orientation.Horizontal, Gap);
        }
        
        protected override Size ArrangeOverride(Size finalSize)
        {
            return GroupUtils.ArrangeGroup(finalSize, Children, Orientation.Horizontal, Gap);
        }
    }
    
    
    public static class GroupUtils
    {
        public static Size Measure(Size availableSize, Controls children, Orientation orientation, double gap)
        {
             Size layoutSlotSize = availableSize;
            Size desiredSize = new Size();
            
            bool hasVisibleChild = false;
            
            //In order to handle percentwidth and percentheight scenario's we first have to measure all the children to determine their constrained measurement
            //then depending on the orientation we factor in the left over space available and split that up via the percentages and orientation
            //we use the measure with the true override to force the child to take our supplied size instead of it's default constrained size
            
            var percentHeightChildrenMap = new Dictionary<Layoutable, double>();
            var percentWidthChildrenMap = new Dictionary<Layoutable, double>();
            
            //loop through all children and determine constrained size and check if percent height is set
            for (int i = 0, count = children.Count; i < count; ++i)
            {
                // Get next child.
                var child = children[i];
                if (child == null) { continue; }
                bool isVisible = child.IsVisible;
                if (isVisible && !hasVisibleChild)
                {
                    hasVisibleChild = true;
                }
            
                if (!double.IsNaN(child.PercentHeight))
                {
                    percentHeightChildrenMap[child] = child.PercentHeight;
                }
                
                if (!double.IsNaN(child.PercentWidth))
                {
                    percentWidthChildrenMap[child] = child.PercentWidth;
                }
                
                
                // Measure the child.
                child.Measure(layoutSlotSize);
                var childDesiredSize = child.DesiredSize;
    
                if (orientation == Orientation.Vertical)
                {
                    //in vertical mode, our width is the max width of the children 
                    desiredSize = desiredSize.WithWidth(Math.Max(desiredSize.Width, childDesiredSize.Width));
                    //our height is the combine height of the children
                    desiredSize = desiredSize.WithHeight(desiredSize.Height + (isVisible ? gap : 0) + childDesiredSize.Height);    
                }
                else
                {
                    //in horizontal mode, our height is the max height of the children 
                    desiredSize = desiredSize.WithHeight(Math.Max(desiredSize.Height, childDesiredSize.Height));
                    //our height is the combine width of the children
                    desiredSize = desiredSize.WithWidth(desiredSize.Width + (isVisible ? gap : 0) + childDesiredSize.Width);   
                }
            }
    
            if (orientation == Orientation.Vertical)
            {
                //Handle percent width
                foreach (var child in children)
                {
                    if (!double.IsNaN(child.PercentWidth))
                    {
                        child.InvalidateMeasure();
                        child.Measure(child.DesiredSize.WithWidth(child.PercentWidth * 0.01 * availableSize.Width), true);
                        desiredSize = desiredSize.WithWidth(Math.Max(desiredSize.Width, child.DesiredSize.Width));
                    }
                }
                
                //if we have dont have a visible child then set to 0, otherwise remove the last added gap
                desiredSize = desiredSize.WithHeight(desiredSize.Height - (hasVisibleChild ? gap : 0));
                if (hasVisibleChild && percentHeightChildrenMap.Count > 0)
                {
                    //for those with percent height set, combine the percent heights together and if above 100, find the scale factor
                    var totalPercentHeight = percentHeightChildrenMap.Sum(v => v.Value);
                    totalPercentHeight = totalPercentHeight <= 0 ? 1 : totalPercentHeight;
                    var scaleRatio =  1 / (totalPercentHeight / 100);
                
                    //the available size leftover after the non-percent height children is now used to calculate the percentheight children sizes
                    var availableHeight = availableSize.Height - desiredSize.Height;
                    Debug.WriteLine($"Remapping %Height Children, availableHeight: {availableHeight}, scaleRatio: {scaleRatio}" );
                    foreach (var child in percentHeightChildrenMap.Keys)
                    {
                        var originalHeight = child.DesiredSize.Height;
                        var percentHeight = percentHeightChildrenMap[child];
                        var heightIncrease = availableHeight * percentHeight * scaleRatio * 0.01;
                        var recalculatedHeight = child.DesiredSize.Height + heightIncrease;
                        
                        child.InvalidateMeasure();
                        child.Measure(child.DesiredSize.WithHeight(recalculatedHeight), true);
                        desiredSize = desiredSize.WithHeight(desiredSize.Height + child.DesiredSize.Height - originalHeight);
                        
                        Debug.WriteLine($"$Found Child Height %:{percentHeight}, Original Height: {originalHeight}, New: {recalculatedHeight}" );
                    }
                }
            }
            else
            {
                //Handle percent height
                foreach (var child in children)
                {
                    if (!double.IsNaN(child.PercentHeight))
                    {
                        child.InvalidateMeasure();
                        child.Measure(child.DesiredSize.WithHeight(child.PercentHeight * 0.01 * availableSize.Height), true);
                        desiredSize = desiredSize.WithHeight(Math.Max(desiredSize.Height, child.DesiredSize.Height));
                    }
                }
                
                //if we have dont have a visible child then set to 0, otherwise remove the last added gap
                desiredSize = desiredSize.WithWidth(desiredSize.Width - (hasVisibleChild ? gap : 0));
                if (hasVisibleChild && percentWidthChildrenMap.Count > 0)
                {
                    //for those with percent Width set, combine the percent Widths together and if above 100, find the scale factor
                    var totalPercentWidth = percentWidthChildrenMap.Sum(v => v.Value);
                    totalPercentWidth = totalPercentWidth <= 0 ? 1 : totalPercentWidth;
                    var scaleRatio =  1 / (totalPercentWidth / 100);
                
                    //the available size leftover after the non-percent height children is now used to calculate the percentheight children sizes
                    var availableWidth = availableSize.Width - desiredSize.Width;
                    Debug.WriteLine($"Remapping %Width Children, availableWidth: {availableWidth}, scaleRatio: {scaleRatio}" );
                    foreach (var child in percentWidthChildrenMap.Keys)
                    {
                        var originalWidth = child.DesiredSize.Width;
                        var percentWidth = percentWidthChildrenMap[child];
                        var widthIncrease = availableWidth * percentWidth * scaleRatio * 0.01;
                        var recalculatedWidth = child.DesiredSize.Width + widthIncrease;
                        
                        child.InvalidateMeasure();
                        child.Measure(child.DesiredSize.WithWidth(recalculatedWidth), true);
                        desiredSize = desiredSize.WithWidth(desiredSize.Width + child.DesiredSize.Width - originalWidth);
                        
                        Debug.WriteLine($"$Found Child Width %:{percentWidth}, Original Width: {originalWidth}, New: {recalculatedWidth}" );
                    }
                }
            }
            
            return desiredSize;
        }
        
        public static Size ArrangeGroup(Size finalSize, Controls children, Orientation orientation, double gap)
        {
            bool fHorizontal = (orientation == Orientation.Horizontal);
            Rect rcChild = new Rect(finalSize);
            double previousChildSize = 0.0;
            var spacing = gap;
    
            //
            // Arrange and Position Children.
            //
            for (int i = 0, count = children.Count; i < count; ++i)
            {
                var child = children[i];
    
                if (child == null || !child.IsVisible)
                {
                    continue;
                }
    
                if (fHorizontal)
                {
                    rcChild = rcChild.WithX(rcChild.X + previousChildSize);
                    previousChildSize = child.DesiredSize.Width;
                    rcChild = rcChild.WithWidth(previousChildSize);
                    rcChild = rcChild.WithHeight(child.DesiredSize.Height);
                    previousChildSize += spacing;
                }
                else
                {
                    rcChild = rcChild.WithY(rcChild.Y + previousChildSize);
                    previousChildSize = child.DesiredSize.Height;
                    rcChild = rcChild.WithHeight(previousChildSize);
                    rcChild = rcChild.WithWidth(child.DesiredSize.Width);
                    previousChildSize += spacing;
                }
    
                child.Arrange(rcChild);
            }
    
            return finalSize;
        }
    }
    

    Finally I had to make a change in the avalonia source class Layoutable

    adding

        public static readonly StyledProperty<double> PercentWidthProperty = AvaloniaProperty.Register<Layoutable, double>(
                    "PercentWidth", Double.NaN);
        
                public static readonly StyledProperty<double> PercentHeightProperty = AvaloniaProperty.Register<Layoutable, double>(
                    "PercentHeight", Double.NaN);
    

    public double PercentHeight { get => GetValue(PercentHeightProperty); set => SetValue(PercentHeightProperty, value); }

        public double PercentWidth
        {
            get => GetValue(PercentWidthProperty);
            set => SetValue(PercentWidthProperty, value);
        }
    

    Registering the properties in the constructor for layoutable such as

    static Layoutable()
                {
                    AffectsMeasure<Layoutable>(
                        WidthProperty,
                        HeightProperty,
                        MinWidthProperty,
                        MaxWidthProperty,
                        MinHeightProperty,
                        MaxHeightProperty,
                        MarginProperty,
                        **PercentHeightProperty,
                        PercentWidthProperty,**
                        HorizontalAlignmentProperty,
                        VerticalAlignmentProperty);
                }
    

    and modifying the measure method to accept a boolean 2nd parameter that tells the measure to use all available space and then uses the percentage calculation:

     public void Measure(Size availableSize, bool useAvailable = false)
            {
                if (double.IsNaN(availableSize.Width) || double.IsNaN(availableSize.Height))
                {
                    throw new InvalidOperationException("Cannot call Measure using a size with NaN values.");
                }
    
                if (!IsMeasureValid || _previousMeasure != availableSize)
                {
                    var previousDesiredSize = DesiredSize;
                    var desiredSize = default(Size);
    
                    IsMeasureValid = true;
    
                    try
                    {
                        _measuring = true;
                        desiredSize = MeasureCore(availableSize);
                        
                        //used in percentwidth height layout system
                        if (useAvailable == true)
                        {
                            desiredSize = desiredSize.WithHeight(Math.Max(availableSize.Height, desiredSize.Height))
                                .WithWidth(Math.Max(availableSize.Width, desiredSize.Width));
                        }
                    }
                    finally
                    {
                        _measuring = false;
                    }
    
                    if (IsInvalidSize(desiredSize))
                    {
                        throw new InvalidOperationException("Invalid size returned for Measure.");
                    }
    
                    DesiredSize = desiredSize;
                    _previousMeasure = availableSize;
    
                    Logger.TryGet(LogEventLevel.Verbose, LogArea.Layout)?.Log(this, "Measure requested {DesiredSize}", DesiredSize);
    
                    if (DesiredSize != previousDesiredSize)
                    {
                        this.GetVisualParent<Layoutable>()?.ChildDesiredSizeChanged(this);
                    }
                }
            }
    

    enter image description here