Share via


WPF: WrapPanel ItemsPanel Last of Line

https://msdnshared.blob.core.windows.net/media/2016/05/0640_NinjaAwardTinyGold.pngGold Award Winner


When you're using an ItemsControl with a WrapPanel as an ItemsPanel then it is sometimes useful to be able to style the last item in a line or the last in a list differently.
This article explains how to enable XAML to recognise whether an item is one of these.


Introduction

In case you're unfamiliar with using a WrapPanel like this or just new to WPF it's perhaps an idea to start by explaining what an ItemsPanel is.

In WPF we use an ItemsControl ( or something inheriting from it ) in order to display a collection of objects. Examples are ListBox, DataGrid, ListView and ComboBox.
When you think of these controls you probably first think in terms of a list arranged vertically down the control. Each new one added below any previous. Those individual ListBoxItems or ListViewItems etc are stacked up like that by a panel which contains them. By default this (ItemsPanel ) is a StackPanel which will arrange it's contents vertically.
If you're familiar with StackPanel you will immediately realise you can arrange those items horizontally instead of vertically.
You could use a more exotic sort of custom panel and even arrange your items in a curve.
The point to understand is that all the items are put into some sort of a panel and it is that which looks after arranging them.

The sample for this article started out life as an answer on the WPF Msdn Forum.

WrapPanel

One option is a WrapPanel. What this does is line the items up one after the other in multiple columns or multiple rows, depending on the Orientation property. For something representing a timeline or process chart it's fairly common to arrange items one after another across and then down using Horizontal orientation.
This is something which is probably easier understood if you can just see a picture.
This markup:

        Title="MainWindow" Height="350" Width="400"
        FontSize="28" FontWeight="Bold"
        >
    <Grid>
        <Grid.Resources>
            <Style TargetType="Border">
                <Setter Property="Height" Value="60"/>
                <Setter Property="Width" Value="120"/>
                <Setter Property="CornerRadius" Value="3" />
                <Setter Property="BorderBrush" Value="Gray"/>
                <Setter Property="BorderThickness" Value="3"/>
            </Style>
        </Grid.Resources>
        <WrapPanel Orientation="Horizontal">
            <Border>
                <Label Content="1" />
            </Border>
            <Border>
                <Label Content="2" />
            </Border>
            <Border>
                <Label Content="3" />
            </Border>
            <Border>
                <Label Content="4" />
            </Border>
            <Border>
                <Label Content="5" />
            </Border>
            <Border>
                <Label Content="6" />
            </Border>
            <Border>
                <Label Content="7" />
            </Border>
            <Border>
                <Label Content="8" />
            </Border>
        </WrapPanel>
    </Grid>
</Window>

Looks like this running:


This just uses a WrapPanel for clarity.  It's called a WrapPanel because of the way it fills horizontally as a row or vertically as a column and then "wraps" to do that again in another. This one is set to Orientation Horizontal so it does a row then another row, as the numbered borders 1 through 8 demonstrate.
What this article is about is working out which is the last in each row ( 3, 6 ) and the last of the list (8) so you can style them differently.

The Sample

You can download the sample for this article from the Technet Gallery here. Once you click the + button to add a few entries, it looks like this running:

Notice that there is different styling for the rounded border on the first and last item in the ListBox and the last item of each row has an end time as well as a start time.  If you drag the width in and out, notice that the styling responds to that change dynamically. As you make it too narrow for 3 items it switches to showing 2 column and those items which are now end of line get the different highlighting, those that were but are no longer, lose it. Remove an item with the - key and the new last item gets the rounded right border.
How's it do that then?

ItemTracer

The styling is set using triggers but the thing which drives all this is ItemTracer. This is a Framework element which sits in the visual tree for each item but is otherwise hidden. It has no UI defined.
There's not a huge amount of code for this:

public class  ItemTracer : FrameworkElement
{
    public bool  IsLastInLine
    {
        get { return (bool)GetValue(IsLastInLineProperty); }
        set { SetValue(IsLastInLineProperty, value); }
    }
    public static  readonly DependencyProperty IsLastInLineProperty =
        DependencyProperty.Register("IsLastInLine", typeof(bool), typeof(ItemTracer), new  PropertyMetadata(false));
 
    public double  ContainerWidth
    {
        get { return (double)GetValue(ContainerWidthProperty); }
        set { SetValue(ContainerWidthProperty, value); }
    }
 
    public static  readonly DependencyProperty ContainerWidthProperty =
        DependencyProperty.Register("ContainerWidth"
            , typeof(double)
            , typeof(ItemTracer)
            , new  PropertyMetadata(0.0, new PropertyChangedCallback(OnContainerWidthChanged))
            );
    public UIElement Container
    {
        get { return (UIElement)GetValue(ContainerProperty); }
        set { SetValue(ContainerProperty, value); }
    }
    public static  readonly DependencyProperty ContainerProperty =
        DependencyProperty.Register("Container", typeof(UIElement), typeof(ItemTracer));
 
    private static  void OnContainerWidthChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        EvaluateIsLastInLine(d);
    }
 
    private static  void EvaluateIsLastInLine(DependencyObject d)
    {
        bool _isLastInLine = false;
        ItemTracer _this = (ItemTracer)d;
        Point relativeLocation = _this.TranslatePoint(new Point(0, 0), (UIElement) _this.GetValue(ContainerProperty));
        double leftInRow = (double)_this.GetValue(ContainerWidthProperty) - relativeLocation.X;
        if ( leftInRow
            < ((double)_this.GetValue(ActualWidthProperty)* 2)) 
        {
            _isLastInLine = true;
        }
        _this.SetCurrentValue(IsLastInLineProperty, _isLastInLine);
   }
    public ItemTracer()
    {
        EvaluateIsLastInLine(this);
        EvaluateIsLastItem(this);
    }
 
    public int  ItemsCount
    {
        get { return (int)GetValue(ItemsCountProperty); }
        set { SetValue(ItemsCountProperty, value); }
    }
    public static  readonly DependencyProperty ItemsCountProperty =
        DependencyProperty.Register("ItemsCount", typeof(int), typeof(ItemTracer)
            , new  PropertyMetadata(0
            , new  PropertyChangedCallback(OnItemsCountChanged))
            );
    private static  void OnItemsCountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        EvaluateIsLastItem(d);
    }
 
    private static  void EvaluateIsLastItem(DependencyObject d)
    {
        ItemTracer _this = (ItemTracer)d;
        bool _isLastItem = false;
        //Debug.WriteLine(_this.GetValue(ItemIndexProperty).ToString() + " of " + _this.GetValue(ItemsCountProperty).ToString());
 
        if( (int)_this.GetValue(ItemsCountProperty) - (int)_this.GetValue(ItemIndexProperty)  == 1)
        {
            _isLastItem = true;
        }
        _this.SetCurrentValue(IsLastItemProperty, _isLastItem);
    }
 
    public int  ItemIndex
    {
        get { return (int)GetValue(ItemIndexProperty); }
        set { SetValue(ItemIndexProperty, value); }
    }
    public static  readonly DependencyProperty ItemIndexProperty =
        DependencyProperty.Register("ItemIndex"
            , typeof(int)
            , typeof(ItemTracer)
            , new  PropertyMetadata(0
            , new  PropertyChangedCallback(OnItemIndexChanged))
            );
    private static  void OnItemIndexChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        EvaluateIsLastItem(d);
    }
    public bool  IsLastItem
    {
        get { return (bool)GetValue(IsLastItemProperty); }
        set { SetValue(IsLastItemProperty, value); }
    }
    public static  readonly DependencyProperty IsLastItemProperty =
        DependencyProperty.Register("IsLastItem", typeof(bool), typeof(ItemTracer), new  PropertyMetadata(false));
 
}

The two properties which are going to be used to drive styling are IsLastInLine and IsLastItem. They are set by EvaluateIsLastInLine and EvaluateIsLastItem.
Because we want to use bindings with these, they're Dependency Properties.
The way it works out if it is the last item in a line is to compare it's position relative to the width of it's parent panel. Of course, to do that it needs a reference to the panel which it receives via the Container property. This will be bound to the ItemsPanel.
The position of an ItemTracer relative to that panel is obtained by this part:

ItemTracer _this = (ItemTracer)d;
Point relativeLocation = _this.TranslatePoint(new Point(0, 0), (UIElement) _this.GetValue(ContainerProperty));
double leftInRow = (double)_this.GetValue(ContainerWidthProperty) - relativeLocation.X;

That gives the distance from the left side of the ItemTracer to the left of the panel. That point is ( obviously ) going to be the width of the ItemTracer from the right hand side of the item because it's set to fill the space the item has. If it is within twice the width of the item from the right hand side of the panel then it is the last in a line. That comparison is what this code does:

if ( leftInRow
    < ((double)_this.GetValue(ActualWidthProperty)* 2)) 
{
    _isLastInLine = true;
}

Working out whether the items is the last in the collection is much simpler. The ItemIndex is bound to the AlternationIndex of the ListBoxItem. This is compared to the ItemsCount property which is bound to the count of the items in the collection. Since the Index is zero based then subtracting that from the count will give 1 for the last item. Which is what this checks:

if( (int)_this.GetValue(ItemsCountProperty) - (int)_this.GetValue(ItemIndexProperty)  == 1)
{
    _isLastItem = true;
}

There is one debug line left commented out in the code. You could un-comment that and maybe add some of your own to explore what's going on.

MainWindow

The control which is particularly interesting in MainWindow is of course the LIstBox. Let's take a look at that piece by piece. Here's the opening Tag:

<ListBox Grid.Row="2"
    Grid.IsSharedSizeScope="True"
    AlternationCount="10000"
    ItemsSource="{Binding Models}"          
    ScrollViewer.HorizontalScrollBarVisibility="Disabled">

There is no convenient index property you can use for an ItemsControl item to work out where it is in the collection. The "trick" used here is to set the AlternationCount to quite a high number so the AlternationIndex will also be the index of the item.
In order to make a horizontally arranged WrapPanel wrap, you need to disable the horizontal scroll bar.
Note also the Grid.IsSharedsSizeScope set to true - which helps make all items the same width.

The Template of the LIstBoxItem is set in order to remove the usual MouseOver and selected blue background.

<ListBox.ItemContainerStyle>
    <Style TargetType="{x:Type ListBoxItem}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type ListBoxItem}">
                    <ContentPresenter/>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ListBox.ItemContainerStyle>

If you comment that out temporarily and spin her up, you will notice a bit of a strange blue box when you mouse over an item or select it:

Notice that the times are offset outside the item they are associated with. Which is of course a bit of a complication for the first and last items in the row. The first one has to have it's start time offset to the left and the last has to have it's end time offset to the right. In order to allow for that we need some extra space inside the ListBox and to both sides. This is done on the WrapPanel:

ItemsPanel

<ListBox.ItemsPanel>
    <ItemsPanelTemplate>
        <WrapPanel Orientation="Horizontal"
                   Margin="14,0,14,0"
                   />
    </ItemsPanelTemplate>
</ListBox.ItemsPanel>

Here the Wrappanel is being set to Horizontal Orientation and it also has a Margin of 14 for the left and right, which those times can fit into. Actually getting them there is covered later.

Each of those items is generated by an ItemsTemplate

ItemTemplate

As the name suggests, this is the Template which will be applied to each object the ListBox ItemsSource gets from it's bound collection.

<ListBox.ItemTemplate>
    <DataTemplate>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="30"/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition MinWidth="120"
                    SharedSizeGroup="AllSameWidth"/>
            </Grid.ColumnDefinitions>
            <Grid>

The sort of rectangular boxed part will go into the top row of the Grid and the times the bottom. There is a single column. You might think this is a bit pointless since you could just stick things in the Grid. It's necessary together with the namescope mentioned earlier and the named SharedSizeGroup to make all the items grow automatically to the width of the widest. That doesn't work unless you have a column.

ItemTracer

The star of the show, is placed in the Grid defined above. It doesn't actually matter which row it goes in since you can't see it and only the width is significant.

<Grid>
    <local:ItemTracer HorizontalAlignment="Stretch"
        Container="{Binding ., RelativeSource={RelativeSource AncestorType={x:Type WrapPanel}}}"
        ContainerWidth="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType={x:Type WrapPanel}}}"
                       
        ItemIndex="{Binding (ItemsControl.AlternationIndex), RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}}" 
        ItemsCount="{Binding Items.Count, RelativeSource={RelativeSource AncestorType={x:Type ListBox}}}"              
        x:Name="lastInLine"
                     />

HorizontalAlignment set to Stretch is used to ensure it fills the grid's width.
The Container DP is bound to the WrapPanel all the items go inside, since it will use this to work out it's position.
ContainerWidth is bound to the Width of that WrapPanel. This will of course change if the user resizes the control and the Dependency Property change will be used to drive re-calculation.
ItemIndex is bound to the ItemsControl.AlternationIndex. If this was unattractive you could get a reference to the collection the item is in and work out the index of each from that. This way the index will change if an item is removed though.
As it;s name suggests, ItemsCount is bound to the count of the Items in the ListBox. so the control knows how many entries are in the collection.

The Border

The box round the ComboBox in each item is produced using a Border.

    <Border BorderThickness="1" BorderBrush="Gray" x:Name="border" >
        <Border.Style>
            <Style TargetType="{x:Type Border}">
                <Style.Triggers>
                    <DataTrigger Binding="{Binding (ItemsControl.AlternationIndex), RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}}" Value="0">
                        <Setter Property="CornerRadius" Value="10,0,0,10" />
                    </DataTrigger>
                    <DataTrigger Binding="{Binding IsLastItem, ElementName=lastInLine}"
                        Value="true"  >
                        <Setter Property="CornerRadius" Value="0,10,10,0" />
                    </DataTrigger>
                    <DataTrigger Binding="{Binding Items.Count, RelativeSource={RelativeSource AncestorType={x:Type ListBox}}}" Value="1">
                        <Setter Property="CornerRadius" Value="10,10,10,10" />
                    </DataTrigger>
                </Style.Triggers>
            </Style>
        </Border.Style>
        <ComboBox Margin="20,4,20,4" MaxDropDownHeight="50"
            ItemsSource="{local:EnumBindingSource {x:Type local:Heat}}"
            SelectedValuePath="{Binding HotNess}"
                  Background="Red"
                  />
    </Border>
</Grid>

Datatriggers set the first item to have a rounded left border, the last to have a rounded right and (finally) both if there's only one in the collection. All will apply in that case but the last datatrigger is applied after the others.
Within the Border is a ComboBox, exactly how this works is a subject for another article. Notice as you choose a HotNess from the combobox, it will expand to the width of the string chosen if it is greater than the current width. Due to the settings explained earlier, all the items will also expand to the maximum width.

Times

The last pieces of the item are the two times. These show the time the item starts at and finishes at. Or at least they will potentially. Because of course the finish time is only shown for last in line and the last in the collection.

<TextBlock Text="{Binding StartTime, StringFormat=\{0:mm\\:ss\}}" Grid.Row="1"
            Margin="-14,0,0,0"
           />
<TextBlock Text="{Binding EndTime, StringFormat=\{0:mm\\:ss\}}"  Grid.Row="1"
    HorizontalAlignment="Right"
           >
    <TextBlock.RenderTransform>
        <TranslateTransform X="14" Y="0"/>
    </TextBlock.RenderTransform>
    <TextBlock.Style>
        <Style TargetType="{x:Type TextBlock}">
            <Setter Property="Visibility" Value="Visible"/>
            <Style.Triggers>
                <MultiDataTrigger>
                    <MultiDataTrigger.Conditions>
                        <Condition Binding="{Binding IsLastInLine, ElementName=lastInLine}" Value="False" />
                        <Condition Binding="{Binding IsLastItem, ElementName=lastInLine}" Value="False" />
                    </MultiDataTrigger.Conditions>
                    <Setter Property="Visibility" Value="Collapsed"/>
                </MultiDataTrigger>
            </Style.Triggers>
        </Style>
    </TextBlock.Style>
</TextBlock>

 
Both TextBlocks are bound to a TimeSpan and use the slightly unusual StringFormat which is therefore necessary. You can read a more detailed explanation of that formatting here.

The first TextBlock is pretty simple. A negative margin moves it left In order to line up the colon ":" between minutes and seconds with the left edge of the item.  This is where that matching margin of 14 to the left of the ItemsPanel comes in, ensuring there will be enough space for it to fit. This negative margin actually takes the time a bit out of it's container which would not have worked if there wasn't the space available there.

The second TextBlock is a bit more complicated. You can't push something out of it's container using a positive margin so a Plan B is necessary - a RenderTransform.

The logic which controls whether the EndTime is visible is when it's last in line or last in the control. Two conditions are handled by a MultiDataTrigger. These things work by evaluating all their list of conditions. If they are all true then the Setter is applied. They therefore only offer AND logic and not OR. The way to handle OR is to turn the logic around and check the reverse is true for all. Rather than checking if the element is last in line or last in the collection, it checks to see if both are false.
The style setter makes it Visible by default and hides it if it neither last in line nor last in the collection.

And that is the end of the explanation of the markup. In case you want to see it all together and haven't downloaded the sample, here it is in one block:

<Window
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:wpf_ItemsControl_Wrapped"
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
 
        x:Class="wpf_ItemsControl_Wrapped.MainWindow"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Window.Resources>
 
    </Window.Resources>
    <Window.DataContext>
        <local:MainWindowViewModel/>
    </Window.DataContext>
    <Grid>
        <Grid.Resources>
            <Style TargetType="{x:Type Button}">
                <Setter Property="Margin" Value="4"/>
            </Style>
        </Grid.Resources>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <Button HorizontalAlignment="Left"
            Content="+"
            Command="{Binding AddCommand}"
            Width="80" Height="40"
                />
        <Button HorizontalAlignment="Right"
            Content="-"
            Command="{Binding RemoveCommand}"
            Width="80" Height="40"
                />
        <StackPanel Orientation="Horizontal"
            Grid.Row="1"
            Margin="4,8,4,8"
                    >
            <TextBlock Text="Time Value:" VerticalAlignment="Center"/>
            <ComboBox ItemsSource="{Binding Durations}"
                DisplayMemberPath="Description"
                SelectedValue="{Binding StepTime}"
                SelectedValuePath="TheTimeSpan"
                Margin="12,0,0,0"
                      />
        </StackPanel>
        <ListBox Grid.Row="2"
            Grid.IsSharedSizeScope="True"
            AlternationCount="10000"
            ItemsSource="{Binding Models}"          
            ScrollViewer.HorizontalScrollBarVisibility="Disabled">
            <ListBox.ItemContainerStyle>
                <Style TargetType="{x:Type ListBoxItem}">
                    <Setter Property="Template">
                        <Setter.Value>
                            <ControlTemplate TargetType="{x:Type ListBoxItem}">
                                <ContentPresenter/>
                            </ControlTemplate>
                        </Setter.Value>
                    </Setter>
                </Style>
            </ListBox.ItemContainerStyle>
            <ListBox.ItemsPanel>
                <ItemsPanelTemplate>
                    <WrapPanel Orientation="Horizontal"
                               Margin="14,0,14,0"
                               />
                </ItemsPanelTemplate>
            </ListBox.ItemsPanel>
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <Grid>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto"/>
                            <RowDefinition Height="30"/>
                        </Grid.RowDefinitions>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition MinWidth="120"
                                SharedSizeGroup="AllSameWidth"/>
                        </Grid.ColumnDefinitions>
                        <Grid>
                            <local:ItemTracer HorizontalAlignment="Stretch"
                                Container="{Binding ., RelativeSource={RelativeSource AncestorType={x:Type WrapPanel}}}"
                                ContainerWidth="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType={x:Type WrapPanel}}}"
                                               
                                ItemIndex="{Binding (ItemsControl.AlternationIndex), RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}}" 
                                ItemsCount="{Binding Items.Count, RelativeSource={RelativeSource AncestorType={x:Type ListBox}}}"              
                                x:Name="lastInLine"
                                             />
                            <Border BorderThickness="1" BorderBrush="Gray" x:Name="border" >
                                <Border.Style>
                                    <Style TargetType="{x:Type Border}">
                                        <Style.Triggers>
                                            <DataTrigger Binding="{Binding (ItemsControl.AlternationIndex), RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}}" Value="0">
                                                <Setter Property="CornerRadius" Value="10,0,0,10" />
                                            </DataTrigger>
                                            <DataTrigger Binding="{Binding IsLastItem, ElementName=lastInLine}"
                                                Value="true"  >
                                                <Setter Property="CornerRadius" Value="0,10,10,0" />
                                            </DataTrigger>
                                            <DataTrigger Binding="{Binding Items.Count, RelativeSource={RelativeSource AncestorType={x:Type ListBox}}}" Value="1">
                                                <Setter Property="CornerRadius" Value="10,10,10,10" />
                                            </DataTrigger>
                                        </Style.Triggers>
                                    </Style>
                                </Border.Style>
                                <ComboBox Margin="20,4,20,4" MaxDropDownHeight="50"
                                    ItemsSource="{local:EnumBindingSource {x:Type local:Heat}}"
                                    SelectedValuePath="{Binding HotNess}"
                                          Background="Red"
                                          />
                            </Border>
                        </Grid>
                        <TextBlock Text="{Binding StartTime, StringFormat=\{0:mm\\:ss\}}" Grid.Row="1"
                                    Margin="-14,0,0,0"
                                   />
                        <TextBlock Text="{Binding EndTime, StringFormat=\{0:mm\\:ss\}}"  Grid.Row="1"
                            HorizontalAlignment="Right"
                                   >
                            <TextBlock.RenderTransform>
                                <TranslateTransform X="14" Y="0"/>
                            </TextBlock.RenderTransform>
                            <TextBlock.Style>
                                <Style TargetType="{x:Type TextBlock}">
                                    <Setter Property="Visibility" Value="Visible"/>
                                    <Style.Triggers>
                                        <MultiDataTrigger>
                                            <MultiDataTrigger.Conditions>
                                                <Condition Binding="{Binding IsLastInLine, ElementName=lastInLine}" Value="False" />
                                                <Condition Binding="{Binding IsLastItem, ElementName=lastInLine}" Value="False" />
                                            </MultiDataTrigger.Conditions>
                                            <Setter Property="Visibility" Value="Collapsed"/>
                                        </MultiDataTrigger>
                                    </Style.Triggers>
                                </Style>
                            </TextBlock.Style>
                        </TextBlock>
                    </Grid>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </Grid>
</Window>

ViewModel

Since this is MVVM there are the following pieces of data/functionality to provide:

  1. A collection to expose the collection the main ListBox uses for it's data.
  2. An Add Item command which adds to that collection.
  3. A remove item command to remove the last one.
  4. A collection to provide the options for the combobox used to set the duration to be added

Models

The collection of data for the ListBox is called Models and it's an ObservableCollection of Model objects:

public ObservableCollection<Model> Models { get; set; }
= new  ObservableCollection<Model>();

Durations

The combobox of possible durations ( which the user can choose for the next addition ) is bound to the Durations collection:

public ObservableCollection<Duration> Durations { get; set; }
= new  ObservableCollection<Duration>();

Constructor

The data for the combobox is set up in a loop with 15 second increments, but there's also a bit more to this.

public MainWindowViewModel()
{
    for (int i = 15; i < 190; i+=15)
    {
        Durations.Add( new  Duration
        {
            TheTimeSpan = TimeSpan.FromSeconds(i)
            , Description=i.ToString() + " sec"
        });
    }
    // Show Mock data in designer (only)
    if (!DesignerProperties.GetIsInDesignMode(new DependencyObject()))
        return;
 
    for (int i = 0; i < 56; i++)
    {
        AddNextItem();
    }
}

There's a bit of a trick used to fill the ListBox with mock data whilst it's in the designer. The technique described in detail here is used to run some code only in the designer. If so, it adds a number of Model objects to the Models collection with the same method the Add Command uses. 

AddCommand

The project uses MVVM Light framework and hence RelayCommands to make ICommands simpler

private RelayCommand _addCommand;
 
public RelayCommand AddCommand
{
    get
    {
        return _addCommand
          ?? (_addCommand = new  RelayCommand(
            () =>
            {
                AddNextItem();
            }));
    }
}

The AddCommand is bound to the button with the plus sign on it and just invokes the AddNextItem method.

AddNextItem

This is used by both the mock data setup and AddCommand to new up the next Model and add it to the Models collection.

private void  AddNextItem()
{
    Model _model = new  Model { StartTime = LastEnd()};
    _model.Duration = stepTime;
    Models.Add(_model);
}

stepTime is the private backer for the property which is bound as the selectedvalue of the Durations combobox. It defaults to 15 seconds. You will notice that uses LastEnd as the start time.

LastEnd

In order to make it easy to work out what the start time will be for any new entry, the LastEnd method:

public TimeSpan LastEnd()
{
    return (Models.Count == 0)? TimeSpan.FromSeconds(0)
                              : Models.Last<Model>().EndTime;
}

SImply finds the last in the collection and returns it's EndTime.
This is only really to make AddNextItem somewhat more readable.

RemoveCommand

The button with the minus sign Removes the last item in the Models collection:

private RelayCommand _removeCommand;
 
public RelayCommand RemoveCommand
{
    get
    {
        return _removeCommand
          ?? (_removeCommand = new  RelayCommand(
            () =>
            {
                if(Models.Count > 0)
                {
                    Models.Remove(Models.Last<Model>());
                }
            }));
    }
}

Conclusion

It's understandably common to think of controls in terms of Buttons and TextBoxes that the user sees and interacts with. These are of course what one first starts working with.  You can do more than that with objects which sit in the UI though.
This article and sample will help you solve quite a specific issue.  Similar "Invisible" controls can help solve other issues as well.  Next time you have a problem which means you need to implement something particularly tricky in the UI, bear this technique in mind. 

See Also

WPF TNWiki Portal