views:

406

answers:

3

I'm trying to write a custom Panel class for WPF, by overriding MeasureOverride and ArrangeOverride but, while it's mostly working I'm experiencing one strange problem I can't explain.

In particular, after I call Arrange on my child items in ArrangeOverride after figuring out what their sizes should be, they aren't sizing to the size I give to them, and appear to be sizing to the size passed to their Measure method inside MeasureOverride.

Am I missing something in how this system is supposed to work? My understanding is that calling Measure simply causes the child to evaluate its DesiredSize based on the supplied availableSize, and shouldn't affect its actual final size.

Here is my full code (the Panel, btw, is intended to arrange children in the most space-efficient manner, giving less space to rows that don't need it and splitting remaining space up evenly among the rest--it currently only supports vertical orientation but I plan on adding horizontal once I get it working properly):

Edit: Thanks for the responses. I will examine them more closely in a moment. However let me clarify how my intended algorithm works since I didn't explain that.

First of all, the best way to think of what I'm doing is to imagine a Grid with each row set to *. This divides the space up evenly. However, in some cases the element in a row may not need all that space; if this is the case, I want to take any leftover space and give it to those rows that could use the space. If no rows need any extra space, I just try to space things evenly (that's what extraSpace is doing, it's only for that case).

I do this in two passes. The ultimate point of the first pass is to determine the final "normal size" of a row--i.e. the size of the rows that will be shrunk (given a size smaller than its desired size). I do this by stepping through smallest item to biggest and adjusting the calculated normal size at each step, by adding the leftover space from each small item to each subsequent larger item until no more items "fit" and then break.

In the next pass I use this normal value to determine if an item can fit or not, simply by taking the Min of the normal size with the item's desired size.

(I also changed the anonymous method to a lambda function for simplicity.)

Edit 2: My algorithm seems to work great at determining the proper size of the children. However, the children just aren't accepting their given size. I tried Goblin's suggested MeasureOverride by passing PositiveInfinity and returning Size(0,0), but this causes the children to draw themselves as though there are no space constraints at all. The part that's not obvious about this is that it's happening because of a call to Measure. Microsoft's documentation on this subject is not at all clear, as I've read over each class and property description several times. However, it's now clear that calling Measure does in fact affect the rendering of the child, so I will attempt to split the logic up among the two functions as BladeWise suggested.

Solved!! I got it working. As I suspected, I needed to call Measure() twice on each child (once to evaluate DesiredSize and a second to give each child its proper height). It seems odd to me that layout in WPF would be designed in such an odd way, where it's split up into two passes, but the Measure pass actually does two things: measures and sizes children and the Arrange pass does next to nothing besides actually physically position the children. Very bizarre.

I'll post the working code at the bottom.

First, the original (broken) code:

protected override Size MeasureOverride( Size availableSize ) {
    foreach ( UIElement child in Children )
        child.Measure( availableSize );

    return availableSize;
}

protected override System.Windows.Size ArrangeOverride( System.Windows.Size finalSize ) {
    double extraSpace = 0.0;
    var sortedChildren = Children.Cast<UIElement>().OrderBy<UIElement, double>( child=>child.DesiredSize.Height; );
    double remainingSpace = finalSize.Height;
    double normalSpace = 0.0;
    int remainingChildren = Children.Count;
    foreach ( UIElement child in sortedChildren ) {
        normalSpace = remainingSpace / remainingChildren;
        if ( child.DesiredSize.Height < normalSpace ) // if == there would be no point continuing as there would be no remaining space
            remainingSpace -= child.DesiredSize.Height;
        else {
            remainingSpace = 0;
            break;
        }
        remainingChildren--;
    }

    // this is only for cases where every child item fits (i.e. the above loop terminates normally):
    extraSpace = remainingSpace / Children.Count;
    double offset = 0.0;

    foreach ( UIElement child in Children ) {
        //child.Measure( new Size( finalSize.Width, normalSpace ) );
        double value = Math.Min( child.DesiredSize.Height, normalSpace ) + extraSpace;
            child.Arrange( new Rect( 0, offset, finalSize.Width, value ) );
        offset += value;
    }

    return finalSize;
}

And here's the working code:

double _normalSpace = 0.0;
double _extraSpace = 0.0;

protected override Size MeasureOverride( Size availableSize ) {
    // first pass to evaluate DesiredSize given available size:
    foreach ( UIElement child in Children )
        child.Measure( availableSize );

    // now determine the "normal" size:
    var sortedChildren = Children.Cast<UIElement>().OrderBy<UIElement, double>( child => child.DesiredSize.Height );
    double remainingSpace = availableSize.Height;
    int remainingChildren = Children.Count;
    foreach ( UIElement child in sortedChildren ) {
        _normalSpace = remainingSpace / remainingChildren;
        if ( child.DesiredSize.Height < _normalSpace ) // if == there would be no point continuing as there would be no remaining space
            remainingSpace -= child.DesiredSize.Height;
        else {
            remainingSpace = 0;
            break;
        }
        remainingChildren--;
    }
    // there will be extra space if every child fits and the above loop terminates normally:
    _extraSpace = remainingSpace / Children.Count; // divide the remaining space up evenly among all children

    // second pass to give each child its proper available size:
    foreach ( UIElement child in Children )
        child.Measure( new Size( availableSize.Width, _normalSpace ) );

    return availableSize;
}

protected override System.Windows.Size ArrangeOverride( System.Windows.Size finalSize ) {
    double offset = 0.0;

    foreach ( UIElement child in Children ) {
        double value = Math.Min( child.DesiredSize.Height, _normalSpace ) + _extraSpace;
        child.Arrange( new Rect( 0, offset, finalSize.Width, value ) );
        offset += value;
    }

    return finalSize;
}

It may not be super-efficient what with having to call Measure twice (and iterating Children three times), but it works. Any optimizations to the algorithm would be appreciated.

+1  A: 

I tried to simplify your code a bit:

public class CustomPanel:Panel
{
    protected override Size MeasureOverride(Size availableSize)
    {
        foreach (UIElement child in Children)
            child.Measure(new Size(double.PositiveInfinity,double.PositiveInfinity));

        return new Size(0,0);
    }

    protected override Size ArrangeOverride(Size finalSize)
    {
        double remainingSpace = Math.Max(0.0,finalSize.Height - Children.Cast<UIElement>().Sum(c => c.DesiredSize.Height));
        var extraSpace = remainingSpace / Children.Count;
        double offset = 0.0;

        foreach (UIElement child in Children)
        {
            double height = child.DesiredSize.Height + extraSpace;
            child.Arrange(new Rect(0, offset, finalSize.Width, height));
            offset += height;
        }

        return finalSize;
    }

}

A few notes:

  • You shouldn't return available size in MeasureOverride - it could be positive infinity which will cause an exception. And since you basically don't care what size it is, just return new Size(0,0).
  • As for your problem with the height of the children - I'm thinking it has to do with the actual children - are they limited in size somehow via Style or properties in regards to HorizontalAlignment?

EDIT: version 2.0:

    public class CustomPanel : Panel
{
    protected override Size MeasureOverride(Size availableSize)
    {
        foreach (UIElement child in Children)
            child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));

        return new Size(0, 0);
    }

    protected override Size ArrangeOverride(Size finalSize)
    {
        double optimumHeight = finalSize.Height / Children.Count;
        var smallElements = Children.Cast<UIElement>().Where(c => c.DesiredSize.Height < optimumHeight);
        double leftOverHeight = smallElements.Sum(c => optimumHeight - c.DesiredSize.Height);
        var extraSpaceForBigElements = leftOverHeight / (Children.Count - smallElements.Count());
        double offset = 0.0;

        foreach (UIElement child in Children)
        {
            double height = child.DesiredSize.Height < optimumHeight ? child.DesiredSize.Height : optimumHeight + extraSpaceForBigElements;
            child.Arrange(new Rect(0, offset, finalSize.Width, height));
            offset += height;
        }

        return finalSize;
    }

}
Goblin
Oh wait - I think I missed your intentions of the panel...How do you determine which elements get the left-over space? Right now you code makes them all a little higher than they desire to be.
Goblin
Goblin, your code only takes into account one case scenario, which is that all of the items fit naturally into the given space and have space leftover. This is only a fringe case in the intended use of the panel, which expects the total height of all elements to be larger than the height of the panel, but also expects some children to be smaller than 1/nth of the total space, and splits this "empty" space (that would occur in equally-sized rows) up among taller children.
chaiguy
+1 for helping out.
chaiguy
+2  A: 

Let's see if I got right how the Panel should work:

  • It should determine the desired size of every UIElement child
  • Depending on such sizes, it should determine if there is some available space
  • In case such space exists, every UIElement size should be adjusted so that the entire space is filled (i.e. every element size will be incremented by a portion of the remaining space)

If I get it right, your current implementation cannot accomplish this task, since you need to change the desired size of the children themselves, not only their the render size (which is done by the Measure and Arrange passes).

Keep in mind that the Measure pass is used to determine how much space an UIElement would require, given a size constraint (the availableSize passed to the method). In case of a Panel, it invokes a Measure pass on its children too, but does not set the desired size of its children (in other words, the size of the children is an input for the measure pass of the panel). As for the Arrange pass, it is used to determine the rectangle where the UI element will be finally rendered, whatever the measured size. In case of a Panel, it invokes an Arrange pass on its children too, but just like the measure pass it will not change the desired size of the children (it will just define their render space).

To achieve the required behaviour:

  • Split properly the logic between the Measure and Arrange pass (in your code, all the logic is in the Arrange pass, while the code used to determine how much space is required for each child should be placed in the measure pass)
  • Use a proper AttachedProperty (i.e. RequiredHeight) in place of the desired size of the children (you have no control on the child size unless it is set to Auto, so there is no need to take DesiredSize)

Since I'm not sure I have understood the purpose of the panel, I wrote an example:

a. Create a new Wpf solution (WpfApplication1) and add a new class file (CustomPanel.cs*)

b. Open the CustomPanel.cs file and paste this code

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Controls;
using System.Windows;

namespace WpfApplication1
{
 public class CustomPanel : Panel
 {

  /// <summary>
  /// RequiredHeight Attached Dependency Property
  /// </summary>
  public static readonly DependencyProperty RequiredHeightProperty = DependencyProperty.RegisterAttached("RequiredHeight", typeof(double), typeof(CustomPanel), new FrameworkPropertyMetadata((double)double.NaN, FrameworkPropertyMetadataOptions.AffectsMeasure, new PropertyChangedCallback(OnRequiredHeightPropertyChanged)));

  private static void OnRequiredHeightPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
  { 

  }

  public static double GetRequiredHeight(DependencyObject d)
  {
   return (double)d.GetValue(RequiredHeightProperty);
  }

  public static void SetRequiredHeight(DependencyObject d, double value)
  {
   d.SetValue(RequiredHeightProperty, value);
  }

  private double m_ExtraSpace = 0;

  private double m_NormalSpace = 0;

  protected override Size MeasureOverride(Size availableSize)
  {
   //Measure the children...
   foreach (UIElement child in Children)
    child.Measure(availableSize);

   //Sort them depending on their desired size...
   var sortedChildren = Children.Cast<UIElement>().OrderBy<UIElement, double>(new Func<UIElement, double>(delegate(UIElement child)
   {
    return GetRequiredHeight(child);
   }));

   //Compute remaining space...
   double remainingSpace = availableSize.Height;
   m_NormalSpace = 0.0;
   int remainingChildren = Children.Count;
   foreach (UIElement child in sortedChildren)
   {
    m_NormalSpace = remainingSpace / remainingChildren;
    double height = GetRequiredHeight(child);
    if (height < m_NormalSpace) // if == there would be no point continuing as there would be no remaining space
     remainingSpace -= height;
    else
    {
     remainingSpace = 0;
     break;
    }
    remainingChildren--;
   }

   //Dtermine the extra space to add to every child...
   m_ExtraSpace = remainingSpace / Children.Count;
   return Size.Empty;  //The panel should take all the available space...
  }

  protected override System.Windows.Size ArrangeOverride(System.Windows.Size finalSize)
  {
   double offset = 0.0;

   foreach (UIElement child in Children)
   {
    double height = GetRequiredHeight(child);
    double value = (double.IsNaN(height) ? m_NormalSpace : Math.Min(height, m_NormalSpace)) + m_ExtraSpace;
    child.Arrange(new Rect(0, offset, finalSize.Width, value));
    offset += value;
   }

   return finalSize;   //The final size is the available size...
  }
 }
}

c. Open the project MainWindow.xaml file and paste this code

<Window x:Class="WpfApplication1.MainWindow"
 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:local="clr-namespace:WpfApplication1"
 Title="MainWindow" Height="350" Width="525">
 <Grid>
        <local:CustomPanel>
            <Rectangle Fill="Blue" local:CustomPanel.RequiredHeight="22"/>
            <Rectangle Fill="Red" local:CustomPanel.RequiredHeight="70"/>
            <Rectangle Fill="Green" local:CustomPanel.RequiredHeight="10"/>
            <Rectangle Fill="Purple" local:CustomPanel.RequiredHeight="5"/>
            <Rectangle Fill="Yellow" local:CustomPanel.RequiredHeight="42"/>
            <Rectangle Fill="Orange" local:CustomPanel.RequiredHeight="41"/>
        </local:CustomPanel>
    </Grid>
</Window>
BladeWise
Thanks for the response--this is a step in the right direction I think. It seems to work well given the RequiredHeights. However, as the purpose of the panel is to *auto-size*, having to explicitly give each row a required height kind of defeats the purpose. That said, I think it's still possible to auto-size the panel, I may have to call Measure() twice on each child to do it though (first to evaluate the desired height, and second to actually "tell" it what height it should be, since apparently this is necessary). Let me test that theory out...
chaiguy
I'll give you the answer as this response helped me the most to finding the solution!
chaiguy
Thanks, I understand what you want to achieve, but I fear it is quite tricky, since if a child size is set as 'Auto', the desired size will be (most likely) the same as the constraint you are passing to the Measure method. This means that, while the auto-sizing can work for objects whose size is fixed, the behaviour with auto-sized obects will not be consistent.Have you considered the possibility to use the RequiredHeight as in the example, and bind it to some property (DesiredSize?) through a converter? This way you could keep a neat panel implementation, while trying to achieve auto-sizing.
BladeWise
A: 

Chaiguy: You are working in WPF. I'm trying to do something similar in Silverlight, but when I try to call Measure twice, I'm not always tripping the breakpoints in the Measure functions of my nested children panels unless I call child.InvalidateMeasure() before I make the second Measure call. Clearly you're not having to do that in WPF. I'm curious if in your investigation you turned up any documentation that says, "Yes, this is a difference between Silverlight and WPF" that you might point me toward.

DMC
Sorry I never ran into any such thing, and I haven't explored Silverlight to offer any intuition. To be honest, I don't really understand why calling Measure twice is required, and for all I know it may be possible to do it without, but I haven't found any.
chaiguy