Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

WPF 3D Tab Carousel

4.99/5 (109 votes)
16 Jun 2012CPOL9 min read 228.9K   8.9K  
How to create a 3D tab carousel using WPF.

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 

Image 1

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 FrameworkElements so that pretty much any UI element can be used as a tab page.
  • The different FrameworkElements 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 FrameworkElements (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.

Image 2

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.

C#
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.

Image 3

This is done by creating a Box from Bornander.Wpf.Meshes, specifying that all sides except the front should be included:

C#
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:

C#
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:

C#
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 Tabs 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:

C#
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 SpinInstructions that tell the Animate method from where and where to go.

C#
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.

C#
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);
}

The CreateSpinAnimation is responsible for creating the actual animations and calling Animate again when the spin animation has completed.

Image 4

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:

Image 5

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:

C#
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:

XML
<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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)