Create a custom user control using Xaml and C# for Windows 8

VSeb (2)

Edit : Here is an update : How to Migrate this control to Windows 8.1 and Windows Phone 8.1 (WinRT)

As you may know, you can easily develop native applications for Windows 8 using XAML and C# (Or VB.NET by the way Clignement d'œil).

Among a lot of other things, XAML allows you to create custom control to factorize your UI.

Today, I would like to show you how to create this kind of control and to do so, please let me introduce you with the final version of the XAML Carousel Control:

<source ="=""><source "="">

As you can see, this is a 3D carousel that can be used to display a long list of items (with a great (if you like it Sourire) mirror).

(If you want to know how to achieve the same goal but with WinJS and HTML, you can read this article written by my colleague David Catuhe: https://blogs.msdn.com/b/eternalcoding/archive/2013/03/19/create-a-custom-user-control-using-javascript-and-winjs-for-windows-8.aspx)

So you may now wonder how you can develop such a beautiful (!!) control. The response will be decomposed on 6 parts:

Setting up the layout

The control itself is built using on top of a Canvas, called Carousel. The Carrousel contains a DataTemplate property called ItemTemplate, which describes the visual structure of each item in the carousel.

In the sample you will see that I made a mirror defined by the original image on which I made a CompositeTransform (ScaleY) and a PlaneProjection (RotationX)

Finally, the Carrousel has a black rectangle to delimit the perspective :

image

Thanks to DataTemplate and Dependency Properties you can style every part of the control just by setting properties and declaring an item template.

For example here are two different styles : without mirror, and no Rotation. And an other with a 90° Rotation max Depth and max TranslateX (Click on each image to view original picture)

image image

Declaring and instancing the control

A simple as :

 <ctrl:LightStone />

By the way, you can configure severals properties:

ItemsSource
On the same model than a ListBox or a ListView, you need to configure an ItemSource. Prefer an ObservableCollection<T> as you can add and remove items during running of your application (see Data Binding later in this article)

Dependency Properties

  • TransitionDuration : animation duration (ms)
  • Depth : Depth on the non selected items
  • Rotation : rotation of non selected items
  • TranslateX : translation on the X axis of non selected items
  • TranslateY : translateion on the Y axis of all items

 

EasingFunction
You can configure an easing function for all items. Here is the MSDN Documentation to see all easing function you can use.

I decided to use a CubicEase with an easing mode set to “EaseOut”

CubicEase EasingMode graphs.

Templates

Xaml infrastructure provides a simple way to implement an ItemTemplate. To go further on the subject, you will see in the code the method to render an item binded with a DataTemplate.

You need to implement a data Template property which will be declared in your xaml control.  In the sample provided, you will see that I declare an image, and a mirrored image with an opacity.

 <ctrl:LightStone.ItemTemplate>
     <DataTemplate>
         <Grid>
             <Grid.RowDefinitions>
                 <RowDefinition Height="Auto"/>
                 <RowDefinition Height="Auto"/>
             </Grid.RowDefinitions>
             <Image Source="{Binding BitmapImage}" Width="600" VerticalAlignment="Bottom" 
                Stretch="Uniform"></Image>

             <Rectangle Grid.Row="1" Fill="Black" Margin="0,10" ></Rectangle>

             <Image Grid.Row="1" VerticalAlignment="Top" Width="600"  Margin="0,10" 
                Source="{Binding BitmapImage}" Stretch="Uniform" 
                Opacity="0.1" >
                 <Image.RenderTransform>
                     <CompositeTransform ScaleY="1" />
                 </Image.RenderTransform>
                 <Image.Projection>
                     <PlaneProjection RotationX="180"></PlaneProjection>
                 </Image.Projection>
             </Image>

         </Grid>
     </DataTemplate>
 </ctrl:LightStone.ItemTemplate>

Implementation

The Itemtemplate property is a DataTemplate. Here is the code. No need to declare a dependency property because we don’t want to change it during running.

 /// <summary>
 /// Item Template 
 /// </summary>
 public DataTemplate ItemTemplate
 {
     get
     {
         return itemTemplate;
     }
     set
     {
         itemTemplate = value;

     }
 }

During binding, a method is responsible of binding and rendering, thanks to the method LoadContent, which will create all the UIElements :

 /// <summary>
/// Bind all Items
/// </summary>
private void Bind()
{
    if (ItemsSource == null)
        return;

    this.Children.Clear();
    this.internalList.Clear();

    foreach (object item in ItemsSource)
        this.CreateItem(item);

    this.Children.Add(rectangle);
}

 /// <summary>
 /// Create an item (Load data template and bind)
 /// </summary>
 private FrameworkElement CreateItem(object item, Double opacity = 1)
 {
     FrameworkElement element = ItemTemplate.LoadContent() as FrameworkElement;
     if (element == null)
         return null;

     element.DataContext = item;
     element.Opacity = opacity;
     element.RenderTransformOrigin = new Point(0.5, 0.5);

     PlaneProjection planeProjection = new PlaneProjection();
     planeProjection.CenterOfRotationX = 0.5;
     planeProjection.CenterOfRotationY = 0.5;
     element.Projection = planeProjection;

     this.internalList.Add(element);
     this.Children.Add(element);

     return element;
 }

Data Binding

The mechanism of data binding is provided by a dependency properties called ItemsSource.

By the way, you need to provide your own collection. You ll find in the sample an ObservableCollection of Data

Here is the Data class (defined by a BitmapImage and a Title)

 public class Data
    {
        public BitmapImage BitmapImage { get; set; }
        public String Title { get; set; }
    }

Here is the ObservableCollection<Data> used :

 public ObservableCollection<Data> Datas { get; set; }

public MainPageViewModel()
{
     this.Datas = new ObservableCollection<Data>();
     this.Datas.Add(new Data { BitmapImage = new BitmapImage(new Uri("ms-appx:///Assets/pic01.jpg", UriKind.Absolute)), Title = "Wall 05" });
     this.Datas.Add(new Data { BitmapImage = new BitmapImage(new Uri("ms-appx:///Assets/pic02.jpg", UriKind.Absolute)), Title = "Wall 06" });
     this.Datas.Add(new Data { BitmapImage = new BitmapImage(new Uri("ms-appx:///Assets/pic03.jpg", UriKind.Absolute)), Title = "Wall 07" });
}

Implementation

I used a dependency property to get a callback method when my ItemsSource change, to allow a “re bind”

Note the Handler on the ItemsSourceChangedCallback method:

 /// <summary>
/// Items source : Better if ObservableCollection :)
/// </summary>
public IEnumerable<Object> ItemsSource
{
    get { return (IEnumerable<Object>)GetValue(ItemsSourceProperty); }
    set { SetValue(ItemsSourceProperty, value); }
}

// Using a DependencyProperty as the backing store for ItemsSource.  
//This enables animation, styling, binding, etc...
public static readonly DependencyProperty ItemsSourceProperty =
    DependencyProperty.Register("ItemsSource",
                typeof(IEnumerable<Object>),
                typeof(LightStone),
                new PropertyMetadata(0, ItemsSourceChangedCallback));

The ItemsSourceChangedCallback allows me to implement my own mechanism when adding or deleting one or multiple items:

 private static void ItemsSourceChangedCallback(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args)
 {
     if (args.NewValue == null)
         return;

     if (args.NewValue == args.OldValue)
         return;

     LightStone lightStone = dependencyObject as LightStone;

     if (lightStone == null)
         return;

     var obsList = args.NewValue as INotifyCollectionChanged;

     if (obsList != null)
     {
         obsList.CollectionChanged += (sender, eventArgs) =>
             {
                 switch (eventArgs.Action)
                 {
                     case NotifyCollectionChangedAction.Remove:
                         foreach (var oldItem in eventArgs.OldItems)
                         {
                             for (int i = 0; i < lightStone.internalList.Count; i++)
                             {
                                 var fxElement = lightStone.internalList[i] as FrameworkElement;
                                 if (fxElement == null || fxElement.DataContext != oldItem) continue;
                                 lightStone.RemoveAt(i);
                             }
                         }

                         break;
                     case NotifyCollectionChangedAction.Add:
                         foreach (var newItem in eventArgs.NewItems)
                             lightStone.CreateItem(newItem, 0);
                         break;
                 }
             };
     }

     lightStone.Bind();
 }

User Input and Animations

Inputs

To respond to user inputs, you just have to handle pointer events.

Note: To go further, you can use the GestureRecognizer, but in this sample we just want to handle simple gestures.

The principle is simple: if the user moves his finger/mouse/pen over more than 40 pixels, we can change the current item according to the direction of the movement.

Here is the code :

 /// <summary>
/// Initial pressed position
/// </summary>
private void OnPointerPressed(object sender, PointerRoutedEventArgs args)
{
    initialOffset = args.GetCurrentPoint(this).Position.X;
}

/// <summary>
/// Calculate Behavior
/// </summary>
private void OnPointerReleased(object sender, PointerRoutedEventArgs pointerRoutedEventArgs)
{
    // Minimum amount to declare as a manipulation
    const int moveThreshold = 40;

    // last position
    var clientX = pointerRoutedEventArgs.GetCurrentPoint(this).Position.X;

    // Here is a "Tap on Item"
    if (!(Math.Abs(clientX - initialOffset) > moveThreshold))
        return;

    isIncrementing = (clientX < initialOffset);

    // Here is a manipulation
    if (clientX < initialOffset)
    {
        this.SelectedIndex = (this.SelectedIndex < (this.internalList.Count - 1))
                                 ? this.SelectedIndex + 1
                                 : this.SelectedIndex;

    }
    else if (this.SelectedIndex > 0)
    {
        this.SelectedIndex--;
    }

    initialOffset = clientX;
}

I used an other method to respond on the touch event (not a gesture, just a tap action)

To get the correct tapped item, I used the UIElement.TranformToVisual method which returns a transform object that can be used to transform coordinates from the UIElement:

 var rect = child.TransformToVisual(this).TransformBounds(new Rect(0, 0, child.DesiredSize.Width, child.DesiredSize.Height));

Here is the complete method:

 /// <summary>
/// Tap an element
/// </summary>
private void OnTapped(object sender, TappedRoutedEventArgs args)
{
    var positionX = args.GetPosition(this).X;
    for (int i = 0; i < this.internalList.Count; i++)
    {
        var child = internalList[i];
        var rect = child.TransformToVisual(this).TransformBounds(new Rect(0, 0, child.DesiredSize.Width, child.DesiredSize.Height));

        if (!(positionX >= rect.Left) || !(positionX <= (rect.Left + rect.Width))) continue;
        
        isIncrementing = (i > this.SelectedIndex);

        this.SelectedIndex = i;
        return;
    }
}

Animations

The transition on each item is provided by a wonderful feature available on each UIElement : the UIElement.Projection

Projection gets or sets the perspective (3-D effect) to apply when rendering this element. When you set a projection, you can choose beetween a Matrix3DProjection or a PlaneProjection. I used the plane projection on this sample.

Each item will animate properties of its PlaneProjection. According to the dependency properties you provided in the xaml control, of course Sourire

image

Thanks to the Animation mechanism of XAML, we will animate all items with a single Storyboard :

 /// <summary>
/// Update all positions. Launch every animations on all items with a unique StoryBoard
/// </summary>
private void UpdatePosition()
{
    if (storyboard.GetCurrentState() != ClockState.Stopped)
    {
        storyboard.SkipToFill();
        storyboard.Stop();
        storyboard = new Storyboard();
    }
   
   
    isUpdatingPosition = true;

    for (int i = 0; i < this.internalList.Count; i++)
    {
        var item = internalList[i];

        PlaneProjection planeProjection = item.Projection as PlaneProjection;

        if (planeProjection == null)
            continue;

        // Get properties
        var depth = (i == this.SelectedIndex) ? 0 : -(this.Depth);
        var rotation = (i == this.SelectedIndex) ? 0 : ((i < this.SelectedIndex) ? Rotation : -Rotation);
        var offsetX = (i == this.SelectedIndex) ? 0 : (i - this.SelectedIndex) * desiredWidth;
        var translateY = TranslateY;
        var translateX = (i == this.SelectedIndex) ? 0 : ((i < this.SelectedIndex) ? -TranslateX : TranslateX);

        // CenterOfRotationX
        // to Get good center of rotation for SelectedIndex, must know the animation behavior
        int centerOfRotationSelectedIndex = isIncrementing ? 1 : 0;
        var centerOfRotationX = (i == this.SelectedIndex) ? centerOfRotationSelectedIndex : ((i > this.SelectedIndex) ? 1 : 0);
        planeProjection.CenterOfRotationX = centerOfRotationX;

        // Dont animate all items
        var inf = this.SelectedIndex - (MaxVisibleItems * 2);
        var sup = this.SelectedIndex + (MaxVisibleItems * 2);

        if (i < inf || i > sup)
            continue;

        // Zindex and Opacity
        var deltaFromSelectedIndex = Math.Abs(this.SelectedIndex - i);
        int zindex = (this.internalList.Count * 100) - deltaFromSelectedIndex;
        Canvas.SetZIndex(item, zindex);
        Double opacity = 1d - (Math.Abs((Double)(i - this.SelectedIndex) / (MaxVisibleItems + 1)));

        var newVisibility = deltaFromSelectedIndex > MaxVisibleItems
                       ? Visibility.Collapsed
                       : Visibility.Visible;

        // Item already present
        if (item.Visibility == newVisibility)
        {
            storyboard.AddAnimation(item, TransitionDuration, rotation, "(UIElement.Projection).(PlaneProjection.RotationY)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, depth, "(UIElement.Projection).(PlaneProjection.GlobalOffsetZ)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, translateX, "(UIElement.Projection).(PlaneProjection.GlobalOffsetX)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, offsetX, "(UIElement.Projection).(PlaneProjection.LocalOffsetX)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, translateY, "(UIElement.Projection).(PlaneProjection.GlobalOffsetY)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, opacity, "Opacity", this.EasingFunction);
        }
        else if (newVisibility == Visibility.Visible)
        {
            // This animation will occur in the ArrangeOverride() method
            item.Visibility = newVisibility;
            item.Opacity = 0d;
        }
        else if (newVisibility == Visibility.Collapsed)
        {
            storyboard.AddAnimation(item, TransitionDuration, rotation, "(UIElement.Projection).(PlaneProjection.RotationY)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, depth, "(UIElement.Projection).(PlaneProjection.GlobalOffsetZ)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, translateX, "(UIElement.Projection).(PlaneProjection.GlobalOffsetX)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, offsetX, "(UIElement.Projection).(PlaneProjection.LocalOffsetX)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, translateY, "(UIElement.Projection).(PlaneProjection.GlobalOffsetY)", this.EasingFunction);
            storyboard.AddAnimation(item, TransitionDuration, 0d, "Opacity", this.EasingFunction);
            storyboard.Completed += (sender, o) =>
                item.Visibility = Visibility.Collapsed;
        }
    }

    // When storyboard completed, Invalidate
    storyboard.Completed += (sender, o) =>
        {
            this.isUpdatingPosition = false;
            this.InvalidateArrange();
        };

    storyboard.Begin();
}

To make it easier and simple to use, you will find an extension method to set a DoubleAnimation directly on a Storyboard:

 public static void AddAnimation(this Storyboard storyboard, DependencyObject element,
                                 int duration,double fromValue, double toValue, String propertyPath,
                                 EasingFunctionBase easingFunction = null)
{
    DoubleAnimation timeline = new DoubleAnimation();
    timeline.From = fromValue;
    timeline.To = toValue;
    timeline.Duration = TimeSpan.FromMilliseconds(duration);
    if (easingFunction != null)
        timeline.EasingFunction = easingFunction;

    storyboard.Children.Add(timeline);

    Storyboard.SetTarget(timeline, element);
    Storyboard.SetTargetProperty(timeline, propertyPath);
}

Optimizations

Finally, just to be sure our control works well on very low end hardware, we must ensure that only visible items are effectively handled by the control, and by the way animate only items which are visible.

In order to achieve this goal, we just have to remove items from the control when their opacity is inferior or equal to 0. Obviously you must have to re-inject them when they become visible again:

 // Dont animate all items
var inf = this.SelectedIndex - (MaxVisibleItems * 2);
var sup = this.SelectedIndex + (MaxVisibleItems * 2);

if (i < inf || i > sup)
    continue;

// Get if item is visible or not
var newVisibility = deltaFromSelectedIndex > MaxVisibleItems
               ? Visibility.Collapsed
               : Visibility.Visible;

By the way in the ArrangeOverride method, we can check if an item appear or not :

 // Items appears
if (container.Visibility == Visibility.Visible && container.Opacity == 0d)
{
    localStoryboard.AddAnimation(container, TransitionDuration, rotation, "(UIElement.Projection).(PlaneProjection.RotationY)", this.EasingFunction);
    localStoryboard.AddAnimation(container, TransitionDuration, depth, "(UIElement.Projection).(PlaneProjection.GlobalOffsetZ)", this.EasingFunction);
    localStoryboard.AddAnimation(container, TransitionDuration, translateX, "(UIElement.Projection).(PlaneProjection.GlobalOffsetX)", this.EasingFunction);
    localStoryboard.AddAnimation(container, TransitionDuration, offsetX, "(UIElement.Projection).(PlaneProjection.LocalOffsetX)", this.EasingFunction);
    localStoryboard.AddAnimation(container, TransitionDuration, translateY, "(UIElement.Projection).(PlaneProjection.GlobalOffsetY)", this.EasingFunction);
    localStoryboard.AddAnimation(container, TransitionDuration, 0, opacity, "Opacity", this.EasingFunction);
}
else
{

    container.Opacity = opacity;
}

You’ll find the complete code provided with this post.

Happy coding.

LightStone.zip