10 August 2012

A WinRT behavior to turn a FlipView into a kind of Windows 8 Panorama

IC425813(UPDATED for RTM August 18 2012)
One of the most beautiful controls of Windows Phone is the Panorama. It’s ideal for showing a lot related content on a small screen and enable the user to easily pan trough it. A visual cue for ‘there is more’ is provided by showing a little part of the next panel to the very right of the current data. A typical example is showed right.

It’s also one of the most abused controls (guilty as charged Your Honor), but still I wanted to port Catch’em Birds to Windows 8 – and I found out there was no ready-to-use control. After fighting with ScrollViewers and GridViewers and whatnot I came to this very simple behavior, which basically takes a FlipView and hammers it into a kind of Panorama.

Now the FlipView is designed to be a full-screen control so the behavior basically walks past all the items in the FlipView, shrinks them horizontally by a configurable percentage of the screen, and displaces the ‘next’ panel a little to the left (making it appear at the right side of the screen on the current panel). To make this look a little bit more fast and fluid, I have made the displacement itself animated, so that the ‘next’ screen not so much snaps as glides into view. The overall effect looks pretty nice to me. I hope Microsoft will think so as well, as my app is up for an App Excellence Lab soon ;-)
In my app it looks like this. I still lack a decent screen recorder for Windows 8, so I took out the video camera

So this behavior, most originally called “FlipViewPanoramaBehavior” is of course based upon my earlier WinRtBehaviors CodePlex project. It starts out like this, with the following dependency properties:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Win8nl.External;
using Win8nl.Utilities;
using WinRtBehaviors;
using Windows.Foundation;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Media.Animation;

namespace Win8nl.Behaviors
{
  /// <summary>
  /// A behavior to turn a FlipView into a kind of panorama
  /// </summary>
  public class FlipViewPanoramaBehavior : Behavior<FlipView>
  {
    #region AnimationTime

    /// <summary>
    /// AnimationTime Property name
    /// </summary>
    public const string AnimationTimePropertyName = "AnimationTime";

    public int AnimationTime
    {
      get { return (int)GetValue(AnimationTimeProperty); }
      set { SetValue(AnimationTimeProperty, value); }
    }

    /// <summary>
    /// AnimationTime Property definition
    /// </summary>
    public static readonly DependencyProperty AnimationTimeProperty = 
      DependencyProperty.Register(
        AnimationTimePropertyName,
        typeof(int),
        typeof(FlipViewPanoramaBehavior),
        new PropertyMetadata(250));

    #endregion

    #region NextPanelScreenPercentage

    /// <summary>
    /// NextPanelScreenPercentage Property name
    /// </summary>
    public const string NextPanelScreenPercentagePropertyName = 
      "NextPanelScreenPercentage";

    public double NextPanelScreenPercentage
    {
      get { return (double)GetValue(NextPanelScreenPercentageProperty); }
      set { SetValue(NextPanelScreenPercentageProperty, value); }
    }

    /// <summary>
    /// NextPanelScreenPercentage Property definition
    /// </summary>
    public static readonly DependencyProperty NextPanelScreenPercentageProperty = 
      DependencyProperty.Register(
        NextPanelScreenPercentagePropertyName,
        typeof(double),
        typeof(FlipViewPanoramaBehavior),
        new PropertyMetadata(10.0));
    #endregion
  }
}
So “AnimationTime” is the number of milliseconds the behavior takes to glide the next panel into view, and NextPanelScreenPercentage is an indication of how much screen real estate the next panel will take. Nothing special here yet.

If I want to muck around with a FlipView contents, I first have to find these contents. With some breakpoints and watches I found out I could use the following code to find the FlipViewItems:
/// <summary>
/// Find all Flip view items
/// </summary>
/// <returns></returns>
private List<FlipViewItem> GetFlipViewItems()
{
  var grid = AssociatedObject.GetVisualChildren().FirstOrDefault();
  if (grid != null)
  {
    return grid.GetVisualDescendents().OfType<FlipViewItem>().ToList();
  }
  return null;
}
Attentive readers might observe that neither GetVisualChildren nor GetVisualDescendents are part of the WinRT api, which is perfectly correct – they come from the VisualTreeHelperExtensions I ported from Windows Phone some time ago. Don’t start to download this stuff and build it together yourself – wait till the end and I will show the lazy way to do this.

Anyway – I wanted to move the FlipView’s contents fluently. That means I will use some Storyboards to work on Translations. So we identify the contents of each FlipViewItem and set its fist visual child’s Rendertransform to CompositeTransform, if that’s not already present:
/// <summary>
/// At compositions transforms to every item within every flip view item
/// </summary>
private void AddTranslates()
{
  var items = GetFlipViewItems();
  if (items != null && items.Count > 1)
  {
    foreach (var item in items)
    {
      var firstChild = item.GetVisualChild(0);
      if (!(firstChild.RenderTransform is CompositeTransform))
      {
        firstChild.RenderTransform = new CompositeTransform();
        firstChild.RenderTransformOrigin = new Point(0.5, 0.5);
      }
    }
  }
}
This assumes every FlipViewItem contains just one child. You better make sure it does for this to work, so put a Grid around it if you need more than one thing to sit in there.

Now the core of the whole behavior is this one piece of code:
/// <summary>
/// Does the actual repositioning and sizing of the items displayed in the Flipview
/// </summary>
private void SizePosFlipViewItems()
{
  AddTranslates(); // <-- moved from AssociatedObjectLoaded for RTM
  var size = AssociatedObject.ActualWidth*(NextPanelScreenPercentage/100);
  var shift = size - 15;

  var items = GetFlipViewItems();
  if (items != null && items.Count > 1)
  {
    // Make all items a bit smaller and make sure they are aligned left
    foreach (var item in items)
    {
      item.GetVisualChild(0).HorizontalAlignment = HorizontalAlignment.Left;
      item.GetVisualChild(0).Width = items[0].ActualWidth - size;
    }

    var selectedIndex = AssociatedObject.SelectedIndex;

    if (selectedIndex > 0)
    {
      StartTranslateStoryBoard(0, 0, 
                               items[selectedIndex - 1].GetVisualChild(0), 0);
    }

    StartTranslateStoryBoard(0, 0, items[selectedIndex].GetVisualChild(0), 
                             AnimationTime);

    if (selectedIndex + 1 < items.Count)
    {
      StartTranslateStoryBoard(-shift, 0,
                                items[selectedIndex + 1].GetVisualChild(0), 
                                AnimationTime);
    }
  }
}
First it calculates the new size of the FlipViewItems, and then it calculates how much it can shift the ‘next panel’ – basically, how much room is there between this panel and the next. This is currently a hard coded number, but feel free to make that a property as well ;-).

Then, for every FlipViewItem it makes the first visual child “size” smaller, and makes sure it’s aligned to the left (so space comes free and the right side). Then:
  1. It moves the panel that just disappeared to the left (if any) back  to it’s normal position, in no time (i.e. not animated – it’s invisible to the leftanyway, so why bother).
  2. It moves the current panel to its normal position, but it animates it. This is because if it’s moved in from the left, it moves a bit too far, as you might have noticed in the movie – so it glides back
  3. It moves the next panel (if any) a little bit to the left – animated, so it glides into view on the right hand side of the screen.
Now of course there is the slight matter of the method that make the storyboards to make it happen:
private static void StartTranslateStoryBoard(double desiredX, double desiredY, 
                                             FrameworkElement fe, int time)
{
  var translatePoint = fe.GetTranslatePoint();
  var destinationPoint = new Point(desiredX, desiredY);
  if (destinationPoint.DistanceFrom(translatePoint) > 1)
  {
    var storyboard = new Storyboard { FillBehavior = FillBehavior.HoldEnd };
    storyboard.AddTranslationAnimation(
         fe, translatePoint, destinationPoint,
         new Duration(TimeSpan.FromMilliseconds(time)),
         new CubicEase { EasingMode = EasingMode.EaseOut });
    storyboard.Begin();
  }
}
Once again, I use some extension methods from code ported from Windows Phone in the article I mentioned before, I underlined them to make them distinguishable from the standard API. Basically: this method accepts a FrameworkElement and moves it to a desired position in a desired time, using a storyboard that animates a translation. That is to say, unless it is already in that desired position. I think I will make this into a separate extension method in a utilities library one day but for the moment it’s doing fine.

All that’s left now is some wiring up, I cobbled that all together in one code block:
protected override void OnAttached()
{
  AssociatedObject.Loaded += AssociatedObjectLoaded;
  base.OnAttached();
}
protected override void OnDetaching()
{
  AssociatedObject.Loaded -= AssociatedObjectLoaded;
  AssociatedObject.SelectionChanged -= AssociatedObjectSelectionChanged;
  AssociatedObject.SizeChanged -= AssociatedObjectSizeChanged;
}

private void AssociatedObjectLoaded(object sender, RoutedEventArgs e)
{
  //AddTranslates(); deleted for RTM
  SizePosFlipViewItems();
  AssociatedObject.SelectionChanged += AssociatedObjectSelectionChanged;
  AssociatedObject.SizeChanged += AssociatedObjectSizeChanged;
}

private async void AssociatedObjectSelectionChanged(object sender, 
                                                    SelectionChangedEventArgs e)
{
  await Task.Delay(250); // Updated after bug report from SCPRedMage
  SizePosFlipViewItems();
}

private async void AssociatedObjectSizeChanged(object sender, 
                                               SizeChangedEventArgs e)
{
  await Task.Delay(250);
  SizePosFlipViewItems();
}
OnAttached and OnDetaching do their usual basic wiring and unwiring of events.
When the AssociatedObject (i.e. the FlipView) is first loaded the FlipViewItems’ first child gets their CompositeTransforms, then the initial screen layout is created by calling SizePosFlipViewItems. Then two events are wired up:
  • SelectionChanged
  • SizeChanged
Now the first one is logical – when the user selects the next panel (i.e. he scrolls it in from the left or right) the panels need to be arrange again so that the newly selected panel stays in view (it scrolls too much to the right, remember) and the ‘new’ next panel comes into view at the left hand side of the screen.

The SizeChanged intercept is necessary for when the user rotates his screen or snaps the application. For then the size of the screen changes, and the portion of the screen that the next panel may use is considerably smaller – in pixels. In my app this is taken care of by a Visual State Manager that listens to page events – basically something stolen from the LayoutAwarePage that’s in every template project – but that takes a while. Now I know I am going to be lambasted for this (and I have a pretty good idea by whom), but to solve this the SizeChanged handler waits a bit for actually calling SizePosFlipViewItems. And to prevent UI blocking I interestingly abused Task.Delay for that. It’s crude, but it works. As you may have seen in the movie when I snapped the app.

So there you have it. The code works, you have seen it in action. Its usage is ridicilously simple: make a FlipView, add items, and add this behavior to the FlipView. Done. You can download the the behavior here but you will need quite some base libs to get it working – as it uses a lot of my win8nl library on CodePlex. If you want to go the easy and quick way: just use the Win8nl NuGet package. That will get you the behavior and all the prerequisites, including MVVMLight.

Be aware that win8nl now uses the Reactive extensions. They are included in the NuGet package and they will come with it as a dependency

UPDATE: Please note there is a tiny code change since original publication: due to Microsoft optimizing the FlipView not all elements are initially loaded, so the check if every FlipViewItem child has a CompositeTransform has to performed at every manipulation.

UPDATE 2: There's a tiny update to AssociatedObjectSelectionChanged. And those who want a simple working sample, download the sources from codeplex and fire up FlipViewTest.XAML as the start page.