This blog post describes the implementation of a metro 'tilt' effect for Windows Phone 7 which causes element to respond to user interactions by tilting in 3D

So far in the "Metro In Motion" series I have covered fluid list animations, 'peel' animations and flying title. In this blog post I am looking at the 'tilt' effect seen in native Windows Phone 7 applications where list items, tiles, check-boxes and other user interface element tilt slightly when the user presses them. This is a subtle 3D effect that makes the phone interface more tactile and playful.

I was in two minds about whether to implement my own tilt effect since there is an implementation of this effect available via the Silverlight (for Windows Phone 7) Toolkit. However, I am not that keen on the toolkit implementation of this feature. Firstly, the effect is applied by specifying the types of element that should tilt, unfortunately this fails in various scenarios; secondly, the tilt is a bit over-the-top. Personally, I think this effect works best when it is at its most subtle.

The video below shows the tilt effect in action, together with the other Metro In Motion effects:


The implementation of this effect is actually rather simple, relying heavily on the PlaneProjection transform that makes 3D transformations accessible to mere mortals like myself (for more powerful and flexible 3D effects I would thoroughly recommend René Schule's Matrix3DEx library). The effect is added to any element by applying an attached property:

<Button local:MetroInMotion.Tilt="6"/>

The value of the Tilt property defines how pronounced the effect is. When the Tilt property is attached, event handlers are added to the UI element:

// The extent of the tilt action, the larger the number, the bigger the tilt
private static double TiltAngleFactor = 4;

// The extent of the scaling action, the smaller the number, the greater the scaling.
private static double ScaleFactor = 100;

private static void OnTiltChanged(DependencyObject d,
  DependencyPropertyChangedEventArgs args)
{
  FrameworkElement targetElement = d as FrameworkElement;

  double tiltFactor = GetTilt(d);

  // create the required transformations
  var projection = new PlaneProjection();
  var scale = new ScaleTransform();
  var translate = new TranslateTransform();

  var transGroup = new TransformGroup();
  transGroup.Children.Add(scale);
  transGroup.Children.Add(translate);

  // associate with the target element
  targetElement.Projection = projection;
  targetElement.RenderTransform = transGroup;
  targetElement.RenderTransformOrigin = new Point(0.5, 0.5);

  targetElement.MouseLeftButtonDown += (s, e) =>
    {
      var clickPosition = e.GetPosition(targetElement);

      // find the maximum of width / height
      double maxDimension = Math.Max(targetElement.ActualWidth, targetElement.ActualHeight);

      // compute the normalised horizontal distance from the centre
      double distanceFromCenterX = targetElement.ActualWidth / 2 - clickPosition.X;
      double normalisedDistanceX = 2 * distanceFromCenterX / maxDimension;

      // rotate around the Y axis
      projection.RotationY = normalisedDistanceX * TiltAngleFactor * tiltFactor;

      // compute the normalised vertical distance from the centre
      double distanceFromCenterY = targetElement.ActualHeight / 2 - clickPosition.Y;
      double normalisedDistanceY = 2 * distanceFromCenterY / maxDimension;

      // rotate around the X axis,
      projection.RotationX = -normalisedDistanceY * TiltAngleFactor * tiltFactor;

      // find the distance to centre
      double distanceToCentre = Math.Sqrt(normalisedDistanceX * normalisedDistanceX
        + normalisedDistanceY * normalisedDistanceY);

      // scale accordingly
      double scaleVal = tiltFactor * (1 - distanceToCentre) / ScaleFactor;
      scale.ScaleX = 1 - scaleVal;
      scale.ScaleY = 1 - scaleVal;
    };

  targetElement.ManipulationCompleted += (s, e) =>
    {
      var sb = new Storyboard();
      sb.Children.Add(CreateAnimation(null, 0, 0.1, "RotationY", projection));
      sb.Children.Add(CreateAnimation(null, 0, 0.1, "RotationX", projection));
      sb.Children.Add(CreateAnimation(null, 1, 0.1, "ScaleX", scale));
      sb.Children.Add(CreateAnimation(null, 1, 0.1, "ScaleY", scale));
      sb.Begin();
    };

}

When the element is clicked, the distance of the click location to the centre is computed. A PlaneProjection is used to tilt the element based on the click location, with a click at the top causing it to tilt around the X axis, and a click on the side tilting around the Y axis. A ScaleTransform is used to make the element shrink slightly, giving a user the feeling that the element has been pushed into the phone slightly.

When the manipulation is complete the properties of these various transforms are animated back to their original values usingCreateAnimation which is a simple utility method for creating DoubleAnimation instances:

private static DoubleAnimation CreateAnimation(double? from, double? to, double duration,
  string targetProperty, DependencyObject target)
{
  var db = new DoubleAnimation();
  db.To = to;
  db.From = from;
  db.EasingFunction = new SineEase();
  db.Duration = TimeSpan.FromSeconds(duration);
  Storyboard.SetTarget(db, target);
  Storyboard.SetTargetProperty(db, new PropertyPath(targetProperty));
  return db;
}

The magnitude of the ScaleTransform and PlaneProjection depends on the click location, with clicks to the centre of the element resulting in a scaling, but no rotation, and clicks at the edge causing rotation but no scaling. The net result can be observed below where the effect of clicking at various locations of an element is shown:

You can also see the effect of clicking a square element below:

Finally, native phone applications have another subtle effect where elements that are tilted at the bottom of the screen appear to be viewed from above, whilst ones at the top are viewed from below. This can be achieved by applying a local offset to the PlaneProjection and an equal and opposite vertical TranslateTransform. The following code is added ...

targetElement.MouseLeftButtonDown += (s, e) =>
  {
    // ... scale and plane projection transforms show earlier go here ...

    // offset the plane transform
    var rootElement = Application.Current.RootVisual as FrameworkElement;
    var relativeToCentre = (targetElement.GetRelativePosition(rootElement).Y - rootElement.ActualHeight / 2) / 2;
    translate.Y = -relativeToCentre;
    projection.LocalOffsetY = +relativeToCentre;

  };

targetElement.ManipulationCompleted += (s, e) =>
  {
    var sb = new Storyboard();
    sb.Children.Add(CreateAnimation(null, 0, 0.1, "RotationY", projection));
    sb.Children.Add(CreateAnimation(null, 0, 0.1, "RotationX", projection));
    sb.Children.Add(CreateAnimation(null, 1, 0.1, "ScaleX", scale));
    sb.Children.Add(CreateAnimation(null, 1, 0.1, "ScaleY", scale));
    sb.Begin();

    translate.Y = 0;
    projection.LocalOffsetY = 0;
  };

Which results in the following effect:

With that, the effect is complete!

Please note, the images in this blog post use a Tilt 'factor' of 6 in order to make the effect easier to see in these static images. I would urge you to use a much lower factor, perhaps 2, to make this effect much more subtle. I think it works best if the user almost doesn't notice the effect, rather they 'feel' it.

You can download the full sourcecode for this blog post here: MetroInMotion4.zip

Regards,Colin E.