FishEyePanel/FanPanel - WPF 自定义布局面板示例






4.84/5 (65投票s)
本文介绍了如何实现自己的WPF布局面板,类似于Grid和StackPanel。
引言
最近,我需要编写一些自定义面板(类似于Grid
和StackPanel
,但布局方式不同)。这些面板效果相当不错,似乎是关于如何编写自定义面板的好例子,因此我想与大家分享。这些面板是作为一个概念验证(Proof-Of-Concept)的一部分编写的,而不是生产质量的代码,所以,当然,在代码清理以提交时,我最终进行了重写,但这很正常!
有两个不同的面板 - FishEyePanel
和FanPanel
。它们都是由Martin Grayson设计的,他绘制了线框图 - 然后我编写了代码。
FishEyePanel
相当完整,它实现了一种变体,类似于Expression Interactive Designer示例中的Hyperbar示例,以及MAC任务栏上的功能。这个控件的区别在于子元素的有趣增长,而其他子元素则收缩以腾出空间 - 与Hyperbar和MAC不同。这意味着面板的宽度保持不变。
FanPanel
将它的子元素排列成一个堆栈,当鼠标悬停在上面时会展开。然后,当您单击时,它会展开到全屏视图。该面板需要更多的工作,因为将子元素的大小设置为大约300平方像素以外的值会导致效果不正确。
我为微软工作并不意味着这些示例是正确的。我不是产品组的成员,也没有任何内部信息。我采用了将子元素放置在 (0,0) 位置重叠的策略,然后使用附加到子元素的RenderTransforms
来将它们移动到我想要的位置。我不知道这样做是否是“正规”的做法,但它似乎效果很好。
使用代码
要编译代码,您需要VS2005、.NET Framework 3.0 (RC1)以及Visual Studio "Orcas" 扩展。如果您没有这些扩展,VS将无法识别项目类型。
我包含了预编译的EXE文件,因此您只需使用RC1 .NET Framework即可进行体验。代码只需重新编译即可用于后续版本的框架。
编写自定义面板
要让自己的自定义面板启动并运行,您需要从System.Windows.Controls.Panel
派生并实现两个重载:MeasureOverride
和LayoutOverride
。它们实现了两阶段布局系统,在Measure阶段,父容器会调用您来了解您需要多少空间。您通常会询问子元素它们需要多少空间,然后将结果返回给父容器。在第二阶段,有人会决定所有元素的大小,并将最终尺寸传递给您的ArrangeOverride
方法,您在该方法中告诉子元素它们的大小并进行布局。请注意,每次执行影响布局的操作时(例如,调整窗口大小),都会以新的尺寸重新执行所有这些操作。
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;
}
在我们的MeasureOverride
中,我们不顾一切,让我们的子元素拥有它们想要的所有空间。然后,我们将我们的理想尺寸告知父容器,在我们的例子中,父容器会忽略它。子元素通过在Measure调用期间设置的child.DesiredSize
属性告诉我们它们想要的大小。我们将根据施加给我们的任何尺寸来缩放我们的子元素。
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;
}
在我们的ArrangeOverride
中,我们向每个子元素添加缩放和平移变换,并计算它们总共想要的大小。该面板可以处理不同大小的子元素,您可以选择单独缩放它们使它们宽度相同,或者让它们以不同的尺寸显示。这由ScaleToFit
属性控制。注意:我们只是将所有子元素堆叠在 (0,0) 位置,稍后我们会使用RenderTransforms
来移动它们。
FishEyePanel
的核心代码如下:
// 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
这是我写过的最难的代码之一,我们两个人花了半天时间,拿着笔和纸才弄清楚!我不会解释数学原理 - 只需要知道它会根据鼠标的位置将三个子元素缩放得比其他元素大,并调整其他所有元素的大小以占据剩余的空间。
FanPanel
的核心代码如下:
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;
}
}
同样,这会缩放/旋转/变换所有子元素,使它们有三种可能的排列方式:堆叠、展开或流式面板风格。请注意,我们在不同状态之间进行动画处理,因此添加子元素看起来也很酷(演示中未显示)。
FanPanel
不如FishEyePanel
干净,因为我先写的它。不过,大部分的“脏活”都在Favourites.xaml
和Favourites.xaml.cs
文件中,而不是在控件本身。我不太喜欢展开时需要改变面板大小的方式,而且要让两组动画看起来协调一致很棘手。不过,效果看起来很酷,所以我还是决定提交它。如果您想在自己的面板基础上使用其中一个控件,请选择FishEyePanel
。
关注点
有两个很棒的功能,花了些时间才弄清楚,并且在两个示例中都使用了。
如果您需要获取ItemsControl
中使用的面板的引用,这并不容易。您不能仅仅给它命名,因为它在模板中,而那不是真正的控件。我最终想出了一个方法,即挂载Loaded
事件并将发送者强制转换为相关的Panel
类型。
我测试数据的方式也很酷。Martin Grayson想出了这个方法 - 看一下TestData.xaml
。它使用了一个XmlDataProvider
。我发现XPath
和Binding语句很难弄对,但这是一个在等待中层提供真实数据时快速模拟数据的绝佳方式。
<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>
另外,请注意如何在App.xaml
中引用资源,以便它们全局可用。
另一个非常出色的属性是设置IsHitTestVisible = false
。这意味着对于所有鼠标命中测试,它都是不可见的,所有事件都会传递给父容器,就好像它不存在一样。在实现拖放时,当您移动一个项目,使其跟随鼠标移动时,这尤其有用。您可以设置此属性,鼠标移动会传递给下面的父容器。这花了两天时间才发现!
另一个很酷的功能是ImagePathConverter
。它允许您指定图像的相对路径 - 转换器中的代码会查找“Images”文件夹并重新映射引用。
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
}
最后一点需要注意:在FishEyePanel
中,向其中填充宽度相同的子元素,与要求它将子元素缩放到相同宽度之间存在差异 - 请注意,在顶行末尾的粉色房子上,它的边距比其他房子稍大。这是因为它最初是一个较小的子元素,然后被缩放了,包括边距。为了获得最佳效果,请向其中填充相同大小的子元素。
摘要
实现自己的面板会很有趣,但布局代码可能真的很难编写。您需要解决联立方程之类的难题。
请随意使用此代码。如果您愿意,甚至可以将其包含在您销售的产品中。我的目标是推动WPF的应用,这也是我发布示例代码的原因。
历史
已修复,以处理不同大小的子元素 - 添加了ScaleToFit
属性。