Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

FishEyePanel/FanPanel - Examples of custom layout panels in WPF

0.00/5 (No votes)
25 Sep 2006 1  
This article describes how to implement your own WPF layout panels like Grid and StackPanel.

FishEye demo

FishEye demo

Introduction

Recently, I needed to write some custom panels (like Grid and StackPanel, but with different arrangements). These turned out pretty cool and seemed like good examples of how to go about writing a custom panel, so I thought I'd share them. These were written as part of a Proof-Of-Concept rather than production quality code, so of course, in the code cleanup for submission, I ended up doing a re-write but that's normal!

There're two different panels - FishEyePanel and FanPanel. They were both designed by Martin Grayson who drew the ideas as wireframes - I then wrote the code.

The FishEyePanel is pretty complete, and implements a variation on the Hyperbar sample found in the Expression Interactive Designer samples, and also as featured on the MAC taskbar. The difference with this control is the interesting children growth, but the other children shrink to make room - unlike Hyperbar and MAC. This means the panel stays a constant width.

The FanPanel arranges its children into a stack which explodes out when you mouse over it. Then, when you click, it expands to a full view. The panel needs a bit more work as feeding it children that are not about 300 square results in the wrong effects.

Now just because I work for Microsoft doesn't mean that these samples are correct. I'm not in the product groups or anything, and don't have any insider information. I came up with the strategy of arranging the children on top of one another at (0,0) and then using RenderTransforms attached to the children to move them to where I wanted them. I don't know if this is a pukka thing to do, but it seems to work quite nicely.

Using the code

To compile the code, you need VS2005, .NET Framework 3.0 (RC1), and the Visual Studio "Orcas" extensions. If you don't have the extensions, VS won't recognise the project type.

I've included pre-built EXEs so you can play with just the RC1 .NET Framework. The code should just require re-compiling for later versions of the framework.

Writing a custom panel

To get your own custom panel off the ground, you need to derive from System.Windows.Controls.Panel and implement two overrides: MeasureOverride and LayoutOverride. These implement the two-pass layout system where during the Measure phase, you are called by your parent to see how much space you'd like. You normally ask your children how much space they would like, and then pass the result back to the parent. In the second pass, somebody decides on how big everything is going to be, and passes the final size down to your ArrangeOverride method where you tell the children their size and lay them out. Note that every time you do something that affects layout (e.g., resize the window), all this happens again with new sizes.

protected override Size MeasureOverride(Size availableSize)
{
    Size idealSize = new Size(0, 0);

    // Allow children as much room as they want - then scale them

    Size size = new Size(Double.PositiveInfinity, Double.PositiveInfinity);
    foreach (UIElement child in Children)
    {
        child.Measure(size);
        idealSize.Width += child.DesiredSize.Width;
        idealSize.Height = Math.Max(idealSize.Height, 
                           child.DesiredSize.Height);
    }

    // EID calls us with infinity, but framework

    // doesn't like us to return infinity

    if (double.IsInfinity(availableSize.Height) || 
        double.IsInfinity(availableSize.Width))
        return idealSize;
    else
        return availableSize;
}

In our MeasureOverride, we throw discipline to the wind and let our children have all the space they want. We then tell our parent our ideal size, which it ignores in our case. The children tell us what size they want to be, through the child.DesiredSize property that is set during the Measure call. We are going to scale our children to fit whatever size is imposed on us.

protected override Size ArrangeOverride(Size finalSize)
{
    if (this.Children == null || this.Children.Count == 0)
        return finalSize;

    ourSize = finalSize;
    totalWidth = 0;

    foreach (UIElement child in this.Children)
    {
        // If this is the first time

        // we've seen this child, add our transforms

        if (child.RenderTransform as TransformGroup == null)
        {
            child.RenderTransformOrigin = new Point(0, 0.5);
            TransformGroup group = new TransformGroup();
            child.RenderTransform = group;
            group.Children.Add(new ScaleTransform());
            group.Children.Add(new TranslateTransform());
//                    group.Children.Add(new RotateTransform());

        }

        child.Arrange(new Rect(0, 0, child.DesiredSize.Width, 
                      child.DesiredSize.Height));

        totalWidth += child.DesiredSize.Width;
    }

    AnimateAll();

    return finalSize;
}

In our ArrangeOverride, we add scale, and translate transforms to every child, and total up how wide they want to be. The panel works with different sized children, and you can either automatically scale them individually so they are the same width, or have them displayed at all different sizes. This is controlled by the ScaleToFit property. Note: we just pile up all the children at (0,0), and we will later use RenderTransforms to move them around.

The heart of the FishEyePanel is the following code:

// These next few lines took two of us hours to write!

double mag = Magnification;
double extra = 0;
if (theChild != null)
    extra += mag - 1;

if (prevChild == null)
    extra += ratio * (mag - 1);
else if (nextChild == null)
    extra += ((mag - 1 ) * (1 - ratio));
else
    extra += mag - 1;

double prevScale = this.Children.Count * (1 + ((mag - 1) * 
                  (1 - ratio))) / (this.Children.Count + extra);
double theScale = (mag * this.Children.Count) / 
                  (this.Children.Count + extra);
double nextScale = this.Children.Count * (1 + ((mag - 1) * ratio)) / 
                                      (this.Children.Count + extra);
double otherScale = this.Children.Count / 
                   (this.Children.Count + extra);
                   // Applied to all non-interesting children

This is some of the hardest code I've ever written, and took about half a day with two of us with pens and paper to figure out! I'm not going to explain the math - suffice it to say that it scales three children to be larger than the others, dependant on where the mouse is, and resizes all the others to take up the remaining space.

The heart of the FanPanel is the following code:

if (!IsWrapPanel)
{
    if (!this.IsMouseOver)
    {
        // Rotate all the children into a stack

        double r = 0;
        int sign = +1;
        foreach (UIElement child in this.Children)
        {
            if (foundNewChildren)
                child.SetValue(Panel.ZIndexProperty, 0);

            AnimateTo(child, r, 0, 0, scaleFactor);
            r += sign * 15;         // +-15 degree intervals

            if (Math.Abs(r) > 90)
            {
                r = 0;
                sign = -sign;
            }
        }
    }
    else
    {
        // On mouse over explode out the children and don't rotate them

        Random rand = new Random();
        foreach (UIElement child in this.Children)
        {
            child.SetValue(Panel.ZIndexProperty, 
                           rand.Next(this.Children.Count));
            double x = (rand.Next(16) - 8) * ourSize.Width / 32;
            double y = (rand.Next(16) - 8) * ourSize.Height / 32;
            AnimateTo(child, 0, x, y, scaleFactor);
        }
    }
}
else
{
    // Pretend to be a wrap panel

    double maxHeight = 0, x = 0, y = 0;
    foreach (UIElement child in this.Children)
    {
        if (child.DesiredSize.Height > maxHeight)
        // Row height

            maxHeight = child.DesiredSize.Height;
        if (x + child.DesiredSize.Width > this.ourSize.Width)
        {
            x = 0;
            y += maxHeight;
        }

        if (y > this.ourSize.Height - maxHeight)
            child.Visibility = Visibility.Hidden;
        else
            child.Visibility = Visibility.Visible;

        AnimateTo(child, 0, x, y, 1);
        x += child.DesiredSize.Width;
    }
}

Again, this scales/rotates/transforms all the children into their three possible arrangements: stacked up, exploded, or wrap panel style. Note that we animate between the different states so adding children looks pretty funky too (not shown in demo).

The FanPanel is not as clean as the FishEyePanel, as I wrote it first. Most of the grunge is, however, in the Favourites.xaml and Favourites.xaml.cs files, not in the control. I don't really like how you have to change the size of the panel when you expand it, and getting the two sets of animations to look right together was tricky. The effect looks pretty cool though, so I thought I'd submit it anyway. If you want to base your own panel on one of these controls, go with the FishEyePanel.

Points of interest

There are a few neat things which took a while to figure out, and are used in both samples.

If you need to get a reference to the panel used in an ItemsControl, it is not straightforward. You can't just give it a name, because it's in a template and that is not the real control. I eventually came up with the idea of hooking the Loaded event and casting the sender to the relevant Panel type.

The way I do the test data is pretty cool too. Martin Grayson came up with this method - take a look at TestData.xaml. It uses an XmlDataProvider. I found the XPath and the Binding statements hard to get right, but it's a great way to quickly dummy up some data when you are waiting for the middle-tier to deliver some real stuff.

<XmlDataProvider x:Key="Things" XPath="Things/Thing">
 <x:XData>
  <Things xmlns="">
    <Thing Image="Aquarium.jpg"/>
    <Thing Image="Ascent.jpg"/>
    <Thing Image="Autumn.jpg"/>
    <Thing Image="Crystal.jpg"/>
    <Thing Image="DaVinci.jpg"/>
    <Thing Image="Follow.jpg"/>
    <Thing Image="Friend.jpg"/>
    <Thing Image="Home.jpg"/>
    <Thing Image="Moon flower.jpg"/>
  </Things>
 </x:XData>
</XmlDataProvider>

Also note how the resources are referenced in the App.xaml so they are globally available.

Another really wonderful property is setting IsHitTestVisible = false. This means that for all mouse hit testing, it is invisible, and all the events go to the parent as if it wasn't there. This is especially useful when implementing drag and drop, if you are moving an item, so it tracks under the mouse. You can set this property and the mouse moves go to the underlying parent. That took two days of screwing around to discover!

Another cool thing is the ImagePathConverter. This allows you to specify relative paths to the images - code in the convertor hunts for the Images folder and remaps the references.

public class ImagePathConverter : IValueConverter
{
    #region IValueConverter Members

    private static string path;

    public object Convert(object value, Type targetType, 
                  object parameter, 
                  System.Globalization.CultureInfo culture)
    {
        if (path == null)
        {
            path = Path.GetDirectoryName(Path.GetDirectoryName(
                   Path.GetDirectoryName(
                   Assembly.GetExecutingAssembly().Location))) + 
                   "\\Images";
            if (!Directory.Exists(path))
            {
                path = Path.GetDirectoryName(
                       Assembly.GetExecutingAssembly().Location) + 
                       "\\Images";
                if (!Directory.Exists(path))
                    throw new FileNotFoundException("Can't " + 
                                  "find images folder", path);
            }
            path += "\\";
        }
        return string.Format("{0}{1}", path, (string)value);
    }

    public object ConvertBack(object value, Type targetType, 
           object parameter, System.Globalization.CultureInfo culture)
    {
        throw new Exception("The method or operation is not implemented.");
    }

    #endregion
}

One last point to note is, in the FishEyePanel, there is a difference between feeding it children that are the same width, and asking it to rescale them to the same width - note how on the pink house at the end of the top row, the margins are slightly larger than the others. This is because it started off as a smaller child and was scaled, margins and all. For best results, feed it children that are the same size.

Summary

Implementing your own panels can be a lot of fun, but the layout code can be really hard to write. You need to solve simultaneous equations and the like.

Please use this code as you like. You can even include it in a product which you sell if you want. I'm goaled on driving WPF adoption, which is why I publish sample code.

History

Fixed to handle different sized children - added ScaleToFit property.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here