Introduction
This article discusses how to create a 3D tab control for WPF. It will go through 3D rotation and camera calculations as well as show how to maintain a large set of controls in a smaller set of UI visualisers. Although a fair portion of the code for this article relates to the creation of 3D meshes, I'll not go into details around that, but rather try to focus the article around other areas more specific to this article.
There's a YouTube video displaying some of the implemented features available here;
http://www.youtube.com/watch?v=zmDHfsRENug&feature=plcp
Background
This article has grown from a made-to-order solution I've implemented for Sacha. I initially knocked up a simple prototype, and Sacha wanted it to do some other things (like sliding window for example, discussed below) while I thought other things would be cool. In the end, it turned out to be a pretty neat control.
Using the code
Dead simple, download the source project and build it, the Bornander.UI.TabCarousel project contains a user control called Carousel
that takes care of just about everything.
For example purposes, the file MainWindow.xaml.cs has two sections in its constructor that are to be used one at a time to test different aspects of the control.
Requirements
When implementing this control, I started out with a set of requirements:
- The control must work on a set of
FrameworkElement
s so that pretty much any UI element can be used as a tab page. - The different
FrameworkElement
s should be displayed on 3D panels placed in a virtual carousel. - Navigation from one panel to another must be available through Next, Previous, and Go to specific index.
- The number of 3D panels must be allowed to be less than the actual
FrameworkElement
s (this was one of Sacha's requirements and it turned out to be a ball ache). - Transition from one tab page to another must be via animation.
- The camera must attempt to position itself in 3D space in a such way as a best effort of maintaining the desired size of the
FrameworkElement
.
Implementation
Overview
The solution is split into three projects:
- Bornander.UI.TabCarousel, this is the project containing the actual user control.
- Bornander.UI.TabCarousel.Test, this is just a test project showcasing the user control.
- Bornander.Wpf.Meshes, this is an extract of a larger project I'm working on, designed to simplify 3D using WPF.
Tackling the requirements
Use FrameworkElements
This is as simple as it sounds. I implemented a class called Tab
to encapsulate the tab page, and this has a property called Element
which can be used to set any FrameworkElement
as the visual for that Tab
.
public FrameworkElement Element
{
get { return element; }
set
{
element = value;
front.Visual = element;
}
}
The front
private member is a Viewport2DVisual3D
.
Creating 3D panels
I wanted the tab pages to be blocks where the front side holds the FrameworkElement
; this is easy to achieve using the Viewport2DVisual3D
class, but as I also wanted the block to have depth, I had to create two meshes, each mesh with its own material.
First, I created a lid-less box, all in one mesh and using a simple DiffuseMaterial
.
This is done by creating a Box
from Bornander.Wpf.Meshes
, specifying that all sides except the front should be included:
boxMesh = Box.CreateBoxMesh(1, 1, depth,
Box.Side.Right |
Box.Side.Left |
Box.Side.Top |
Box.Side.Bottom |
Box.Side.Back);
Note that the width and height of the box is set to 1.0, that's because the correct aspect ratio (that is, the ratio that the UI element was designed with) is not actually calculated until the assignment of a FrameworkElement
, and then a scale transform is calculated to achieve this.
The "lid" of the box is then created the same way, but this time, only the front is included:
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
};
The visual host material is required to display a UIElement
as an interactive material on a 3D surface. These two meshes are then added into a model of type ModelVisual3D
; that way, whenever I need to move, rotate, or scale the meshes, I can simply apply the transformations to that group of meshes and not have to do it separately for each mesh.
The whole Tab
class looks like this:
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;
}
}
}
Allowing animated navigation
In order to place the Tab
s in a "carousel", several things have to be calculated: the angle between the different 3D panels, the specific location for a panel and the radius, and the distance from an imaginary center to the center of the panel. All these things are dynamic, and change as the number of panels change.
The first thing, the angle is easy; simply divide 360 degrees by the number of tab panels; that means that if there are three panels, they should be separated by 120 degrees each. The second thing, the specific angle for one tab is calculated using an index; the Carousel
user control keeps a IList<Tab>
and the angle is calculated using the index in this list. The Tab
classes can calculate this themselves, and that's what the UpdateTransform
method above does. It creates a rotation transform based on the angle and the index (simply multiply the angle by the index), and that transform rotates the panel to the correct slot on the carousel. The last bit is the radius; this needs to get larger and larger as the number of panels increase so that they won't overlap. As one needs to know the number of panels, this has to be calculated by the 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)));
}
}
Since all panels are 1.0 wide (this never changes; regardless of the aspect ratio, I only modify the height), I calculate the radius as 1.0 / sin(angle between panels). This isn't the optimal distance (i.e., not the smallest distance possible without overlapping), but it's guaranteed to be larger than that, plus, I think it generates a suitable distance.
In order to actually rotate from one panel to the other, I had to come up with a lot of weird calculations (mostly due to Sacha's unreasonable requirements of sliding windows and wrapping collections); it's not that much code, but it's still fairly confusing. Sacha wanted a go-to function, allowing the user to directly jump from one tab page to another, something which is easy enough to implement, but he wanted it so that it never had to rotate more than one step. That is, in the standard setting, jumping from tab 1 to 4 will rotate past 2 and 3 before getting to 4, but Sacha wanted this to directly find 4. Completely unreasonable, if you ask me.
Below is the code that handles this, but first, it's worth noting that I request rotations by queuing up SpinInstruction
s that tell the Animate
method from where and where to go.
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;
}
}
In the standard setting, whenever a multi-step rotation is requested by the user, it's queued up as all the steps making up that rotation.
private void Animate()
{
if (instructions.Count == 0 || isAnimating)
return;
SpinInstruction instruction = instructions.Peek();
bool wrapIt = false;
if (instruction.To < 0 || instruction.To >= elements.Count)
{
if (WrapAtEnd && (instruction.To == -1 ||
instruction.To == elements.Count))
{
wrapIt = true;
instruction = new SpinInstruction(
instruction.From,
instruction.To < 0 ? elements.Count - 1 : 0);
}
else
{
instructions.Dequeue();
isAnimating = false;
return;
}
}
double angle = 360.0 / tabs.Count;
int tabToIndex = AlwaysOnlyOneStep ?
GetSafeIndex(currentTabIndex +
Math.Sign(instruction.To - instruction.From))
: GetSafeIndex(instruction.To);
if (wrapIt)
{
if (instruction.To == 0)
tabToIndex = 0;
if (instruction.To == elements.Count - 1)
tabToIndex = tabs.Count - 1;
}
foreach (Tab owner in (from tab in tabs
where tab.Element == elements[instruction.To]
|| tab.Element == elements[instruction.From] select tab))
owner.Element = null;
tabs[currentTabIndex].Element = elements[instruction.From];
tabs[currentTabIndex].UpdateTransform(currentTabIndex,
angle, CalculateRadius());
tabs[tabToIndex].Element = elements[instruction.To];
tabs[tabToIndex].UpdateTransform(tabToIndex, angle, CalculateRadius());
isAnimating = true;
double fromAngle = currentTabIndex * angle;
double toAngle = tabToIndex * angle;
if (wrapIt)
{
if (instruction.To == 0)
toAngle += 360;
if (instruction.To == elements.Count - 1)
toAngle -= 360;
}
if (instruction.To - instruction.From > 0 &&
tabToIndex < currentTabIndex)
toAngle += 360;
if (instruction.To - instruction.From < 0 &&
tabToIndex > currentTabIndex)
toAngle -= 360;
CreateSpinAnimation(instruction, tabToIndex, fromAngle, toAngle);
}
The CreateSpinAnimation
is responsible for creating the actual animations and calling Animate
again when the spin animation has completed.
Mid-rotation with the FlipIt flag set to true.
Calculating camera distance
In the code above, the camera distance is calculated on a tab-per-tab basis. This is because although the tabs themselves will scale to the correct aspect ratio, there's also the issue with size on screen. If, for example, a user control was designed to be displayed in 300x400, it's not enough to create a 3D box 300 wide and 400 tall, because one set of units (the first) are in pixels and the second is unit less. It's just distance in 3D, not pixels. Therefore, the Carousel
has to calculate the distance from the panel that the camera has to be at in order for the UI element to be rendered correctly. This also depends on the size of the Viewport3D
containing all the elements.
Basically, it looks something like this:
And, in math terms: solve the distance y, where y is one leg of a square triangle made up of y itself, 0.5 (half the 3D panel width), and the hypotenuse is formed by extending the camera's field of view (or half field of view). Since we don't know the length of the hypotenuse but can figure out the angle (as it's half the field of view), we can use tan(field of view / 2.0), or in code terms:
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;
}
When y is found, multiply it with the ratio between the designed UI element width and the Viewport3D
current width to compensate for the size of the Viewport3D
. And lastly, offset it by the distance of the radius of the carousel. By taking the max of 1.0 and the calculated ratio, Math.Max(ratio, 1.0)
, the distance will make sure the entire width of the panel is always visible, even if the Viewport3D
is smaller than the designed size of the panel.
Since most WPF user controls are designed to be used inside a window or another control, their width and height cannot always be determined (hence the need for both Width
and ActualWidth
properties found on some WPF UI elements). In order for a user control to play nice with this tab control, it's therefore important to set the MinWidth
, MaxWidth
, and Width
at design time.
The user control
The WPF user control that implements the carousel is called Carousel
, intuitive, eh? And, as this control is mostly about rotation and camera position calculations, the XAML for it is quite simple:
<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>
The user control sets up a few things:
- The camera; it's important that the position for the camera is at
(0, 0, 0)
in order to get the distance calculations right; also, the look direction has to be along the Z-axis. - Ambient light, so that not only surfaces hit by the directional light are visible.
- Directional light; this is important as the scene looks "flat" without it.
CarouselContainer
: this is just the ModelVisual3D
used to hold all items in the carousel; this is what is actually being rotated when the carousel spins.
Points of interest
I could have had the definitions for the meshes in XAML as well, but I find it easier and more flexible to use code for this. The most complicated part was getting the wrapping rotation right, especially when there's less tab than there are elements in the carousel. This is because the way the rotation animation works, animating from 270 degrees to 360 is different than going from 270 to 0, which kind of makes sense, but still caused me some head aches as 360 and 0 are really the same.
As always, any comments on the code or the article are most welcome.
History
- 2010-01-01: First version.