Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

WPF 3D FlipPanel

0.00/5 (No votes)
13 Mar 2012 1  
WPF Implementation of the spinning panel frequently used on the iPhone
In this article, you will find a demo of a VB.NET way to duplicate the spinning panel effect widely used on the iPhone to let a panel host content both on its front and its back.

FlipPanelCS

Introduction

This article demonstrates a VB.NET way of duplicating the spinning panel effect widely used on the iPhone to let a panel host content both on its front and its back. I find that that type of control has its uses both on the small screens of hand held devices as well as normal computer monitors as a way of conserving screen real estate.

An example of it in action is available here on YouTube.

Background

I've written this control for a friend which is why it's in VB.NET instead of C# that I am more familiar with and I like to apologize in advance for any oddities that might exist in the VB.NET code due to my inexperience with that language. For completeness, I've included a C# version as well.

There are other controls available, but I haven't found one that was suitable for what my friend asked for:

  1. It must be like a normal WPF Panel, add it to any container and it should resize to fit its parent.
  2. It must work in the Visual Studio designer showing (at least) the front side during design.
  3. It must be simple and easy to understand so that it can be modified without too much hassle.

There will be a very limited amount of 3D math in this article, but people with no prior experience in 3D math should still be able to follow the article without problems.

Using the Code

Download the appropriate solution (VB.NET or C#), unzip and open. Both solutions have a class library and a WPF test app showing of a very simple sample implementation. The C# solution has two sample apps, one simple and one slightly more involved.
It's all been written using VS2010 Express Editions.

The Basics

Neither a Bird, nor a Superman

The 3D models used to host the UIElements are two identical planes, or quads, arranged to occupy the same "volume" of space but one is rotated 180 degrees. Since back face culling in 3D graphics allows us to only render the front of a mesh, it's perfectly acceptable for the two meshes to occupy the same space as long as one is facing in the opposite direction of the other.

A class called FlipPanel, which is discussed in detail later in this article, is responsible for positioning and rotating these two planes as well as providing the materials for them. In order to set up the meshes though, I've written a helper class called Plane.

Plane has a method for adding another plane to an already existing mesh, such a method might seem like overkill for this type of application, but I've left it in there as it's really a subset of a bigger set of helpers I use when working with 3D in WPF. The method AddPlaneToMesh takes an existing MeshGeometry3D along with the properties of the plane and adds those to the bigger mesh whilst making sure things like texture coordinates are added as well.

A wrapper method called Create is used to create a mesh containing just one plane, and yet another wrapper method called CreateXY allows for creating a mesh containing a plane defined in the XY plane of the 3D space.

Public Class Plane
  Public Shared Function AddPlaneToMesh(ByVal mesh As MeshGeometry3D, _
  ByVal normal As Vector3D, ByVal upperLeft As Point3D, ByVal lowerLeft As Point3D, _
  ByVal lowerRight As Point3D, ByVal upperRight As Point3D) As MeshGeometry3D
    Dim offset As Integer = 0

    mesh.Positions.Add(upperLeft)
    mesh.Positions.Add(lowerLeft)
    mesh.Positions.Add(upperRight)
    mesh.Positions.Add(lowerRight)

    mesh.Normals.Add(normal)
    mesh.Normals.Add(normal)
    mesh.Normals.Add(normal)
    mesh.Normals.Add(normal)

    mesh.TextureCoordinates.Add(New Point(0, 0))
    mesh.TextureCoordinates.Add(New Point(0, 1))
    mesh.TextureCoordinates.Add(New Point(1, 1))
    mesh.TextureCoordinates.Add(New Point(1, 0))

    mesh.TriangleIndices.Add(offset + 0)
    mesh.TriangleIndices.Add(offset + 1)
    mesh.TriangleIndices.Add(offset + 2)
    mesh.TriangleIndices.Add(offset + 0)
    mesh.TriangleIndices.Add(offset + 2)
    mesh.TriangleIndices.Add(offset + 3)

    Return mesh
  End Function

  Public Shared Function Create(ByVal normal As Vector3D, _
  ByVal upperLeft As Point3D, ByVal lowerLeft As Point3D, _
  ByVal lowerRight As Point3D, ByVal upperRight As Point3D) As MeshGeometry3D
    Return AddPlaneToMesh(New MeshGeometry3D(), normal, upperLeft, _
                          lowerLeft, upperRight, lowerRight)
  End Function

  Public Shared Function CreateXY(ByVal normal As Vector3D, _
         ByVal width As Double, ByVal height As Double) As MeshGeometry3D
    Dim w As Double = width / 2.0
    Dim h As Double = height / 2.0

    Return Create(normal, New Point3D(-w, h, 0), New Point3D(-w, -h, 0), _
                  New Point3D(w, -h, 0), New Point3D(w, h, 0))
  End Function
End Class

The planes are created so that the object local point (0, 0, 0) is the center of the plane.

FlipPanel

338671/Example.png

FlipPanel showing one of many panels spinning from showing a graph to showing stock information.

In order to achieve a panel spinning in 3D, I've used the 3D capabilities of WPF, it could have been implemented using only 2D transformations but I find it simpler to just use actual 3D since the logic then becomes a lot clearer and the solution is not as forced as any 2D version I could come up with would have been.

The members of the FlipPanel class are these:

Public Class FlipPanel
  Inherits Panel

  ...

  ' Definitions of the three axes
  Private Shared ReadOnly AxisX As Vector3D = New Vector3D(1, 0, 0)
  Private Shared ReadOnly AxisY As Vector3D = New Vector3D(0, 1, 0)
  Private Shared ReadOnly AxisZ As Vector3D = New Vector3D(0, 0, 1)

  ' Material and mesh, these are Shared
  Private Shared ReadOnly visualHostMaterial As Material = New DiffuseMaterial(Brushes.White)
  Private Shared ReadOnly mesh As MeshGeometry3D = Plane.CreateXY(AxisZ, 1, 1)

  ' The main model that holds both front and back side
  Private model As ModelVisual3D = New ModelVisual3D()
  
  ' Front and back Visuals, these hold the content in their .Visual property
  Private frontVisual3D As Viewport2DVisual3D
  Private backVisual3D As Viewport2DVisual3D

  ' The Front and Back content
  Private frontElement As UIElement
  Private backElement As UIElement

  ' 3D view port
  Private viewPort As Viewport3D
  
  ' The container holding the model 
  Private contentContainer As ModelVisual3D
  
  ' Rotation, translation and scale of the contentContainer
  Private rotation As AxisAngleRotation3D = New AxisAngleRotation3D(FlipPanel.AxisY, 0)
  Private translation As TranslateTransform3D = New TranslateTransform3D()
  Private scale As ScaleTransform3D = New ScaleTransform3D(1, 1, 1)
  
  ...
  
 End Class

The FlipPanel I've implemented for this article is simply a class extending Panel and exposing two properties of type UIElement called Front and Back. Using those properties, any content can be added to the panel's front and back like this (where f is the XML namespace pointing to the FlipPanel implementation);

<f:FlipPanel>
  <f:FlipPanel.Front>
    <Border Background="Red" BorderBrush="Black" BorderThickness="2">
      <Button Content="Front" VerticalAlignment="Center" Click="Button_Click"/>
    </Border>
  </f:FlipPanel.Front>
  <f:FlipPanel.Back>
    <Border Background="Green" BorderBrush="Black" BorderThickness="2">
      <Button Content="Back" VerticalAlignment="Center" Click="Button_Click"/>
    </Border>
  </f:FlipPanel.Back>
</f:FlipPanel>  

The content inside the Front and Back properties can obviously consist of more (or less) XAML or a user control. Just about anything, really.

In order for the FlipPanel to be able to host this, it needs some predefined children to set up the 3D scene. Essentially, the structure needed for this looks something like this:

<Grid>
  <Viewport3D>
    <Viewport3D.Camera>
      <PerspectiveCamera FieldOfView="90" Position="0,0,0.5" LookDirection="0,0,-1"/>
   </Viewport3D.Camera>
   <ModelVisual3D>
     <ModelVisual3D.Content>
       <AmbientLight Color="#808080"/>
     </ModelVisual3D.Content>
   </ModelVisual3D>
   <ModelVisual3D>
     <ModelVisual3D.Content>
       <DirectionalLight Color="White" Direction="0,0,-1"/>
     </ModelVisual3D.Content>
   </ModelVisual3D x:Name="container"/> 
 </Viewport3D>
</Grid>  

But since this is a panel implementation, I've done it all in code instead in a method called InitializeComponent, the code equivalent of the above XAML looks something like this:

Sub InitializeComponent()
  viewPort = New Viewport3D()
  viewPort.Camera = New PerspectiveCamera With
  {
    .FieldOfView = 90,
    .Position = New Point3D(0, 0, 0.5),
    .LookDirection = New Point3D(0, 0, -1)
  }
  viewPort.Children.Add(New ModelVisual3D With _
           {.Content = New AmbientLight(Colors.DarkGray)})
  viewPort.Children.Add(New ModelVisual3D With _
           {.Content = New DirectionalLight(Colors.White, New Vector3D(0, 0, -1))})

  Dim transform As Transform3DGroup = New Transform3DGroup()
  transform.Children.Add(New RotateTransform3D(rotation))
  transform.Children.Add(translation)
  transform.Children.Add(scale)

  contentContainer = New ModelVisual3D With {.Transform = transform}
  viewPort.Children.Add(contentContainer)
  Children.Add(viewPort)

  contentContainer.Children.Add(model)
End Sub

Note that at this point, a translation, scale and rotation is added to a Transform3DGroup that is then set as the transform for the contentContainer. This is because the contentContainer is what will hold the two models for the front and the back and by rotating the entire parent model, both the front and the back can be rotated using a single transform.

The translation part is required because I want the panel to "back up" or move further away from the screen so that it never has any part that is technically in front of the screen during rotation. More on this later.

The scale part is required because the model that hosts the actual content needs to be resized to fit the parent container. The FlipPanel achieves this by letting the model be 1 unit wide, and scaling the height to the ratio (aspect ratio) between the height and width of the FlipPanel itself. In order to get a model that is 1 unit wide to always fill the screen on the horizontal, simply position the camera 0.5 units away from the model and set the field of view to 90 degrees.

The FlipPanel uses ArrangeOverride and MeasureOverride to let the content think it is hosted on a surface the size of the control rather than the size of the model. This is important as not doing this yields UIs that look stretched or compressed.

338671/cameraposition.png

Position and field of view of camera to have a quad of width 1.0 fully fill the viewport.

Dependency Properties

To keep it simple, only three properties are exposed on the FlipPanel and those are:

  • FrontVisible
  • SpinTime
  • SpinAxis

Obviously properties for setting the front and back content exists as well, but they're just standard properties, not dependency properties.
In code, these three properties are defined like this:

Public Shared ReadOnly FrontVisibleProperty As DependencyProperty = _
       DependencyProperty.Register("FrontVisible", GetType(Boolean), _
       GetType(FlipPanel), New PropertyMetadata(False, AddressOf OnFrontVisibleChanged))
Public Shared ReadOnly SpinTimeProperty As DependencyProperty = _
       DependencyProperty.Register("SpinTime", GetType(Double), _
       GetType(FlipPanel), New PropertyMetadata(1.0))
Public Shared ReadOnly SpinAxisProperty As DependencyProperty = _
       DependencyProperty.Register("SpinAxis", GetType(Orientation), _
       GetType(FlipPanel), New PropertyMetadata(Orientation.Vertical, _
       AddressOf OnSpinAxisChanged))

FrontVisible

This property determines which side should be the one facing the user, it's just a boolean and whenever it changes value, the method OnFrontVisibleChanged is called and that method delegates to a method called Spin which is the one responsible for rotating the planes so that the one that should be facing the user actually is facing the user.

Private Shared Sub OnFrontVisibleChanged_
  (ByVal d As DependencyObject, ByVal e As DependencyPropertyChangedEventArgs)
  CType(d, FlipPanel).Spin()
End Sub

SpinTime

This property is a double and it holds the number of seconds a spin from front to back should take. It only sets the value, there's no method called when this is changed as the value is only relevant when a new rotation is started, and at that point, the Spin method reads the SpinTime in order to setup the Durations for the spin animations correctly.

SpinAxis

This property holds a value of type Orientation (i.e., Vertical or Horizontal) that is the axis the planes should spin around. Initially, one might think that this property need not call a method when it's value is changed as with the SpinTime property, the value is only relevant at animation start but that's not the case.
The way the content must added the front and back side of the planes differs depending on the axis of rotation. If rotation takes place around the Y-axis, both contents need to be oriented the same way, but if the axis of rotation is the X-axis, then the back content must be oriented upsidedown compared to the front content for it to appear right side up after the rotation.
(That explanation didn't come out as clear as I'd like, but it makes sense if you think about it.)

Because of this, the models has to be setup again whenever this value changes and that is done in SetupModel which is called like this:

Private Shared Sub OnSpinAxisChanged(ByVal d As DependencyObject, _
        ByVal e As DependencyPropertyChangedEventArgs)
  CType(d, FlipPanel).SetupModel()
End Sub

Content Layout

In order to get the content to display in a non-stretched way, it's important to let the UIElements that make up the content for the front and back side think they're laid out on area the size of the control. This is because when the content of a 3D control is added as a texture, it will always fit, but the aspect ratio might be skewed.

To fix this, the FlipPanel hooks into both stages of the WPF layout pass, measure and arrange, by overriding the corresponding methods;

Protected Overrides Function MeasureOverride(ByVal availableSize As Size) As Size
  viewPort.Measure(availableSize)
  If Not frontElement Is Nothing Then
    frontElement.Measure(availableSize)
  End If

  If Not backElement Is Nothing Then
    backElement.Measure(availableSize)
  End If

  Return availableSize
End Function

Protected Overrides Function ArrangeOverride(ByVal finalSize As Size) As Size
  Dim r As Rect = New Rect(finalSize)
  viewPort.Arrange(r)

  If Not frontElement Is Nothing Then
    frontElement.Arrange(r)
  End If

  If Not backElement Is Nothing Then
    backElement.Arrange(r)
  End If

  Return finalSize
End Function

In addition to doing the layout passes, the 3D models need to be adjusted so that they always exactly fill the viewport, this is done by hooking in to the SizeChanged event and adjusting the width and height of the models.

In reality, the models themselves never change, it's simply a scale transform that is applied to the container holding the model.

Since the width of the model is always 1.0, just the height part of the scale needs to be changed to represent the fraction of the width that the height makes up:

Private Sub HandlesSizeChanged(ByVal sender As Object, _
        ByVal args As SizeChangedEventArgs) Handles Me.SizeChanged
  If args.NewSize.Width > 0 Then
    scale.ScaleY = args.NewSize.Height / args.NewSize.Width
  End If
End Sub

Where scale in the snippet above is the scale transform applied to the content in SetupModel.

Setting Up the Model

Setting up the models is a matter of creating two Viewport2DVisual3D with the front and back content and then adding those to the main model.
This is straightforward enough and the only thing to note about the method is that the rotation for the back content has to take the SpinAxis dependency property into account, as discussed earlier.
Also, as new Viewport2DVisual3Ds are created, the current content has to be stored in temporary variables so that they can be restored onto the new Viewport2DVisual3D.

Public Sub SetupModel()
  Dim front As Visual = Nothing
  Dim back As Visual = Nothing

  ' Store temporary and unhook current
  If Not frontVisual3D Is Nothing Then
    front = frontVisual3D.Visual
   frontVisual3D.Visual = Nothing
  End If

  If Not backVisual3D Is Nothing Then
    back = backVisual3D.Visual
    backVisual3D.Visual = Nothing
  End If

  ' Set up the rotation for the back, this depends on the axis it's going to spin about
  Dim backRotation As AxisAngleRotation3D = New AxisAngleRotation3D_
                  (IIf(SpinAxis = Orientation.Vertical, AxisY, AxisX), 180)

  ' Set up the front and back Viewport2DVisual3D,
  ' adding the backRotation to the transform of the back
  ' Note that they share the same mesh and material
  frontVisual3D = New Viewport2DVisual3D _
                  With {.Geometry = mesh, .Material = visualHostMaterial}
  backVisual3D = New Viewport2DVisual3D With {.Geometry = mesh, _
  .Material = visualHostMaterial, .Transform = New RotateTransform3D(backRotation)}
  rotation.Axis = IIf(SpinAxis = Orientation.Vertical, AxisY, AxisX)

  ' Set the visuals if not null
  If Not front Is Nothing Then ' Double negatives, must be a better way of doing this...
    frontVisual3D.Visual = front
  End If

  If Not back Is Nothing Then
    backVisual3D.Visual = back
  End If

  ' Clear the current, and add the newly created
  model.Children.Clear()
  model.Children.Add(frontVisual3D)
  model.Children.Add(backVisual3D)

  ' Force layout pass
  InvalidateMeasure()
End Sub

Front and Back

The front and back UIElements are set through two aptly named properties, Front and Back.

These properties are responsible for unhooking the current content before setting the new one on to the Viewport2DVisual3D hosts created in method SetupModel.
The implementations look like this:

Public Property Front() As UIElement
  Get
    Return frontElement
  End Get
  Set(ByVal value As UIElement)
    frontVisual3D.Visual = Nothing
    frontVisual3D.Visual = value
    frontElement = value
  End Set
End Property

Public Property Back() As UIElement
  Get
    Return backElement
  End Get
  Set(ByVal value As UIElement)
    backVisual3D.Visual = Nothing
    backVisual3D.Visual = value
    backElement = value
  End Set
End Property

These are then the properties that the user of this control is supposed to use to set the content, possibly in XAML like this:

<f:FlipPanel x:Name="flipper" f:FlipPanel.FrontVisible="False">
  <f:FlipPanel.Front>
    <Border Background="Red" BorderBrush="Black" BorderThickness="2">
      <Button Content="Front" VerticalAlignment="Center" Click="Button_Click"/>
    </Border>
  </f:FlipPanel.Front>
  <f:FlipPanel.Back>
    <Border Background="Green" BorderBrush="Black" BorderThickness="2">
      <Button Content="Back" VerticalAlignment="Center" Click="Button_Click"/>
    </Border>
  </f:FlipPanel.Back>
</f:FlipPanel>

Like a Record, Baby

Last step of the FlipPanel is to get the content to spin around, right round, baby. Like a record, baby. Round, round.
When everything has been setup using the methods I've described above, the matter of actually spinning this whenever the FrontVisible dependency property changes value becomes really, really simple.

As all the transforms are already attached to the container. It's now a matter of just creating two animations:

  • Rotation
  • Translation

Both are needed to get a good looking rotation from front to back.

Rotation

This is the simpler of the two animations, it is a DoubleAnimation animating the rotation member either to 180.0 or 0.0 degrees depending on which side is currently showing. The Duration of the animation is dictated by the SpinTime dependency property.

Setting this up is easy;

Dim rotationAnimation As DoubleAnimation = New DoubleAnimation_
    (IIf(FrontVisible, 180, 0), New Duration(TimeSpan.FromSeconds(SpinTime)))
rotation.BeginAnimation(AxisAngleRotation3D.AngleProperty, rotationAnimation)

Translation

The translation part of the animation is required to prevent any part of the model ending up "behind" the camera.
This is achieved by backing up the model by such a distance that the near corner of the model remains at Z-coordinate 0.0.

The distance to find is distance d (in the diagram below) for any angle a such that the point highlighted by the orange overlay always lines up with the position of the virtual screen, or in our case, Z-coordinate 0.0. This distance starts at 0.0, then increases to 0.5 before it drops back to 0.0.

338671/distance.png

A Z-translation distance d for any angle a is required to keep the panel in front of the screen.

This might initially sound like it is quite complicated to figure out, as the distance changes non-linear over time, that is to say the model needs to first back away slowly, then speed up, then again slow down the way it backs up. And that's just half of it, then all of that has to be repeated in the other direction when it moves back towards the camera.

Luckily, while this is slightly complicated to show in maths, it just works out in code as the distance needed to back up is essentially sine of the rotation so far.
And this makes it easy because WPF already provides SineEase, which applied with proper values gives us exactly the behaviour we want.

Dim translationAnimation As DoubleAnimationUsingKeyFrames = _
                                New DoubleAnimationUsingKeyFrames()
translationAnimation.KeyFrames.Add(New EasingDoubleKeyFrame_
     (-0.5, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(SpinTime / 2.0)), New SineEase()))
translationAnimation.KeyFrames.Add(New EasingDoubleKeyFrame_
     (0, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(SpinTime)), _
     New SineEase With {.EasingMode = EasingMode.EaseIn}))
translation.BeginAnimation(TranslateTransform3D.OffsetZProperty, translationAnimation)

First key frame is moving back, second keyframe is moving front again, back into position where the model fills the screen.

Using It

Using the FlipPanel is easy, just add it, or several of it, like any other Panel to your WPF control, set the Front and Back properties and make sure you hook up a way to trigger the spinning. Simple.

Some people might say that Front and Back should have been dependency properties but I disagree with that approach. For the content to change during runtime, I rather have the content of Front and Back to hold a ContentPresenter and that's where the bindings should be. The FlipPanel is, like any Panel about layout, not content. It just happens to layout content in more dimensions.

Points of Interest

Flawed

A flaw in the design of this control is that it inherits from Panel, I did this because it's so convenient but since this particular Panel can only have two children, the hosts for Front and Back, it is illegal to add any children to the Panel without using these properties.

This problem is easily fixed by making sure that when the visual children of the FlipPanel changes, the child affected is either front or back;

Protected Overrides Sub OnVisualChildrenChanged_
 (ByVal visualAdded As DependencyObject, ByVal visualRemoved As DependencyObject)
  If Not Object.ReferenceEquals(visualAdded, frontVisual3D) _
     And Object.ReferenceEquals(visualAdded, backVisual3D) Then
    Throw New InvalidOperationException("Add children using the Front and Back properties")
  End If
  MyBase.OnVisualChildrenChanged(visualAdded, visualRemoved)
End Sub

And while this works fine, it's still a big flaw I think because the implied contract of Panel has been violated. There is not a clean is-a relationship between Panel and FlipPanel. And that's bad design.

Screen Real Estate

I really think this sort of control is useful when it comes to conserving screen real estate and I try to show that in the C# example apps, also available on YouTube here.

History

  • 1st March, 2012: First version
  • 14th March, 2012: Fixed bug in the stock demo app

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here