WPF 3D Tab Carousel






4.99/5 (105投票s)
如何使用WPF创建一个3D标签轮播。
引言
本文讨论如何为WPF创建一个3D标签控件。它将介绍3D旋转和摄像机计算,以及如何在一个较小的UI可视化控件集合中维护大量的控件。尽管本文大部分代码与创建3D网格有关,但我不会详细介绍,而是尽量将文章重点放在与本文更相关的其他方面。
这里有一个YouTube视频展示了部分已实现的功能;
http://www.youtube.com/watch?v=zmDHfsRENug&feature=plcp
背景
这篇文章源于我为Sacha定制的一个解决方案。我最初粗略地做了一个简单的原型,Sacha希望它能做一些其他事情(比如滑动窗口,如下文所述),而我认为有些事情会很酷。最终,它变成了一个相当不错的控件。
使用代码
非常简单,下载源代码项目并构建它,Bornander.UI.TabCarousel项目包含一个名为Carousel
的用户控件,它几乎处理了所有事情。
为方便演示,MainWindow.xaml.cs文件在其构造函数中有两个部分,可以一次使用一个来测试控件的不同方面。
要求
在实现此控件时,我最初有一套需求
- 控件必须在
FrameworkElement
集合上工作,以便几乎任何UI元素都可以用作标签页。 - 不同的
FrameworkElement
应该显示在放置在虚拟轮播中的3D面板上。 - 必须通过下一个、上一个和转到特定索引来在面板之间导航。
- 3D面板的数量可以少于实际的
FrameworkElement
(这是Sacha的要求之一,结果却是个麻烦事)。 - 从一个标签页到另一个标签页的过渡必须通过动画实现。
- 摄像机必须尝试在3D空间中定位自身,以尽可能地保持
FrameworkElement
的所需大小。
实现
概述
解决方案分为三个项目
- Bornander.UI.TabCarousel,这是包含实际用户控件的项目。
- Bornander.UI.TabCarousel.Test,这只是一个展示用户控件的测试项目。
- Bornander.Wpf.Meshes,这是我正在开发的一个大型项目的提取,旨在简化WPF中的3D。
解决需求
使用FrameworkElement
这听起来很简单。我实现了一个名为Tab
的类来封装标签页,它有一个名为Element
的属性,可以用来将任何FrameworkElement
设置为该Tab
的视觉元素。
public FrameworkElement Element
{
get { return element; }
set
{
element = value;
front.Visual = element;
}
}
front
私有成员是一个Viewport2DVisual3D
。
创建3D面板
我希望标签页是块状的,正面显示FrameworkElement
;这很容易通过Viewport2DVisual3D
类实现,但由于我也希望块有深度,所以我必须创建两个网格,每个网格都有自己的材质。
首先,我创建了一个无盖的盒子,全部在一个网格中,并使用简单的DiffuseMaterial
。
这是通过从Bornander.Wpf.Meshes
创建Box
来实现的,并指定除了正面之外的所有面都应包含在内。
boxMesh = Box.CreateBoxMesh(1, 1, depth,
Box.Side.Right |
Box.Side.Left |
Box.Side.Top |
Box.Side.Bottom |
Box.Side.Back);
请注意,盒子的宽度和高度设置为1.0,这是因为正确的宽高比(即UI元素设计时的比例)在分配FrameworkElement
之前不会计算,然后会计算一个缩放变换来实现这一点。
然后,盒子“盖子”以相同的方式创建,但这次只包含正面。
visualHostMaterial = new DiffuseMaterial(Brushes.White);
visualHostMaterial.SetValue(
Viewport2DVisual3D.IsVisualHostMaterialProperty, true);
visualMesh = Box.CreateBoxMesh(1, 1, depth, Box.Side.Front);
front = new Viewport2DVisual3D
{
Geometry = visualMesh,
Visual = element,
Material = visualHostMaterial
};
视觉宿主材质用于将UIElement
显示为3D表面上的交互式材质。然后,这两个网格被添加到ModelVisual3D
类型的模型中;这样,每当我需要移动、旋转或缩放网格时,我都可以简单地将变换应用于该网格组,而无需单独为每个网格进行操作。
整个Tab
类如下所示
class Tab
{
private readonly Material visualHostMaterial;
private readonly MeshGeometry3D boxMesh;
private readonly MeshGeometry3D visualMesh;
private Viewport2DVisual3D front;
private ModelVisual3D back;
private FrameworkElement element;
private double depth;
public ModelVisual3D Model { get; private set; }
public Tab(FrameworkElement element, Color color, double depth)
{
this.element = element;
this.depth = depth;
visualHostMaterial = new DiffuseMaterial(Brushes.White);
visualHostMaterial.SetValue(
Viewport2DVisual3D.IsVisualHostMaterialProperty, true);
boxMesh = Box.CreateBoxMesh(1, 1, depth,
Box.Side.Right |
Box.Side.Left |
Box.Side.Top |
Box.Side.Bottom |
Box.Side.Back);
visualMesh = Box.CreateBoxMesh(1, 1, depth, Box.Side.Front);
front = new Viewport2DVisual3D
{
Geometry = visualMesh,
Visual = element,
Material = visualHostMaterial
};
back = new ModelVisual3D
{
Content = new GeometryModel3D
{
Geometry = boxMesh,
Material = new DiffuseMaterial(Brushes.CadetBlue),
}
};
Model = new ModelVisual3D();
Model.Children.Add(back);
Model.Children.Add(front);
}
public void UpdateTransform(int index, double angle, double radius)
{
TranslateTransform3D translaslation = new TranslateTransform3D(
0, 0, radius - depth / 2.0);
RotateTransform3D rotation = new RotateTransform3D(
new AxisAngleRotation3D(new Vector3D(0, 1, 0), -index * angle));
ScaleTransform3D scale = element != null ?
new ScaleTransform3D(1.0, double.IsNaN(element.Height)
? 1.0 :
element.Height / element.Width, 1.0)
: new ScaleTransform3D(1, 1, 1);
Transform3DGroup transform = new Transform3DGroup();
transform.Children.Add(scale);
transform.Children.Add(translaslation);
transform.Children.Add(rotation);
Model.Transform = transform;
}
public FrameworkElement Element
{
get { return element; }
set
{
element = value;
front.Visual = element;
}
}
}
允许动画导航
为了将Tab
放置在“轮播”中,必须计算几个事项:不同3D面板之间的角度、面板的特定位置和半径,以及从假想中心到面板中心的距离。所有这些事项都是动态的,并且随着面板数量的变化而变化。
第一项,角度很容易;只需将360度除以标签面板的数量;这意味着如果有三个面板,它们应该相隔120度。第二项,一个标签的特定角度是使用索引计算的;Carousel
用户控件维护一个IList<Tab>
,并且角度是使用此列表中的索引计算的。Tab
类可以自行计算,这正是UpdateTransform
方法所做的。它基于角度和索引(只需将角度乘以索引)创建一个旋转变换,该变换将面板旋转到轮播中的正确位置。最后一部分是半径;随着面板数量的增加,半径需要越来越大,以防止它们重叠。由于需要知道面板的数量,因此必须由Carousel
计算。
private static double DegreesToRadians(double degrees)
{
return (degrees / 180.0) * Math.PI;
}
private double CalculateRadius()
{
double splitAngle = 360.0 / tabs.Count;
switch (tabs.Count)
{
case 1: return 0.0;
case 2: return 0.25;
default:
return 1.0 / Math.Abs(Math.Sin(DegreesToRadians(splitAngle)));
}
}
由于所有面板的宽度都是1.0(这从不改变;无论宽高比如何,我只修改高度),我将半径计算为1.0 / sin(面板之间的角度)。这不是最佳距离(即,不是不重叠的最小可能距离),但它保证大于该距离,而且我认为它会产生一个合适的距离。
为了实际从一个面板旋转到另一个面板,我不得不进行许多奇怪的计算(主要是由于Sacha不合理的需求,如滑动窗口和循环集合);代码量不多,但仍然相当令人困惑。Sacha想要一个转到功能,允许用户直接从一个标签页跳转到另一个标签页,这很容易实现,但他希望它永远不用旋转超过一步。也就是说,在标准设置下,从标签1跳转到4会经过2和3,然后到达4,但Sacha希望直接跳转到4。如果问我的话,这是完全不合理的。
下面是处理此问题的代码,但首先,值得注意的是,我通过排队SpinInstruction
来请求旋转,这些指令告诉Animate
方法从哪里到哪里。
private class SpinInstruction
{
public int From { get; private set; }
public int To { get; private set; }
public SpinInstruction(int from, int to)
{
From = from;
To = to;
}
}
在标准设置下,每当用户请求多步旋转时,它就会被排队,成为构成该旋转的所有步骤。
private void Animate()
{
// If no instructions are queue up
// or if we're already animating, ignore request
if (instructions.Count == 0 || isAnimating)
return;
// Grab the next spin instruction
SpinInstruction instruction = instructions.Peek();
bool wrapIt = false;
// If the spin To target is outside the elements list,
// this is going to be a wrapping sping
if (instruction.To < 0 || instruction.To >= elements.Count)
{
// If WrapAtEnd is enabled and if the instruction
// target is a valid one accept it
if (WrapAtEnd && (instruction.To == -1 ||
instruction.To == elements.Count))
{
// Set wrapIt to true to indicate that this
// is a wrapping spin and then adjust the instruction to
// fit the standard logic
wrapIt = true;
instruction = new SpinInstruction(
instruction.From,
instruction.To < 0 ? elements.Count - 1 : 0);
}
else // Done animating for now, remove instruction and return
{
instructions.Dequeue();
isAnimating = false;
return;
}
}
// Angle between panels
double angle = 360.0 / tabs.Count;
// Figure out the target index in the tabs list
int tabToIndex = AlwaysOnlyOneStep ?
GetSafeIndex(currentTabIndex +
Math.Sign(instruction.To - instruction.From))
: GetSafeIndex(instruction.To);
// If this is a wrapping spin, the tabToIndex can
// be set to either the first or last index
if (wrapIt)
{
if (instruction.To == 0)
tabToIndex = 0;
if (instruction.To == elements.Count - 1)
tabToIndex = tabs.Count - 1;
}
// Unhook from visual tree if required because
// a Visual cannot have to parents
foreach (Tab owner in (from tab in tabs
where tab.Element == elements[instruction.To]
|| tab.Element == elements[instruction.From] select tab))
owner.Element = null;
// Make sure the current tab contains the From element
tabs[currentTabIndex].Element = elements[instruction.From];
tabs[currentTabIndex].UpdateTransform(currentTabIndex,
angle, CalculateRadius());
// Make sure the target tab contains the To element,
// this is what allows less tab panels than elements
tabs[tabToIndex].Element = elements[instruction.To];
tabs[tabToIndex].UpdateTransform(tabToIndex, angle, CalculateRadius());
isAnimating = true;
// The angles of the carousel for the from and to tabs
double fromAngle = currentTabIndex * angle;
double toAngle = tabToIndex * angle;
// If this is a wrapping spin add/remove
// a full lap otherwise the animation
// would run backwards for these cases
if (wrapIt)
{
if (instruction.To == 0)
toAngle += 360;
if (instruction.To == elements.Count - 1)
toAngle -= 360;
}
// If this is spinning to a later element,
// but the tab index is less than the current tab index, add a lap
if (instruction.To - instruction.From > 0 &&
tabToIndex < currentTabIndex)
toAngle += 360;
// If this is spinning to a earlier element,
// but the tab index is greater than the
// current tab index, subtract a lap
if (instruction.To - instruction.From < 0 &&
tabToIndex > currentTabIndex)
toAngle -= 360;
CreateSpinAnimation(instruction, tabToIndex, fromAngle, toAngle);
}
CreateSpinAnimation
负责创建实际的动画,并在旋转动画完成后再次调用Animate
。
计算摄像机距离
在上面的代码中,摄像机距离是逐个标签计算的。这是因为虽然标签本身会缩放到正确的宽高比,但屏幕上的大小也是一个问题。例如,如果一个用户控件被设计为在300x400的尺寸显示,那么创建一个300宽、400高的3D盒子是不够的,因为一组单位(第一组)是像素,而第二组是无单位的。它只是3D的距离,而不是像素。因此,Carousel
必须计算摄像机必须达到的与面板的距离,以便正确渲染UI元素。这还取决于包含所有元素的Viewport3D
的大小。
基本上,它看起来是这样的
用数学术语来说:求解距离y,其中y是由y本身、0.5(3D面板宽度的一半)构成的直角三角形的一条边,斜边由摄像机的视场(或半视场)延伸形成。由于我们不知道斜边的长度,但可以确定角度(因为它是视场的一半),所以我们可以使用tan(field of view / 2.0),或者用代码表示
private double CalculateCameraDistance(int index, int tabIndex)
{
Tab tab = tabs[tabIndex];
double y = 0.5 / Math.Tan(DegreesToRadians(MainCamera.FieldOfView / 2.0));
double panelWidth = tab.Element != null ? tab.Element.Width : 1.0;
double ratio = Grid3D.ActualWidth / panelWidth;
return CalculateRadius() + Math.Max(ratio, 1.0) * y;
}
找到y后,将其乘以设计UI元素宽度与Viewport3D
当前宽度之间的比率,以补偿Viewport3D
的大小。最后,将其偏移到轮播半径的距离。通过取1.0和计算出的比率的最大值,Math.Max(ratio, 1.0)
,距离将确保面板的整个宽度始终可见,即使Viewport3D
小于面板的设计尺寸。
由于大多数WPF用户控件设计用于在窗口或其他控件中使用,因此其宽度和高度并不总是可以确定(因此需要WPF UI元素上同时存在的Width
和ActualWidth
属性)。为了让用户控件与此标签控件配合使用,因此在设计时设置MinWidth
、MaxWidth
和Width
很重要。
用户控件
实现轮播的WPF用户控件称为Carousel
,很直观,不是吗?而且,由于此控件主要涉及旋转和摄像机位置计算,因此其XAML非常简单
<UserControl x:Class="Bornander.UI.TabCarousel.Carousel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
SizeChanged="HandleSizeChanged">
<Grid x:Name="Grid3D" Width="Auto" Height="Auto">
<Viewport3D>
<Viewport3D.Camera>
<PerspectiveCamera x:Name="MainCamera"
FieldOfView="90"
Position="0,0,0"
LookDirection="0,0,-1"/>
</Viewport3D.Camera>
<ModelVisual3D>
<ModelVisual3D.Content>
<AmbientLight x:Name="Ambient" Color="#808080"/>
</ModelVisual3D.Content>
</ModelVisual3D>
<ModelVisual3D>
<ModelVisual3D.Content>
<DirectionalLight x:Name="Directional"
Color="#FFFFFFFF" Direction="0,-1,-1"/>
</ModelVisual3D.Content>
</ModelVisual3D>
<ModelVisual3D x:Name="CarouselContainer"/>
</Viewport3D>
</Grid>
</UserControl>
用户控件设置了一些东西
- 摄像机;摄像机的位置必须是
(0, 0, 0)
才能正确进行距离计算;此外,视线方向必须沿着Z轴。 - 环境光,这样不仅仅是被定向光照射到的表面是可见的。
- 定向光;这很重要,因为没有它场景会显得“平坦”。
CarouselContainer
:这只是用于容纳轮播中所有项目的ModelVisual3D
;当轮播旋转时,这实际上是正在旋转的对象。
关注点
我也可以在XAML中定义网格,但我发现使用代码更简单、更灵活。最复杂的部分是正确处理循环旋转,尤其是在标签数量少于轮播中的元素数量时。这是因为旋转动画的工作方式,从270度到360度的动画与从270度到0度的动画不同,这在某种程度上是有道理的,但仍然让我头疼,因为360度和0度实际上是相同的。
一如既往,任何关于代码或文章的评论都非常欢迎。
历史
- 2010-01-01:第一个版本。