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.
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:
- It must be like a normal WPF Panel, add it to any container and it should resize to fit its parent.
- It must work in the Visual Studio designer showing (at least) the front side during design.
- 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 UIElement
s 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
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
...
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)
Private Shared ReadOnly visualHostMaterial As Material = New DiffuseMaterial(Brushes.White)
Private Shared ReadOnly mesh As MeshGeometry3D = Plane.CreateXY(AxisZ, 1, 1)
Private model As ModelVisual3D = New ModelVisual3D()
Private frontVisual3D As Viewport2DVisual3D
Private backVisual3D As Viewport2DVisual3D
Private frontElement As UIElement
Private backElement As UIElement
Private viewPort As Viewport3D
Private contentContainer As ModelVisual3D
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.
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 Duration
s 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 UIElement
s 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 Viewport2DVisual3D
s 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
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
Dim backRotation As AxisAngleRotation3D = New AxisAngleRotation3D_
(IIf(SpinAxis = Orientation.Vertical, AxisY, AxisX), 180)
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)
If Not front Is Nothing Then
frontVisual3D.Visual = front
End If
If Not back Is Nothing Then
backVisual3D.Visual = back
End If
model.Children.Clear()
model.Children.Add(frontVisual3D)
model.Children.Add(backVisual3D)
InvalidateMeasure()
End Sub
Front and Back
The front and back UIElement
s 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:
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.
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