TiltContentControl for Windows Phone

7 August 2010

An enjoyable feature found in Windows Phone is a neat interactive effect that our designers have dubbed “tilt”. Tilt gives a little motion to standard controls during manipulations (when they’re being touched).

9/16/2010 Update: This post is no longer the best reference for this effect as this version does not include global camera tilt.
Alternative recommended resources: MSDN documentation on Tilt, and Peter Torr’s implementation (via attached behaviors)

Although the operating system’s menus and core applications use this effect in many places, applications built for the platform by default don’t pick up any tilt effects. It’s a pretty complex problem to try and make assumptions about which controls and interface elements users would like to have a tilt added to.

We decided for now not to include the tilt effect for Silverlight apps on the phone, which is why you don’t see the tilt in the beta of the phone developer tools.

It’s a subtle effect, but you do see it when you use a device. It lets you know something’s responsive. So, if you click on the N in New Orleans in a list box like the one I have in a sample below:

TiltExample
Tilt in effect: the New Orleans entry has been touched and has tilted in reaction to the manipulation. When the manipulation is done, it will move back to its standard position.

Introducing Luke’s TiltContentControl

So one of our excellent devs on the Silverlight mobile team, Luke Longley, went ahead and coded up this control we’re calling TiltContentControl. I’m sharing this control on his behalf today as we wanted to get this out for people to experiment with.

This lets you wrap any element that you’d like to have receive a tilt-like experience. You can also add it to data templates or re-template controls like Button to make it easy to apply throughout your app in a standard way.

ContentControl vs Attached Property

Since this control derives from ContentControl, it is designed to wrap a single control that should have the effect. It can also be placed inside of custom templates and styles to make it easy to apply, instead of having to manually wrap all controls you’d like to tilt enable with the control.

Another approach other than using a wrapping content control is to use an attached property. Peter Torr from the Windows Phone application platform team created an implementation of tilt that does just that.

Both these implementations are slightly different, so check them both out – they’re interesting lessons in the platform. This content control responds to manipulation deltas, so you’ll get a very similar effect to that of the rest of the phone controls while your finger is still on the screen.

Adding the control to your project

As a simple derived content control, you can add TiltContentControl to your project just by dropping this single C# file into your project.

TiltContentControl.cs (code also duplicated below)

How to use the control within your project

So here’s how to use it in-place:

<unofficial:TiltContentControl 
    VerticalAlignment="Center" 
    HorizontalAlignment="Center">
    <Button Content="Hello" 
        Padding="40,0,40,0"/>
</unofficial:TiltContentControl>

Here’s how to re-template something to use it – in this case I’m retemplating Button. You could put this style one time in your project, in App.xaml or a resource dictionary, then easily use it wherever without having to actually wrap the individual control instances.

<!-- Re-templated button -->
<!--
This scenario re-templates button to contain the 
tilt behavior and it can be used with any button 
by applying the styled key TiltButton.
-->
<Style x:Key="TiltButton" TargetType="ButtonBase">
    <Setter Property="Background" Value="Transparent"/>
    <Setter Property="BorderBrush" Value="{StaticResource PhoneForegroundBrush}"/>
    <Setter Property="Foreground" Value="{StaticResource PhoneForegroundBrush}"/>
    <Setter Property="BorderThickness" Value="{StaticResource PhoneBorderThickness}"/>
    <Setter Property="FontFamily" Value="{StaticResource PhoneFontFamilySemiBold}"/>
    <Setter Property="FontSize" Value="{StaticResource PhoneFontSizeMediumLarge}"/>
    <Setter Property="Padding" Value="10,3,10,5"/>

    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="ButtonBase">
                <unofficial:TiltContentControl>
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="CommonStates">
                            <VisualState x:Name="Normal"/>
                            <VisualState x:Name="MouseOver"/>
                            <VisualState x:Name="Pressed">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentContainer" Storyboard.TargetProperty="Foreground">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource PhoneBackgroundBrush}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ButtonBackground" Storyboard.TargetProperty="Background">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource PhoneForegroundBrush}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ButtonBackground" Storyboard.TargetProperty="BorderBrush">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource PhoneForegroundBrush}" />
                                    </ObjectAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                            <VisualState x:Name="Disabled">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentContainer" Storyboard.TargetProperty="Foreground">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource PhoneDisabledBrush}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ButtonBackground" Storyboard.TargetProperty="BorderBrush">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource PhoneDisabledBrush}" />
                                    </ObjectAnimationUsingKeyFrames>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ButtonBackground" Storyboard.TargetProperty="Background">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="Transparent" />
                                    </ObjectAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>

                    <Grid Background="Transparent">
                        <Border x:Name="ButtonBackground" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="0" Background="{TemplateBinding Background}" Margin="{StaticResource PhoneTouchTargetOverhang}" >
                            <ContentControl x:Name="ContentContainer" Foreground="{TemplateBinding Foreground}" HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" Padding="{TemplateBinding Padding}" Content="{TemplateBinding Content}" ContentTemplate="{TemplateBinding ContentTemplate}"/>
                        </Border>
                    </Grid>
                </unofficial:TiltContentControl>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

So to have a Button use it, just set the style to the static resource:

<Button Style="{StaticResource TiltButton}"
    Content="A styled button" />

The XML namespace I’m using (this should go at the top of any pages or resource dictionaries you place the tilt content control in):

xmlns:unofficial="clr-namespace:Microsoft.Phone.Controls.Unofficial"

Now in the sample image above, I show a ListBox with items that all have the tilt effect enabled. Here’s how you can do the same by adding the control within a data template and setting that to the ItemTemplate of your list box. I also have ended up using ItemContainerStyle to make the entire width of the item tilt-enabled:

<ListBox x:Name="ListBox1" Grid.Row="1">
    <ListBox.ItemContainerStyle>
        <Style TargetType="ListBoxItem">
            <Setter 
                Property="HorizontalContentAlignment" 
                Value="Stretch"/>
        </Style>
    </ListBox.ItemContainerStyle>
    <ListBox.ItemTemplate>
        <DataTemplate>
            <unofficial:TiltContentControl
                Background="Transparent"
                HorizontalAlignment="Stretch"
                HorizontalContentAlignment="Stretch"
                FontSize="{StaticResource PhoneFontSizeExtraLarge}">
                <Grid HorizontalAlignment="Stretch"
                        IsHitTestVisible="True"
                        Background="Transparent">
                    <ContentControl
                        HorizontalAlignment="Stretch"
                        HorizontalContentAlignment="Left"
                        Content="{Binding}"/>
                </Grid>
            </unofficial:TiltContentControl>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

I’m sure there are other ways to do this, too. Which type of solution is really up to you.

Creating and using a custom easing function

The easing function defined for the tilt up operation is not a standard one found in Silverlight. As a result, the choice is either to implement a custom easing function in C#, or to find a similar one in the platform.

Here’s what the UX people say:

CustomEasingFunction

Silverlight’s easing functions take in a normalized time value from 0 to 1. This means that if the double animation is really one second-long duration, then at half a second (500ms) the normalized time value will be 0.5, and at one second, 1.0. But never outside the range of [0, 1].

To implement a custom easing function, you just create a new class that derives from EasingFunctionBase from the System.Windows.Media.Animation namespace. The tilt control has this as a nested private class, since we aren’t exposing it for use elsewhere.

/// <summary>
/// An easing function of ln(t+1)/ln(2).
/// </summary>
private class LogarithmicEase : EasingFunctionBase
{
    /// <summary>
    /// Constant value of ln(2) used in the easing function.
    /// </summary>
    private const double NaturalLog2 = 0.693147181;

    /// <summary>
    /// Overrides the EaseInCore method to provide the logic portion of
    /// an ease in.
    /// </summary>
    /// <param name="normalizedTime">Normalized time (progress) of the
    /// animation, which is a value from 0 through 1.</param>
    /// <returns>A double that represents the transformed progress.</returns>
    protected override double EaseInCore(double normalizedTime)
    {
        return Math.Log(normalizedTime + 1) / NaturalLog2;
    }
}

We’ve only overwritten the EaseIn method here. Pretty simple, you don’t need to be a mathematician!

In code, you can just use an instance of the LogarithmicEase type now for any animation’s EasingFunction value. You’ll see this implementation has a single static readonly instance of it that’s reused every time.

Quick perf lesson #1: Avoid custom easing functions as they have a negative performance implication

Custom easing functions are called per frame on the user interface thread. This is a much more expensive operation than a standard easing function which is implemented in native code by Silverlight, and able to run in the compositor thread (previously known as the render thread or independent animations thread).

The callback into C# is time that the user interface thread could have spent handling operating system events, handling input, letting property change notifications propagate, or the layout system to perform a full measure-arrange pass.

We’ve decided to use a custom easing function because the platform’s designers have explicitly designated this as the tilt-up animation’s ease.

Quick perf lesson #2: Store a single static instance of property paths when creating animations in code

If you look through the code, you’ll see that the storyboard and double animations are created in code. To minimize the additional number of new object allocations, the code has 3 static readonly properties for the paths.

/// <summary>
/// Single instance of the Rotation X property.
/// </summary>
private static readonly PropertyPath RotationXProperty = new PropertyPath(PlaneProjection.RotationXProperty);

/// <summary>
/// Single instance of the Rotation Y property.
/// </summary>
private static readonly PropertyPath RotationYProperty = new PropertyPath(PlaneProjection.RotationYProperty);

/// <summary>
/// Single instance of the Global Offset Z property.
/// </summary>
private static readonly PropertyPath GlobalOffsetZProperty = new PropertyPath(PlaneProjection.GlobalOffsetZProperty);

This decreases the number of objects that have to be allocated, which decreases the cost of garbage collection. It’s probably a minimal improvement, but it’s something to think about.

Does this tilt exactly match the OS?

No, the effect doesn’t completely match.

The phone has an additional feature, a “global camera,” that I’m told takes into account the element’s position on screen. The global camera effect is why buttons near the bottom of the screen have an exaggerated tilt effect.

You won’t get that specific effect from this Silverlight implementation today. So this Silverlight implementation is more simple, but in my mind still achieves the goal of offering the tilt effect on elements.

We’ll see how people like it and maybe we’ll find a way to match the platform even closer in the future.

Workaround note for beta tools developers

There was a bug in the beta release of the platform where objects within a perspective transform that used bitmap caching would not necessarily react to the changing perspective. We’ve since fixed this internally.

To enable this file to be relevant to developers today (using beta tools) as well as in the future, I’ve coded in a workaround for beta tools users.

If you are using Beta tools, you should uncomment the #define statement near the top of the file (line 13 I think).

If you’re using post-beta tools, you can hopefully just use the file as-is.

Full implementation of TiltContentControl.cs

Here’s the full implementation, you can just drop this into your phone project:

// (c) Copyright Microsoft Corporation.
// This source is subject to the Microsoft Public License (Ms-PL).
// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details.
// All other rights reserved.

// ---
// Important Workaround Note for developers using the BETA:
// There is a workaround in code that removes any CacheMode from the content of
// the control. It works around a platform bug that is slated to be fixed for
// release.
//
// If you are using the beta tools, remove the comment below:
// #define WORKAROUND_BITMAP_CACHE_BUG
// ---

using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;

namespace Microsoft.Phone.Controls.Unofficial
{
    /// <summary>
    /// A content control designed to wrap anything in Silverlight with a user
    /// experience concept called 'tilt', applying a transformation during 
    /// manipulation by a user.
    /// </summary>
    public class TiltContentControl : ContentControl
    {
        #region Constants
        /// <summary>
        /// Maximum angle for the tilt effect, defined in Radians.
        /// </summary>
        private const double MaxAngle = 0.3;
        
        /// <summary>
        /// The maximum depression for the tilt effect, given in pixel units.
        /// </summary>
        private const double MaxDepression = 25;

        /// <summary>
        /// The number of seconds for a tilt revert to take.
        /// </summary>
        private static readonly Duration TiltUpAnimationDuration = new Duration(TimeSpan.FromSeconds(.5));

        /// <summary>
        /// A single logarithmic ease instance.
        /// </summary>
        private static readonly IEasingFunction LogEase = new LogarithmicEase();

        #endregion

        #region Static property instances
        /// <summary>
        /// Single instance of the Rotation X property.
        /// </summary>
        private static readonly PropertyPath RotationXProperty = new PropertyPath(PlaneProjection.RotationXProperty);

        /// <summary>
        /// Single instance of the Rotation Y property.
        /// </summary>
        private static readonly PropertyPath RotationYProperty = new PropertyPath(PlaneProjection.RotationYProperty);

        /// <summary>
        /// Single instance of the Global Offset Z property.
        /// </summary>
        private static readonly PropertyPath GlobalOffsetZProperty = new PropertyPath(PlaneProjection.GlobalOffsetZProperty);
        #endregion

        /// <summary>
        /// The content element instance.
        /// </summary>
        private ContentPresenter _presenter;

        /// <summary>
        /// The original width of the control.
        /// </summary>
        private double _width;

        /// <summary>
        /// The original height of the control.
        /// </summary>
        private double _height;

        /// <summary>
        /// The storyboard used for the tilt up effect.
        /// </summary>
        private Storyboard _tiltUpStoryboard;

        /// <summary>
        /// The plane projection used to show the tilt effect.
        /// </summary>
        private PlaneProjection _planeProjection;

        /// <summary>
        /// Overrides the method called when apply template is called. We assume
        /// that the implementation root is the content presenter.
        /// </summary>
        public override void OnApplyTemplate()
        {
            base.OnApplyTemplate();
            _presenter = GetImplementationRoot(this) as ContentPresenter;
        }

        /// <summary>
        /// Overrides the maniupulation started event.
        /// </summary>
        /// <param name="e">The manipulation event arguments.</param>
        protected override void OnManipulationStarted(ManipulationStartedEventArgs e)
        {
            base.OnManipulationStarted(e);

            if (_presenter != null)
            {
#if WORKAROUND_BITMAP_CACHE_BUG
                // WORKAROUND NOTE:
                // This is a workaround for a platform bug related to cache mode
                // that should be fixed before final release of the platform.
                UIElement elementContent = _contentElement.Content as UIElement;
                if (elementContent != null && elementContent.CacheMode != null)
                {
                    elementContent.CacheMode = null;
                }
#endif
                _planeProjection = new PlaneProjection();
                _presenter.Projection = _planeProjection;

                _tiltUpStoryboard = new Storyboard();
                _tiltUpStoryboard.Completed += TiltUpCompleted;

                DoubleAnimation tiltUpRotateXAnimation = new DoubleAnimation();
                Storyboard.SetTarget(tiltUpRotateXAnimation, _planeProjection);
                Storyboard.SetTargetProperty(tiltUpRotateXAnimation, RotationXProperty);
                tiltUpRotateXAnimation.To = 0;
                tiltUpRotateXAnimation.EasingFunction = LogEase;
                tiltUpRotateXAnimation.Duration = TiltUpAnimationDuration;

                DoubleAnimation tiltUpRotateYAnimation = new DoubleAnimation();
                Storyboard.SetTarget(tiltUpRotateYAnimation, _planeProjection);
                Storyboard.SetTargetProperty(tiltUpRotateYAnimation, RotationYProperty);
                tiltUpRotateYAnimation.To = 0;
                tiltUpRotateYAnimation.EasingFunction = LogEase;
                tiltUpRotateYAnimation.Duration = TiltUpAnimationDuration;

                DoubleAnimation tiltUpOffsetZAnimation = new DoubleAnimation();
                Storyboard.SetTarget(tiltUpOffsetZAnimation, _planeProjection);
                Storyboard.SetTargetProperty(tiltUpOffsetZAnimation, GlobalOffsetZProperty);
                tiltUpOffsetZAnimation.To = 0;
                tiltUpOffsetZAnimation.EasingFunction = LogEase;
                tiltUpOffsetZAnimation.Duration = TiltUpAnimationDuration;

                _tiltUpStoryboard.Children.Add(tiltUpRotateXAnimation);
                _tiltUpStoryboard.Children.Add(tiltUpRotateYAnimation);
                _tiltUpStoryboard.Children.Add(tiltUpOffsetZAnimation);
            }
            if (_planeProjection != null)
            {
                _width = ActualWidth;
                _height = ActualHeight;
                if (_tiltUpStoryboard != null)
                {
                    _tiltUpStoryboard.Stop();
                }
                DepressAndTilt(e.ManipulationOrigin, e.ManipulationContainer);
            }
        }

        /// <summary>
        /// Handles the manipulation delta event.
        /// </summary>
        /// <param name="e">The manipulation event arguments.</param>
        protected override void OnManipulationDelta(ManipulationDeltaEventArgs e)
        {
            base.OnManipulationDelta(e);
            // Depress and tilt regardless of whether the event was handled.
            if (_planeProjection != null)
            {
                DepressAndTilt(e.ManipulationOrigin, e.ManipulationContainer);
            }
        }

        /// <summary>
        /// Handles the manipulation completed event.
        /// </summary>
        /// <param name="e">The manipulation event arguments.</param>
        protected override void OnManipulationCompleted(ManipulationCompletedEventArgs e)
        {
            base.OnManipulationCompleted(e);
            if (_planeProjection != null)
            {
                if (_tiltUpStoryboard != null)
                {
                    _tiltUpStoryboard.Begin();
                }
                else
                {
                    _planeProjection.RotationY = 0;
                    _planeProjection.RotationX = 0;
                    _planeProjection.GlobalOffsetZ = 0;
                }
            }
        }

        /// <summary>
        /// Updates the depression and tilt based on position of the 
        /// manipulation relative to the original origin from input.
        /// </summary>
        /// <param name="manipulationOrigin">The origin of manipulation.</param>
        /// <param name="manipulationContainer">The container instance.</param>
        private void DepressAndTilt(Point manipulationOrigin, UIElement manipulationContainer)
        {
            GeneralTransform transform = manipulationContainer.TransformToVisual(this);
            Point transformedOrigin = transform.Transform(manipulationOrigin);
            Point normalizedPoint = new Point(
                Math.Min(Math.Max(transformedOrigin.X / _width, 0), 1),
                Math.Min(Math.Max(transformedOrigin.Y / _height, 0), 1));
            double xMagnitude = Math.Abs(normalizedPoint.X - 0.5);
            double yMagnitude = Math.Abs(normalizedPoint.Y - 0.5);
            double xDirection = -Math.Sign(normalizedPoint.X - 0.5);
            double yDirection = Math.Sign(normalizedPoint.Y - 0.5);
            double angleMagnitude = xMagnitude + yMagnitude;
            double xAngleContribution = xMagnitude + yMagnitude > 0 ? xMagnitude / (xMagnitude + yMagnitude) : 0;
            double angle = angleMagnitude * MaxAngle * 180 / Math.PI;
            double depression = (1 - angleMagnitude) * MaxDepression;
            // RotationX and RotationY are the angles of rotations about the x- 
            // or y-axis. To achieve a rotation in the x- or y-direction, the
            // two must be swapped. So a rotation to the left about the y-axis 
            // is a rotation to the left in the x-direction, and a rotation up 
            // about the x-axis is a rotation up in the y-direction.
            _planeProjection.RotationY = angle * xAngleContribution * xDirection;
            _planeProjection.RotationX = angle * (1 - xAngleContribution) * yDirection;
            _planeProjection.GlobalOffsetZ = -depression;
        }

        /// <summary>
        /// Handles the tilt up completed event.
        /// </summary>
        /// <param name="sender">The source object.</param>
        /// <param name="e">The event arguments.</param>
        private void TiltUpCompleted(object sender, EventArgs e)
        {
            if (_tiltUpStoryboard != null)
            {
                _tiltUpStoryboard.Stop();
            }
            _tiltUpStoryboard = null;
            _planeProjection = null;
            _presenter.Projection = null;
        }

        /// <summary>
        /// An easing function of ln(t+1)/ln(2).
        /// </summary>
        private class LogarithmicEase : EasingFunctionBase
        {
            /// <summary>
            /// Constant value of ln(2) used in the easing function.
            /// </summary>
            private const double NaturalLog2 = 0.693147181;

            /// <summary>
            /// Overrides the EaseInCore method to provide the logic portion of
            /// an ease in.
            /// </summary>
            /// <param name="normalizedTime">Normalized time (progress) of the
            /// animation, which is a value from 0 through 1.</param>
            /// <returns>A double that represents the transformed progress.</returns>
            protected override double EaseInCore(double normalizedTime)
            {
                return Math.Log(normalizedTime + 1) / NaturalLog2;
            }
        }

        /// <summary>
        /// Gets the implementation root of the Control.
        /// </summary>
        /// <param name="dependencyObject">The DependencyObject.</param>
        /// <remarks>
        /// Implements Silverlight's corresponding internal property on Control.
        /// </remarks>
        /// <returns>Returns the implementation root or null.</returns>
        public static FrameworkElement GetImplementationRoot(DependencyObject dependencyObject)
        {
            Debug.Assert(dependencyObject != null, "DependencyObject should not be null.");
            return (1 == VisualTreeHelper.GetChildrenCount(dependencyObject)) ?
                VisualTreeHelper.GetChild(dependencyObject, 0) as FrameworkElement :
                null;
        }
    }
}

Namespace: Microsoft.Phone.Controls.Unofficial

One change since the ProgressBar template I blogged about: I’m using the namespace Microsoft.Phone.Controls.Unofficial now. Unsupported sounded harsh; obviously these types of controls will be improved upon over time and they’re out in the public, so either on StackOverflow.com or Twitter or whatever there will be some place for people to talk about what’s good and bad with these controls.

Hope this helps.

Jeff Wilcox is a Software Engineer at Microsoft in the Open Source Programs Office (OSPO), helping Microsoft engineers use, contribute to and release open source at scale.

comments powered by Disqus