Adventures of a content rotator control

In my previous post, I developed a user control for showing GIF animations in a Windows 8 / Windows Store app.  I did this, because I’m currently developing a Windows Phone app and at the same time porting it to Windows 8 / Windows Store app.  Today I faced a new challenge.

Being very happy about the control suites from Telerik and using it extensively for my Windows Phone (WP) app, I needed to display a Hubtile with rotating content on its front.  In WP is used the RadCycleHubTile control, that enables you to supply an ItemSource with the data and an ItemTemplate for rendering each item.  The control will indefinetly cycle through the items on its tile front.  Nice!

The end of hapiness

Well, my hapiness came to an end, discovering that the only HubTile control not implemented in the Telerik suite was exactly this one.

So I just had to implement this myself.

What I needed was infact just a control, being able to show rotating content based on an ItemSource and an ItemTemplate.  Then this control could then be used in my scenario as content of a Hubtile or even other scenarios.

The RotatorControl

The control is implemented using an standard ItemsControl, that will handle the data and templating stuff.  When this is initialized, I internally create a series of storyboards for creating crossfade animations between each UI item.  That’s actually it.

In my scenario, it even works better than Telerik’s RadCyclicHubTile control – so I’ll port this for Windows Phone very soon.

RotatorControl.xaml – The XAML for the control

<UserControl x:Class="TriGemini.Controls.RotatorControl" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:MvvmLight4" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="400">
    
    <Grid>
        <ItemsControl x:Name="itemsControl">
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <Canvas/>
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
        </ItemsControl>
    </Grid>
</UserControl>

RotatorControl.xaml.cs – The code for the control

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media.Animation;
 
namespace TriGemini.Controls
{
  /// <summary>
  ///  RotaterControl
  ///  Written By Henrik Brinch (TriGemini)
  ///  www.trigemini.dk / www.henrikbrinch.dk
  ///  Check out www.linkedin.com/in/trigemini
  ///  
  /// License terms:  Feel free to use the code in your own projects, however if distributing
  /// the source code, you must preserve this heading.
  /// 
  /// Please do not post this code on other sites, but link to this blog-post instead.
  /// If you really find it usefull and use it in commercial application, I'd like to hear about
  /// it - and you're very welcome to mention me your application credits 🙂
  /// </summary>
  public sealed partial class RotatorControl : UserControl
  {
    #region Private Fields
    private static readonly DependencyProperty _itemTemplateProperty = DependencyProperty.Register
    (
      "ItemTemplate", typeof(DataTemplate), typeof(RotatorControl), new PropertyMetadata(null, ItemTemplateChanged)
    );

    private static readonly DependencyProperty _itemsSourceProperty = DependencyProperty.Register
    (
      "ItemsSource", typeof(IEnumerable), typeof(RotatorControl), new PropertyMetadata(null, ItemsSourceChanged)
    );

    private static readonly DependencyProperty _fadeSpeedProperty = DependencyProperty.Register
    (
      "FadeSpeed", typeof(TimeSpan), typeof(RotatorControl), new PropertyMetadata(TimeSpan.FromSeconds(1))
    );

    private static readonly DependencyProperty _waitIntervalProperty = DependencyProperty.Register
    (
      "WaitInterval", typeof(TimeSpan), typeof(RotatorControl), new PropertyMetadata(TimeSpan.FromSeconds(3), WaitIntervalChanged)
    );

    //  Hold internal storyboards
    private List<Storyboard> _storyBoards = new List<Storyboard>();

    //  Holds the current storyboard
    private Storyboard _currentStoryBoard;

    //  Internal timer, for the wait interval between the frames
    private DispatcherTimer _timer;

    //  Helper field, to determine if we're during an unload
    private bool _isUnloading;
    #endregion

    #region Public Properties
    /// <summary>
    /// The ItemTemplate for the items to be shown
    /// </summary>
    public DataTemplate ItemTemplate
    {
      get 
      {
        return (DataTemplate)GetValue(_itemTemplateProperty);
      }
      set
      {
        SetValue(_itemTemplateProperty, value);
      }
    }

    /// <summary>
    /// The datasource (IEnumerable) for the data items
    /// </summary>
    public IEnumerable ItemsSource
    {
      get
      {
        return (IEnumerable)GetValue(_itemsSourceProperty);
      }
      set
      {
        SetValue(_itemsSourceProperty, value);
      }
    }

    /// <summary>
    /// Holds the speed for the fade animation
    /// </summary>
    public TimeSpan FadeSpeed
    {
      get 
      {
        return (TimeSpan)GetValue(_fadeSpeedProperty);
      }
      set
      {
        SetValue(_fadeSpeedProperty, value);
      }
    }

    /// <summary>
    /// Holds the time to wait between the frames
    /// </summary>
    public TimeSpan WaitInterval
    {
      get
      {
        return (TimeSpan)GetValue(_waitIntervalProperty);
      }
      set
      {
        SetValue(_waitIntervalProperty, value);
      }
    }

    /// <summary>
    /// Returns the number of items currently bound
    /// </summary>
    public int NumItems
    {
      get
      {
        if(ItemsSource == null)
        {
          return 0;
        }

        var result = 0;
        foreach(var dummy in ItemsSource)
        {
          result++;
        }

        return result;
      }
    }
    #endregion

    #region Constructors
    public RotatorControl()
    {
      this.InitializeComponent();
      this.Loaded += RotatorControl_Loaded;
      this.Unloaded += RotatorControl_Unloaded;
      
      //  Set the internal wait timer up (for waiting between the frames)
      _timer = new DispatcherTimer();
      _timer.Interval = WaitInterval;
      _timer.Tick += _timer_Tick;
    }

    void RotatorControl_Unloaded(object sender, RoutedEventArgs e)
    {
      _isUnloading = true;

      _timer.Stop();
      _timer = null;

      _storyBoards.Clear();
      _currentStoryBoard = null;

      this.Loaded -= RotatorControl_Loaded;
      this.Unloaded -= RotatorControl_Unloaded;
    }
    #endregion

    #region Private Methods
    /// <summary>
    /// Helper function for retreiving the ContentPresenter for a given item in the ItemsPanel
    /// </summary>
    /// <param name="index">The index in the ItemsControl</param>
    /// <returns>The ContentPresenter for the given index</returns>
    private ContentPresenter GetItem(int index)
    {
      var obj = itemsControl.ItemContainerGenerator.ContainerFromIndex(index);
      return obj as ContentPresenter;
    }

    /// <summary>
    /// Helper function for hiding all items (except the first item)
    /// </summary>
    private bool Initialize()
    {
      for(var index = 0; index < NumItems; index++)
      {
        var ctrl = GetItem(index);

        //  If control is null, obviously we're not ready yet ... 
        if(ctrl == null)
        {
          return false;
        }

        ctrl.Opacity = index == 0 ? 1 : 0;          
      }

      return true;
    }

    /// <summary>
    /// Helper function for building a crossfade animation storyboard between to given items
    /// in the ItemsControl
    /// </summary>
    /// <param name="fromIndex">The index to fade from</param>
    /// <param name="toIndex">The index to fade to</param>
    /// <param name="fadeSpeed">The speed to use for the fading</param>
    /// <returns></returns>
    private Storyboard BuildStoryBoard(int fromIndex, int toIndex, TimeSpan fadeSpeed)
    {
      var sb = new Storyboard();

      var ctrlOut = GetItem(fromIndex);

      //  Create the fade out animation and add it to the storyboard
      var faderOut = new DoubleAnimationUsingKeyFrames();
      sb.Children.Add(faderOut);

      Storyboard.SetTarget(faderOut, ctrlOut);
      Storyboard.SetTargetProperty(faderOut, "(UIElement.Opacity)");

      faderOut.KeyFrames.Add(new EasingDoubleKeyFrame()
      {
        KeyTime = KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0)),
        Value = 1
      });

      faderOut.KeyFrames.Add(new EasingDoubleKeyFrame()
      {
        KeyTime = KeyTime.FromTimeSpan(fadeSpeed),
        Value = 0
      });

      //  Create the fade in animation and add it to the storyboard
      var ctrlIn = GetItem(toIndex);
      var faderIn = new DoubleAnimationUsingKeyFrames();
      Storyboard.SetTarget(faderIn, ctrlIn);
      Storyboard.SetTargetProperty(faderIn, "(UIElement.Opacity)");
      sb.Children.Add(faderIn);

      faderIn.KeyFrames.Add(new EasingDoubleKeyFrame()
      {
        KeyTime = KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0)),
        Value = 0
      });

      faderIn.KeyFrames.Add(new EasingDoubleKeyFrame()
      {
        KeyTime = KeyTime.FromTimeSpan(fadeSpeed),
        Value = 1
      });

      //  Add event handler to the storyboard, so we know when it finishes
      sb.Completed += sb_Completed;

      return sb;
    }

    /// <summary>
    /// Helper function for build all storyboards
    /// </summary>
    private void BuildStoryBoards()
    {
      for (int index = 0; index < NumItems-1; index++)
      {
        _storyBoards.Add(BuildStoryBoard(index, index + 1, FadeSpeed));
      }

      _storyBoards.Add(BuildStoryBoard(NumItems - 1, 0, FadeSpeed));

      //  Set the initial currentStoryBoard to the first storyboard
      _currentStoryBoard = _storyBoards[0];
    }

    private void DoInitialize()
    {
      if (ItemsSource != null && ItemTemplate != null)
      {
        if(Initialize())
        {
          if (NumItems > 1)
          {
            BuildStoryBoards();
            _timer.Start();
          }
        }
      }
    }

    /// <summary>
    /// Handles the loaded event, to set up the storyboards from the bound itemssource
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    void RotatorControl_Loaded(object sender, RoutedEventArgs e)
    {
      DoInitialize();
    }

    /// <summary>
    /// Handler for when the a storyboard is completed
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void sb_Completed(object sender, object e)
    {
      if(_isUnloading)
      {
        return;
      }

      // Find the next storyboard to be used
      var nextStoryBoardIndex = _storyBoards.IndexOf(_currentStoryBoard) + 1;

      if (nextStoryBoardIndex > NumItems - 1)
      {
        nextStoryBoardIndex = 0;
      }

      _currentStoryBoard = _storyBoards[nextStoryBoardIndex];

      // Start the timer ...
      _timer.Start();
    }

    /// <summary>
    /// Handles the timer event, which will start the storyboard animation
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    private void _timer_Tick(object sender, object e)
    {
      _currentStoryBoard.Begin();
      _timer.Stop();
    }

    /// <summary>
    /// Notification of ItemTemplate property changed
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="args"></param>
    private static void ItemTemplateChanged(object sender, DependencyPropertyChangedEventArgs args)
    {
      var ctrl = sender as RotatorControl;
      ctrl.itemsControl.ItemTemplate = args.NewValue as DataTemplate;
      ctrl.DoInitialize();
    }

    /// <summary>
    /// Notification of ItemSource property changed
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="args"></param>
    private static void ItemsSourceChanged(object sender, DependencyPropertyChangedEventArgs args)
    {
      var ctrl = sender as RotatorControl;
      ctrl.itemsControl.ItemsSource = args.NewValue;
      ctrl.DoInitialize();
    }

    private static void WaitIntervalChanged(object sender, DependencyPropertyChangedEventArgs args)
    {
      var ctrl = sender as RotatorControl;
      ctrl.itemsControl.ItemsSource = args.NewValue;
      ctrl._timer.Interval = (TimeSpan)args.NewValue;
    }
    #endregion
  }
}

Example usage in XAML:

        <Grid>
            <Grid.Resources>
                <DataTemplate x:Key="MyTemplate">
                    <Grid>
                        <TextBlock Text="{Binding Name}" FontSize="24"/>
                    </Grid>
                </DataTemplate>
            </Grid.Resources>
            
            <TriGemini:RotatorControl x:Name="rotator" ItemTemplate="{StaticResource MyTemplate}"
                                      Width="500" Height="500"
                                      FadeSpeed="0:0:1" WaitInterval="0:0:2" />
        </Grid>

E.g. initializing it with data from codebehind:

private List<Person> _list = new List<Person>();

public MainPage()
{
InitializeComponent();

_list.Add(new Person(){Name = "Henrik Brinch"});
_list.Add(new Person() { Name = "John Doe" });
_list.Add(new Person() { Name = "Mary Poppins" });
_list.Add(new Person() { Name = "Bill Gates" });
_list.Add(new Person() { Name = "Queen Mary" });

rotator.ItemsSource = _list;