In the latest version of my Podcast Aggregator application I’ve spent lots of time playing around with styling the standard Silverlight ListBox. With ultimate control of how the ListBox renders comes a reasonable level of complexity – such is life, so I thought I’d give an overview of how I did it.

This is how my list of podcasts renders;

image

There were three aspects of the listbox that I wanted to control,

  • Display - what controls/data was displayed for each item in the list
  • Interaction - what happened when users interact with the ListBox and controls – mouse over effects etc.
  • DataBinding – in particular, how best to bind to Commands in my View Model from within a ListItem which has its own DataContext
  • Smooth Scrolling – by default ListBoxes don’t scroll smoothly (at least not in Silverlight 4.0)

Display

Controlling how each item in the list should be displayed is typically controlled by defining a DataTemplate. For some reason the DataTemplate is set through a property called ItemTemplate but you get that.

<ListBox ItemsSource="{Binding Podcasts}" 
         ItemTemplate="{StaticResource PodcastListDataTemplate}">
</ListBox>

I’ve defined my DataTemplate in a resource dictionary file so can simply refer to it from my ListBox definition.

<!-- This data template determines how each item in the list should be rendered -->
<DataTemplate x:Key="PodcastListDataTemplate">
    <Grid Background="White" >
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="30"/>
            <ColumnDefinition  />
            <ColumnDefinition Width="105"/>
            <ColumnDefinition Width="105"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="30"/>
            <RowDefinition Height="30"/>
            <RowDefinition Height="2"/>
        </Grid.RowDefinitions>
 
        <!-- Background when unread -->
        <Rectangle x:Name="backgroundUnread" Visibility="{Binding Read, Converter={StaticResource InvisibilityConverter}}" Grid.RowSpan="3" Fill="White"  Grid.ColumnSpan="4" />
 
        <!-- Right fade when unread -->
        <Border x:Name="fadeUnread" Margin="0,1" Grid.RowSpan="3" Visibility="{Binding Read, Converter={StaticResource InvisibilityConverter}}" Grid.Column="1"  IsHitTestVisible="False" HorizontalAlignment="Right" Width="97" Canvas.ZIndex="1">
            <Border.Background>
                <LinearGradientBrush StartPoint="0,0.5" EndPoint="1,.5" >
                    <GradientStop Color="Transparent" Offset="0.258"/>
                    <GradientStop Color="White" Offset="0.722"/>
                </LinearGradientBrush>
            </Border.Background>
        </Border>
 
        <!-- Background when read -->
        <Rectangle x:Name="backgroundRead" Visibility="{Binding Read, Converter={StaticResource VisibilityConverter}}" Grid.RowSpan="3" Fill="#FFE5E5E5"  Grid.ColumnSpan="4" />
 
        <!-- Right fade when read -->
        <Border x:Name="fadeRead" Margin="0,1" Visibility="{Binding Read, Converter={StaticResource VisibilityConverter}}" Grid.RowSpan="3" Grid.Column="1"  IsHitTestVisible="False" HorizontalAlignment="Right" Width="97" Canvas.ZIndex="1">
            <Border.Background>
                <LinearGradientBrush StartPoint="0,0.5" EndPoint="1,.5" >
                    <GradientStop Color="Transparent" Offset="0.258"/>
                    <GradientStop Color="#FFE5E5E5" Offset="0.722"/>
                </LinearGradientBrush>
            </Border.Background>
        </Border>
 
        <!-- Dotted divider -->
        <Rectangle x:Name="divider" StrokeDashArray="5,2" Stroke="#FFDADADA" Grid.ColumnSpan="4" Grid.RowSpan="3"  />
        <Border x:Name="divider2" BorderBrush="White" BorderThickness="1,1,1,0" Grid.ColumnSpan="4" Grid.RowSpan="3" />
 
        <!-- Star -->
        <HyperlinkButton x:Name="starHyperlink" VerticalAlignment="Center" HorizontalAlignment="Center" Command="{Binding ListViewModel.ToggleStar, Source={StaticResource Locator}}" CommandParameter="{Binding}" Style="{StaticResource HyperLinkImages}">
            <local:Star Height="15" Width="15" Background="Black" Starred="{Binding Starred, Mode=TwoWay}" />
        </HyperlinkButton>
 
        <!-- Title (link to details) -->
        <HyperlinkButton x:Name="titleHyperlink" HorizontalAlignment="Left" Grid.Column="1" Foreground="{StaticResource TextForecolour}" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" IsTabStop="False" CommandParameter="{Binding}">
            <TextBlock Text="{Binding Title}"  />
        </HyperlinkButton>
 
        <!-- Channel Name -->
        <TextBlock Margin="15,0,0,10" Grid.Column="1" Grid.Row="1" Text="{Binding ChannelName}" FontSize="13" Foreground="#FF8B8888" VerticalAlignment="Center"/>
 
        <!-- Published Date -->
        <TextBlock Margin="0,2,0,0" Grid.Column="2" Grid.Row="0" Text="{Binding Published, StringFormat=MMM dd HH:mm}" FontWeight="Bold" FontSize="13" Foreground="{StaticResource TextForecolour}" VerticalAlignment="Center" />
 
        <!-- Download Progressbar -->
        <HyperlinkButton Visibility="{Binding IsDownloading, Converter={StaticResource VisibilityConverter}, Mode=OneWay}" Grid.Row="0" Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" CommandParameter="{Binding}" ToolTipService.ToolTip="Click to cancel" Canvas.ZIndex="10" Style="{StaticResource HyperLinkImages}">
            <Grid HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Column="2" >
                <Rectangle  HorizontalAlignment="Left"  Width="100" Height="18" Fill="#FFA19F9F" />
                <Rectangle Width="{Binding DownloadProgressPercentage}" Height="18" Fill="#FF7C7777" HorizontalAlignment="Left" Visibility="{Binding IsDownloading, Converter={StaticResource VisibilityConverter}, Mode=OneWay}"/>
                <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding DownloadProgressText, Mode=OneWay}" FontSize="9" Foreground="White" />
                <Border Width="100" Height="18" BorderThickness="1" BorderBrush="#FF7C7777" />
            </Grid>
        </HyperlinkButton>
 
        <!-- Download button -->
        <HyperlinkButton Visibility="{Binding CanDownload, Converter={StaticResource VisibilityConverter}, Mode=OneWay}" Grid.Row="0" Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" CommandParameter="{Binding}" Canvas.ZIndex="10" Style="{StaticResource HyperLinkButton}" Content="DOWNLOAD">
            <!-- bind to command throught Locator -->
        </HyperlinkButton>
 
        <!-- Downloaded -->
        <TextBlock Visibility="{Binding FileExists, Converter={StaticResource VisibilityConverter}}" Grid.Row="0" Grid.Column="3" VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="Gray" Text="DOWNLOADED">
                <ToolTipService.ToolTip>
                    <Grid>
                        <StackPanel>
                            <TextBlock Text="file:" Style="{StaticResource Label}"/>
                            <TextBlock Width="100" Text="{Binding FullPath}" TextWrapping="Wrap"/>
                        </StackPanel>
                    </Grid>
                </ToolTipService.ToolTip>
            </TextBlock>
 
        <!-- Play button -->
        <HyperlinkButton x:Name="play" Grid.Column="3" Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.2" Style="{StaticResource HyperLinkImages}" CommandParameter="{Binding}" >
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="MouseEnter">
                    <ei:ChangePropertyAction PropertyName="Opacity" Value="1"/>
                </i:EventTrigger>
                <i:EventTrigger EventName="MouseLeave">
                    <ei:ChangePropertyAction PropertyName="Opacity" Value="0.2"/>
                </i:EventTrigger>
            </i:Interaction.Triggers>
            <Grid>
                <Ellipse Height="20" Width="20" Margin="0,0,2,0" Stroke="Black" VerticalAlignment="Bottom"/>
                <ed:RegularPolygon InnerRadius="1" PointCount="3" Stretch="Fill" Stroke="Black" Width="10" Height="10" RenderTransformOrigin="0.5,0.5">
                    <ed:RegularPolygon.RenderTransform>
                        <CompositeTransform Rotation="90"/>
                    </ed:RegularPolygon.RenderTransform>
                </ed:RegularPolygon>
            </Grid>
        </HyperlinkButton>
        
    </Grid>
</DataTemplate>

I’ll leave you to look through the XAML but it’s just that, XAML, and can contain anything you like.

There are a couple of things in there that are worth noting though.

  • Visibility/Invisibility Converters are used to hide/show elements based on Boolean properties on the podcast entity I’m binding each item to. I’m sure someone out there has a tidier way to do this but this is simple and works.
  • To provide hover-over effects for individual controls (for example, the Play control) I’m using EventTriggers that come with Expression Blend.

While I’m used to doing most of my Silverlight work in Visual Studio and directly in XAML, I have to admit that working with templates and styles is a lot easier in Expression Blend. For example, even if your template is defined in another file (as is the case for me) you can edit the XAML and see the effects within the designer.

image

If you only defined the DataTemplate and ran the application you’d see something that looked like;

image

Which is getting there but has a few problems. Firstly, the ListItems are not stretching out to fill the width of the ListBox and secondly the hover-over/selected effects don’t really work with as I’d like. Both of these things are sorted by looking at the ItemContainerStyle.

Interaction

To control the style of the container that hosts each ListItem and how it should react to user interaction you need to set the ItemContainerStyle (again – I’m not crazy about this name).

Here’s the one I defined.

<Style x:Key="PodcastListBoxItemStyle" TargetType="ListBoxItem">
    <Setter Property="Padding" Value="0"/>
    <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
    <Setter Property="Background" Value="Transparent"/>
    <Setter Property="BorderThickness" Value="1"/>
    <Setter Property="TabNavigation" Value="Local"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="ListBoxItem">
                <Grid Background="{TemplateBinding Background}" d:DesignWidth="930" d:DesignHeight="140">
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="CommonStates" >
                            <VisualState x:Name="Normal"/>
                            <VisualState x:Name="MouseOver">
                                <Storyboard>
                                    <DoubleAnimation Duration="0" To="1" Storyboard.TargetProperty="(UIElement.Opacity)" Storyboard.TargetName="mouseOverHighlight" />
                                    <DoubleAnimation Duration="0" To="0.385" Storyboard.TargetProperty="(UIElement.Opacity)" Storyboard.TargetName="readHyperlink" />
                                </Storyboard>
                            </VisualState>
                            <VisualState x:Name="Disabled">
                                <Storyboard>
                                    <DoubleAnimation Duration="0" To=".55" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="contentPresenter"/>
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                        <VisualStateGroup x:Name="SelectionStates">
                            <VisualState x:Name="Unselected"/>
                            <VisualState x:Name="Selected">
                                <Storyboard>
                                    <DoubleAnimation Duration="0" To="1" Storyboard.TargetProperty="(UIElement.Opacity)" Storyboard.TargetName="selectedHighlight" />
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                        <VisualStateGroup x:Name="FocusStates">
                            <VisualState x:Name="Focused" />
                            <VisualState x:Name="Unfocused"/>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
 
                    <!-- Read/Unread Toggle -->
                    <HyperlinkButton Margin="5,9,0,0" Command="{Binding ListViewModel.ToggleRead, Source={StaticResource Locator}}" x:Name="readHyperlink" CommandParameter="{Binding}" Style="{StaticResource HyperLinkImages}" HorizontalAlignment="Left" VerticalAlignment="Center" Canvas.ZIndex="2" Opacity="0" Width="20" Height="20" ToolTipService.ToolTip="Read/UnRead"  >
                        <Canvas>
                            <Rectangle x:Name="unread" Height="12" Width="12" Stroke="Black" />
                            <TextBlock Margin="2,-1,0,0" FontSize="10" Foreground="Black"><Run Text="R"/></TextBlock>
                        </Canvas>
                    </HyperlinkButton>
 
                    <ContentPresenter x:Name="contentPresenter" Content="{TemplateBinding Content}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" Margin="{TemplateBinding Padding}" OpacityMask="Black"/>
 
                    <Rectangle Width="5" Margin="0,2,0,2" HorizontalAlignment="Left" x:Name="mouseOverHighlight" IsHitTestVisible="False" Opacity="0" Fill="#FFD4D4D4"/>
 
                    <Rectangle Width="5" Margin="0,2,0,2" HorizontalAlignment="Left" x:Name="selectedHighlight" IsHitTestVisible="False" Opacity="0" Fill="#FFB2B2B2"/>
 
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

So I solved the problem of each ListItem not stretching by setting the HorizontalContentAlignment to Stretch.

And the VisualStateManager stuff controls the animations that occur when the state changes through user interaction.

The only thing here that’s slightly unusual is that I have also added a set of control (rectangle with the letter R in it) that the user can click to toggle a podcast as read/unread. The reason I put this in the ItemContainerStyle.Template (rather than the DataTemplate) is that I wanted it to appear and disappear as a user moused over a ListItem – this seemed the easiest way to do this. The DataBinding technique is the same as for the DataTemplate (see below).

DataBinding

The DataBinding within the templates is pretty straightforward in most cases because we are binding directly to the podcast object that each ListItem’s DataContext is set to. The only thing that takes a bit more thought is how to bind to a Command that is in the DataContext (ViewModel) of the parent of the ListBox. Since the ListBoxItem has no direct knowledge of it’s parent’s DataContext we have to take a different approach.

WPF (of which I have no real experience!) apparently allows you to use RelativeSource with FindAncestor to do exactly what we want – i.e. find our ViewModel from an Ancestor control – but Silverlight 4.0 doesn’t yet have this.

One approach is to use, Colin Eberhardt’s extended implementation of RelativeSource binding that supports FindAncestor, but if you’re following the pattern of using a static View Model locator (e.g. like MVVMLight uses) then it’s a bit easier.

As you’ll see in more detail in the download I have a Locator class that contains a ListViewModel property that returns a static instance of my View Model – this means that whoever calls it will get access to the same instance.

Which means that from within my ItemContainerStyle.Template (or anywhere for that matter) I can write something like,

<HyperlinkButton x:Name="readHyperlink" Command="{Binding ListViewModel.ToggleRead, Source={StaticResource Locator}}" CommandParameter="{Binding}" Style="{StaticResource HyperLinkImages}" HorizontalAlignment="Left" VerticalAlignment="Center" Canvas.ZIndex="2" Opacity="0" Width="18" Height="18"  ToolTipService.ToolTip="Read/UnRead">
    ...
</HyperlinkButton>

Which binds the Command of the Hyperlink to the ToggleRead property of my ListViewModel – easy.

Smooth Scrolling

The last thing I wanted to achieve was to make my list scroll smoothly – this is actually pretty easy.

The ItemsPanel property of the ListBox controls how the outer container of the ListBox is constructed. By default ListBoxes use a VirtualizingStackPanel that has some smart behaviour to make working with large lists perform better. The consequence of this performance improvement is that the scrolling on the ListBox will jump from one item to the next rather than scrolling smoothly.

You can override this behaviour – if smoothness out scores performance – by defining your own ItemPanelTemplate to use a standard StackPanel.

<ItemsPanelTemplate x:Key="PodcastItemPanelTemplate">
    <StackPanel/>
</ItemsPanelTemplate>

Finally

So now with all the templates configured we just reference them from the ListBox and we’re there.

<ListBox ItemsSource="{Binding Podcasts}" 
         ItemTemplate="{StaticResource PodcastListDataTemplate}"
         ItemContainerStyle="{StaticResource PodcastListBoxItemStyle}"
         ItemsPanel="{StaticResource PodcastItemPanelTemplate}"
         ScrollViewer.HorizontalScrollBarVisibility="Disabled"
         >
</ListBox>

The only additional thing I added was to disable the horizontal scrollbar so that my list always took up the entire page width even if the contents of my columns were long.

And that’s it – have a look at the source to get it up and running and have a play.