Category Archives: Controls

Display item count in WPF ListBox and reorder using ButtonSpinner

I ran into a situation the other day where I needed to be able to display Trips to a user in the order that they were going to be executed and have them be able to move them up and down the list. I liked how Netflix tackled the issue, but wanted to give the user a little more control.

As it were, I could not find a decent solution to the problem. Google and StackOverflow mostly turned up hacks that were sure to fall apart. After much trial and error, I was able to use an IMultiValueConverter that accepted a ListBox and an item in the ListBox. From there, I could do an IndexOf and return that value back to a TextBlock for display.

The second issue of reordering I solved using the ButtonSpinner in the WPF Extended Toolkit. I had to trick it because I wanted the “down” spinner to move the item to the next higher index and the “up” spin to actually move the item down the list (from an index’s point of view). This functionality requires that the binding list is an ObservableCollection that exposes a Move event and also fires all the INotifyPropertyChanged events that WPF uses for UI refreshing.

Note that the ButtonSpinner is a content control and looks a little ugly when used by itself. The trick is to set the Padding to zero.

So now we have a ListBox that displays the current order of the items and allows the user to move the items up and down the list (which changes the order label) using the ButtonSpinners.

WPF ListBox

In my production code, I actually hide the ButtonSpinners until the user mouses over them, but I didn’t want to clutter the code. I’ll save that for a later post.

The key parts of the code are listed below. The entire project can be downloaded here. Remember to unblock after downloading.

The Converter that takes a ListBox and an item in the ListBox:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Data;
using System.Windows.Controls;

namespace ReorderingListBox
{
    public class ListBoxIndexConverter : IMultiValueConverter
    {
        #region IMultiValueConverter Members

        public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            var item = values[0];

            if (item == null)
            {
                return null;
            }

            var lb = values[1] as ListBox;
            if (lb == null)
            {
                return null;
            }

            //make it 1 based
            var rv = lb.Items.IndexOf(item) + 1;

            //very important because control
            //we are binding to is expecting a string
            return rv.ToString();
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture)
        {
            throw new NotImplementedException();
        }

        #endregion
    }
}

The XAML with the ListBox and ButtonSpinner:

<Window x:Class="ReorderingListBox.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:converters="clr-namespace:ReorderingListBox"
        xmlns:wpf="http://schemas.xceed.com/wpf/xaml/toolkit"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid.Resources>
            <!--reference to our multi converter-->
            <converters:ListBoxIndexConverter x:Key="listBoxIndexConverter" />
            <!--this defines how each item in the listbox will layout-->
            <DataTemplate x:Key="placesTemplate">
                <StackPanel Orientation="Horizontal">
                    <!--shows the current order in the listbox-->
                    <TextBlock Width="15" Name="textBlockOrder">
                        <TextBlock.Text>
                            <!--this is a multibinding, we are into the converter-->
                            <!--the current object that we are bound to (a place)-->
                            <!--and the listbox that it lives in-->
                            <MultiBinding Converter="{StaticResource listBoxIndexConverter}">
                                <Binding />
                                <Binding ElementName="listBox" />
                            </MultiBinding>
                        </TextBlock.Text>
                    </TextBlock>
                    <!--control from http://wpftoolkit.codeplex.com/-->
                    <!--note the padding has to be explicitly set-->
                    <!--otherwise it looks horrible more info about that-->
                    <!--http://timhibbard.com/blog/2012/05/08/default-padding-issue-with-buttonspinner/-->
                    <wpf:ButtonSpinner Padding="0"
                                       Margin="3"
                                       Spin="spinner_Spin" />
                    <TextBlock Text="{Binding}" />
                </StackPanel>
            </DataTemplate>
        </Grid.Resources>
        <ListBox Name="listBox"
                 ItemTemplate="{StaticResource placesTemplate}" />

    </Grid>
</Window>

The code behind that constructs the list that binds to the ListBox and the event that listens to the ButtonSpinner.Spin event:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Collections.ObjectModel;
using Xceed.Wpf.Toolkit;

namespace ReorderingListBox
{
    ///
<summary> /// Interaction logic for MainWindow.xaml
 /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            //construct our backing list to bind to
            var placesIWantToLive = new ObservableCollection
            {
                "Minneapolis",
                "San Diego",
                "Charleston",
                "Philadelphia"
            };

            this.listBox.ItemsSource = placesIWantToLive;
        }

        private void spinner_Spin(object sender, SpinEventArgs e)
        {
            ButtonSpinner spinner = sender as ButtonSpinner;
            //this will find the textblock in the same listbox item
            TextBlock textOrder = spinner.FindName("textBlockOrder") as TextBlock;
            ObservableCollection places = this.listBox.ItemsSource as ObservableCollection;
            //back to zero based - remember we store the current index in this textblock
            int current = int.Parse(textOrder.Text) - 1;
            int destination = 0;
            switch (e.Direction)
            {
                case SpinDirection.Decrease:
                    //because we want it to go "down" the list
                    destination = current + 1;
                    break;
                case SpinDirection.Increase:
                    //because we want it to go "up" the list
                    destination = current - 1;
                    break;
                default:
                    //we'll never hit here, but else statements are ugly
                    //and not as obvious as switch statements
                    break;
            }
            if (destination < 0)             
            {                 
                 //can't more the first item any higher so exit                 
                 return;             
            }             
            if (destination > places.Count + 1)
            {
                //can't move it any more down so exit
                return;
            }

            //this is where the magic happens
            places.Move(current, destination);
            //this refreshes the textblocks holding the index
            this.listBox.Items.Refresh();
            //highlight the item that was moved so it is obvious to the user
            this.listBox.SelectedIndex = destination;
        }
    }
}

WPF TimeSpinner Control

spinner

As I discussed in my last post, we created a TimeSpinner control based on the Extended WPF Toolkit’s ButtonSpinner.

Now, the toolkit has a DateTimeUpDown control that will display a date or time and allow the user to click the spinners for each time part, but I needed a little more. I needed the raw text to be editable and I wanted the spinners to just modify the minutes portion. I also wanted to have special parsing based on the number of characters entered.

4 chars:
Parse as military time.

3 chars: 
Parse as modified military time, but only assume waking hours.

So 933 becomes 9:33 AM

345 becomes 3:45 PM

We put the cut-off at 7. Anything before 7 assumes PM anything 7 or after assumes AM.

2 chars:
Assuming shortened military time.

12 = 12:00 PM

15 = 3:00 PM

1 char:
Parses as shortened military time, but assumes waking hours like 3 chars.

1 = 1:00 PM

8 = 8:00 AM

 

The TimeSpinnerControl exposes one bindable property, Time. So it would be consumed like:

   1: <runtime:TimeSpinnerControl Time="{Binding Path=ReturnTime}"

   2:                             x:Name="spinnerReturnTime"/>

 

So, here is what we did:

TimeSpinnerControl.xaml:

   1: <UserControl x:Class="ParaPlan.Controls.TimeSpinnerControl"

   2:              xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

   3:              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"

   4:              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 

   5:              xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 

   6:              xmlns:wpftoolkit="clr-namespace:Microsoft.Windows.Controls;assembly=WPFToolkit.Extended"

   7:              mc:Ignorable="d">

   8:     <wpftoolkit:ButtonSpinner Spin="Time_Spin" IsTabStop="False">

   9:         <TextBox Name="textTime" 

  10:                  MinWidth="60" 

  11:                  Margin="0"

  12:                  MouseDoubleClick="textTimeDoubleClick"

  13:                  Keyboard.GotKeyboardFocus="textTimeGotKeyboardFocus"

  14:                  MouseEnter="textTimeMouseEnter"

  15:                  Keyboard.LostKeyboardFocus="textTimeLostKeyboardFocus"/>

  16:     </wpftoolkit:ButtonSpinner>

  17: </UserControl>

 

TimeSpinnerControl.xaml.cs:

   1: using System;

   2: using System.Collections.Generic;

   3: using System.Linq;

   4: using System.Text;

   5: using System.Windows;

   6: using System.Windows.Controls;

   7: using System.Windows.Data;

   8: using System.Windows.Documents;

   9: using System.Windows.Input;

  10: using System.Windows.Media;

  11: using System.Windows.Media.Imaging;

  12: using System.Windows.Navigation;

  13: using System.Windows.Shapes;

  14: using ParaPlan.Extensions;

  15: using System.Globalization;

  16:  

  17: namespace ParaPlan.Controls

  18: {

  19:     /// <summary>

  20:     /// Interaction logic for TimeSpinnerControl.xaml

  21:     /// </summary>

  22:     public partial class TimeSpinnerControl : UserControl

  23:     {

  24:         public TimeSpinnerControl()

  25:         {

  26:             InitializeComponent();

  27:         }

  28:  

  29:         #region Time Property

  30:  

  31:         /// <summary>

  32:         /// Time Dependency Property

  33:         /// </summary>

  34:         public static readonly DependencyProperty TimeProperty =

  35:             DependencyProperty.Register("Time", typeof(DateTime), typeof(TimeSpinnerControl),

  36:                 new FrameworkPropertyMetadata(DateTime.MinValue,

  37:                     FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,

  38:                     new PropertyChangedCallback(OnTimeChanged)));

  39:  

  40:         /// <summary>

  41:         /// Gets or sets the Time property.

  42:         /// </summary>

  43:         public DateTime Time

  44:         {

  45:             get { return (DateTime)GetValue(TimeProperty); }

  46:             set { SetValue(TimeProperty, value); }

  47:         }

  48:  

  49:         /// <summary>

  50:         /// Handles changes to the Time property.

  51:         /// </summary>

  52:         private static void OnTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)

  53:         {

  54:             ((TimeSpinnerControl)d).OnTimeChanged(e);

  55:         }

  56:  

  57:         /// <summary>

  58:         /// Provides derived classes an opportunity to handle changes to the Time property.

  59:         /// </summary>

  60:         protected virtual void OnTimeChanged(DependencyPropertyChangedEventArgs e)

  61:         {

  62:             DateTime value = (DateTime)e.NewValue;

  63:             if (value.IsMinValue())

  64:             {

  65:                 textTime.Text = "";

  66:                 return;

  67:             }

  68:             textTime.Text = value.ToShortTimeString();

  69:         }

  70:  

  71:         #endregion

  72:  

  73:         #region Event Handlers

  74:         /// <summary>

  75:         /// Handles when the user clicks a spinner

  76:         /// </summary>

  77:         /// <param name="sender"></param>

  78:         /// <param name="e"></param>

  79:         private void Time_Spin(object sender, Microsoft.Windows.Controls.SpinEventArgs e)

  80:         {

  81:             if (e.Direction == Microsoft.Windows.Controls.SpinDirection.Increase)

  82:             {

  83:                 this.Time = this.Time.AddMinutesRoundUp(5);

  84:             }

  85:             else

  86:             {

  87:                 this.Time = this.Time.AddMinutesRoundUp(-5);

  88:             }

  89:  

  90:         }

  91:  

  92:         /// <summary>

  93:         /// Handles when the user double clicks in the textbox

  94:         /// </summary>

  95:         /// <param name="sender"></param>

  96:         /// <param name="e"></param>

  97:         private void textTimeDoubleClick(object sender, MouseButtonEventArgs e)

  98:         {

  99:             this.textTime.SelectAll();

 100:         }

 101:  

 102:         /// <summary>

 103:         /// Handles when the user tabs in the control

 104:         /// </summary>

 105:         /// <param name="sender"></param>

 106:         /// <param name="e"></param>

 107:         private void textTimeGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)

 108:         {

 109:             this.textTime.SelectAll();

 110:         }

 111:  

 112:         /// <summary>

 113:         /// Handles when the user enters the control by mouse

 114:         /// </summary>

 115:         /// <param name="sender"></param>

 116:         /// <param name="e"></param>

 117:         private void textTimeMouseEnter(object sender, MouseEventArgs e)

 118:         {

 119:             this.textTime.SelectAll();

 120:         }

 121:  

 122:         /// <summary>

 123:         /// Handles when the control loses focus. Now we want to parse what

 124:         /// the user entered

 125:         /// </summary>

 126:         /// <param name="sender"></param>

 127:         /// <param name="e"></param>

 128:         private void textTimeLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)

 129:         {

 130:             this.Time = ParseText(this.textTime.Text);

 131:         } 

 132:         #endregion

 133:  

 134:         private DateTime ParseText(string value)

 135:         {

 136:             //empty...move on

 137:             if (string.IsNullOrWhiteSpace(value))

 138:             {

 139:                 return DateTime.MinValue;

 140:             }

 141:             

 142:             //parsed as valid date

 143:             DateTime date = DateTime.MinValue;

 144:             DateTime.TryParse(value, out date);

 145:             if (!date.IsMinValue())

 146:             {

 147:                 return date;

 148:             }

 149:  

 150:             string input = value;

 151:             int first = int.Parse(value.Substring(0, 1));

 152:             switch (value.Length)

 153:             {

 154:                 case 3:

 155:                     input = "0" + value;

 156:                     if (first > 0 && first < 7)

 157:                     {

 158:                         input = (first + 12).ToString() + value.Substring(1, 2);

 159:                     }

 160:                     break;

 161:                 case 2:

 162:                     input = value + "00";

 163:                     break;

 164:                 case 1:

 165:                     input = "0" + value + "00";

 166:                     if (first > 0 && first < 7)

 167:                     {

 168:                         input = (first + 12).ToString() + "00";

 169:                     }

 170:                     break;

 171:                 default:

 172:                     break;

 173:             }

 174:             DateTime.TryParseExact(input, "HHmm", null, DateTimeStyles.None, out date);

 175:             return date;

 176:         }

 177:  

 178:  

 179:     }

 180: }

 

DateTime Extension Method:

   1: /// <summary>

   2: /// Adds or subtracts a value to a date time rounding it to

   3: /// multiples of the entered value

   4: /// </summary>

   5: /// <param name="dt">this</param>

   6: /// <param name="value">Amount to add or subtract</param>

   7: /// <returns>Adjusted DateTime</returns>

   8: /// <history>

   9: ///     [Tim Hibbard]   04/08/2011  Created

  10: /// </history>

  11: public static DateTime AddMinutesRoundUp(this DateTime dt, int value)

  12: {

  13:     int delta = dt.Minute % value;

  14:     int subtractDeltaFrom = 0;

  15:     //since value to add is negative, we want to round down

  16:     //to nearest delta value

  17:     if (value < 0)

  18:     {

  19:         //but only if delta is more than 0. If delta is 0, 

  20:         //then we are already rounded down

  21:         if (delta > 0)

  22:         {

  23:             subtractDeltaFrom = Math.Abs(value); 

  24:         }

  25:         

  26:     }

  27:     return dt.AddMinutes(subtractDeltaFrom - delta).AddMinutes(value);

  28: }