Monday, January 17, 2011

Silverlight 4.0 Tutorial (11 of N): Roulette Behavior

You can find a list of the previous tutorial posts here

Continuing our RegistrationBooth Application…

In most of the offline events we hold, there are some giveaways for the audience (most of the time the prize is XBOX). Picking a winner is manual process where we collect the feedback forms, put it in a big box/bag, shake the box/bag and turn it upside down multiple times to make sure that the forms are scrambled enough so that when we pick a form it will be a random one Smile, not the best process I know.

So in this post we will add to our RegistrationBooth application the ability to perform a random draw. we already have our attendees listed in a PathListBox, wouldn’t it be nice if this list can spin like a roulette till it stops at the lucky winner, let’s see how we can implement this. I thought it’s better if we implement this feature as a behavior so that we can easily apply it to any PathListBox, I will call this behavior RouletteBehavior.

To create a behavior we need to derive from the abstract class Bahavior<T> available in the System.Windows.Interactivity assembly, where T is the control or UI Element that you will attach this behavior to. in our case our behavior will derive from Bahavior<PathListBox>

  1. public class RouletteBehavior : Behavior<PathListBox>
  2. {
  3. }

What properties this behavior will expose? first there should be a way to start the roulette, for maximum usability we will add a property StartCommand of type ICommand, so that users of this behavior can use any type of trigger to start the roulette

  1. /// <summary>
  2. /// Gets a command that starts the roulette animation.
  3. /// </summary>
  4. public ICommand StartCommand
  5. {
  6. get;
  7. private set;
  8. }

We need another property to determine how long the roulette will spin, so we will add a property Duration of type Duration, I will make this property a DependencyProperty to make it open for any advanced scenario that the users of this behavior might want to implement.

  1. /// <summary>
  2. /// Gets or sets the approximate duration of the roulette.
  3. /// </summary>
  4. public Duration Duration
  5. {
  6. get { return (Duration)this.GetValue(RouletteBehavior.DurationProperty); }
  7. set { this.SetValue(RouletteBehavior.DurationProperty, value); }
  8. }
  9. public static readonly DependencyProperty DurationProperty =
  10. DependencyProperty.Register("Duration", typeof(Duration), typeof(RouletteBehavior),
  11. new PropertyMetadata(new Duration(new TimeSpan(0, 0, 1))));

To be able to customize the spinning speed we will expose a property called Speed (of type integer) that determines the speed of spinning it terms of items/second

  1. /// <summary>
  2. /// Gets or sets the speed of the roulette (Items/Sec).
  3. /// </summary>
  4. public int Speed
  5. {
  6. get { return (int)this.GetValue(RouletteBehavior.SpeedProperty); }
  7. set { this.SetValue(RouletteBehavior.SpeedProperty, value); }
  8. }
  9. public static readonly DependencyProperty SpeedProperty =
  10. DependencyProperty.Register("Speed", typeof(int), typeof(RouletteBehavior),
  11. new PropertyMetadata(5));

There are two protected method that we need to override, OnAttached and OnDetaching these methods are called when attaching and detaching the behavior to a PathListBox, the AssociatedObject property of the Behavior gives you access to the PathListBox you are attaching the behavior to, so what we do is we change the WrapItems property of the PathListBox when attaching the behavior and we set it back to the original value when detaching the behavior

  1. protected override void OnAttached()
  2. {
  3. base.OnAttached();
  4. _originalWrapItems = AssociatedObject.WrapItems;
  5. AssociatedObject.WrapItems = true;
  6. }
  7. protected override void OnDetaching()
  8. {
  9. base.OnDetaching();
  10. AssociatedObject.WrapItems = _originalWrapItems;
  11. }

To create the spinning animation we will need a storyboard, so we will add a member variable of type Storyboard to our behavior class and in the constructor we will initialize the storyboard and attach a method called StartRoulette to the StartCommand

  1. public RouletteBehavior()
  2. {
  3. this.StartCommand = new ActionCommand(this.StartRoulette);
  4. this.rouletteAnimationStoryboard = new Storyboard();
  5. }

The StartRoulette method contains the logic for animating the PathListBox items, we do this by creating a DoubleAnimation to animate the Start property of the LayoutPath of the PathListBox (currently this behavior supports only one layout path), based on the list items count, the Speed property, the Duration and a generated random number we calculate the final value of the Start property that we will assign to the To property of the animation.

We assign a Circle out easing function to the animation so that the speed of spinning is decreased over the duration of the animation till it stops.

  1. private void StartRoulette()
  2. {
  3. PathListBox pathListBox = this.AssociatedObject as PathListBox;
  4. int itemsCount = pathListBox.Items.Count;
  5. if (pathListBox == null || pathListBox.Items == null || itemsCount <= 1)
  6. {
  7. return;
  8. }
  9. //Support one layout path only?
  10. PropertyPath propertyPath = new PropertyPath(String.Format("(ec:PathListBox.LayoutPaths)[{0}].(ec:LayoutPath.Start)", 0));
  11. DoubleAnimation startPropertyAnimation = new DoubleAnimation();
  12. Storyboard.SetTarget(startPropertyAnimation, pathListBox);
  13. Storyboard.SetTargetProperty(startPropertyAnimation, propertyPath);
  14. this.rouletteAnimationStoryboard.Stop();
  15. this.rouletteAnimationStoryboard.Children.Clear();
  16. this.rouletteAnimationStoryboard.Children.Add(startPropertyAnimation);
  17. int randomOffset = new Random(Environment.TickCount).Next(1, 100);
  18. Duration calculatedDuration = this.Duration;
  19. int nIterations = 1;
  20. if (this.Duration.TimeSpan.TotalSeconds <= 1)
  21. {
  22. calculatedDuration = new Duration(new TimeSpan(0, 0, 1));
  23. }
  24. nIterations = ((int)calculatedDuration.TimeSpan.TotalSeconds * this.Speed) / itemsCount;
  25. if (nIterations < 1)
  26. nIterations = 1;
  27. startPropertyAnimation.Duration = calculatedDuration;
  28. startPropertyAnimation.To = (double)(100 * nIterations + randomOffset) / 100.0;
  29. var easing = new CircleEase();
  30. easing.EasingMode = EasingMode.EaseOut;
  31. startPropertyAnimation.EasingFunction = easing;
  32. this.rouletteAnimationStoryboard.Begin();
  33. }

If we try this now the items will spin (the items are not actually spinning we are changing Start property of the layout path and that makes it look like spinning). but we didn’t specify yet how we will select the winning item, so what we need to do is that while the items are virtually spinning we need to change the selected item or more specifically change the SelectedIndex property. The problem is that we need to keep changing the SelectedIndex value as long as the Start property value is changing, how we do this by binding the two properties. so we will add the following code to the OnAttached method

  1. Binding binding = new Binding();
  2. PropertyPath propertyPath = new PropertyPath("LayoutPaths[0].Start");
  3. binding.Path = propertyPath;
  4. binding.RelativeSource = new RelativeSource(RelativeSourceMode.Self);
  5. var converter = new StartToIndexConverter(AssociatedObject);
  6. binding.Converter = converter;
  7. binding.Mode = BindingMode.TwoWay;
  8. AssociatedObject.SetBinding(ListBox.SelectedIndexProperty, binding);

The code binds the SelectedIndex property to the Start property of the first LayoutPath (this behavior supports 1 LayoutPath), we need a custom IValueConverter to convert the value of the Start property to a correct SelectedIndex property that’s where the StartToIndexConverter comes into play, the two functions that we need to implement in the IValueConverter interface are shown below

  1. public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
  2. {
  3. if (this._pathListBox == null)
  4. return 0;
  5. int index = 0;
  6. double capacity = 0;
  7. if (double.IsNaN(this._pathListBox.LayoutPaths[0].Capacity))
  8. capacity = this._pathListBox.Items.Count;
  9. else
  10. capacity = this._pathListBox.LayoutPaths[0].Capacity;
  11. double start = (double)value;
  12. double shift = _previousValue - start;
  13. double spacing = (this._pathListBox.LayoutPaths[0].Span / capacity);
  14. Debug.WriteLine("Current Start:{0}", start.ToString());
  15. if (Math.Abs(shift) > spacing)
  16. {
  17. _previousValue = start;
  18. if (shift > 0)
  19. if (this._pathListBox.SelectedIndex >= (this._pathListBox.Items.Count - 1))
  20. index = 0;
  21. else
  22. index = this._pathListBox.SelectedIndex + 1;
  23. else
  24. if (this._pathListBox.SelectedIndex <= 0)
  25. index = this._pathListBox.Items.Count - 1;
  26. else
  27. index = this._pathListBox.SelectedIndex - 1;
  28. }
  29. else
  30. index = this._pathListBox.SelectedIndex;
  31. PathListBoxItem itemToBeSelected = (PathListBoxItem)this._pathListBox.ItemContainerGenerator.ContainerFromIndex(index);
  32. if (itemToBeSelected != null && !itemToBeSelected.IsArranged)
  33. {
  34. if (shift > 0)
  35. index = GetFirstArrangedIndex(0);
  36. else
  37. index = GetLastArrangedIndex(0);
  38. }
  39. return index;
  40. }
  41. public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
  42. {
  43. return this._pathListBox.LayoutPaths[0].Start;
  44. }

Basically what this conversion does is that if the Start value is increased/decreased by an amount that’s greater than the spacing between the items, the converter increments/decrements the SelectedIndex value to move to the next/previous item. we need to make sure that SelectedIndex value remains within the range of the items that are actually visible on the Path that’s why we are checking the PathListBoxItem.IsArranged property.

Once we finish the Behavior, you can drag it from the Assets window and drop it on your PathListBox, then you can change its properties accordingly

RouletteBehavior properties

PS: This behavior will not work correctly if you are using PathListBoxScrollBehavior.ScrollSelectedCommand

You can download the RegistrationBooth from here.

And you can download a Demo for using the RouletteBehavior from here

1 comment:

Anonymous said...

Excellent post! help me alot