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);
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);
}
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 (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());
}
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:
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);
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)
{
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;
if (Math.Abs(r) > 90)
{
r = 0;
sign = -sign;
}
}
}
else
{
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
{
double maxHeight = 0, x = 0, y = 0;
foreach (UIElement child in this.Children)
{
if (child.DesiredSize.Height > maxHeight)
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.