Share via


Silverlight: StrikeThrough

Silverlight only supports underline TextDecoration, this article is about implementing Strikethrough-like functionality and a warning about some of the issues to be aware of. 


Introduction

One of the aspects of regular .Net that the designers of Silverlight decided they could do without is Strikethrough.   There is more than meets the eye to implementing this for yourself. Finding a robust solution proved quite a challenge.

Draw Your Own Line

An obvious solution for a line through your text is  to put your own line over the top of some text.
Something along these lines:

<Border HorizontalAlignment="Left">
    <Grid VerticalAlignment="Center">
        <TextBlock Text="{Binding Name}" Foreground="Gray" />
 
        <Line StrokeThickness="1"
              VerticalAlignment="Center"
              X1="1"
              X2="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=Border}}"
              Stroke="Black" />
 
    </Grid>
</Border>

In the above, the textblock will make the Border grow and the X2 co-ordinate of the line is bound to the ActualWidth of the Border and hence will grow.
The Grid is there so the Line will go on top of the TextBlock.
The Border is there so there is some concrete parent control in the visual tree that the binding can resolve to.
This approach is fine for simple requirements ( but note the ComboBox issues later ! )

Complications

This doesn't work at all well if your TextBlock is wrapped, because the line is only related to the TextBlock by the fact that it's in the same container.
Something very odd happens when you use this with a ComboBox.

When used with a ComboBox:

        <DataTemplate x:Key="TestTemplate">
            <Border HorizontalAlignment="Left">
 
            <Grid VerticalAlignment="Center">
                     <TextBlock x:Name="NameTb"
                               Text="{Binding Name}" Foreground="Gray" />
                    <Line StrokeThickness="1"
                          VerticalAlignment="Center"
                          X1="1"
                          X2="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=Border}, Mode=OneTime}"
                          Stroke="Black" />
                </Grid>
            </Border>
        </DataTemplate>
    </Grid.Resources>
    <StackPanel>
        <ComboBox Name="cbo"
                  ItemsSource="{Binding Items}"
                  ItemTemplate="{StaticResource TestTemplate}" Height="30" Width="200" SelectionChanged="ComboBox_SelectionChanged"
              />
 
    </StackPanel>
</Grid>

At first glance this looks good.

When you choose the first entry the combobox seems to grab that line.
This is noticeable when you choose a second item.

Here, Name "BBBBBBBBBBBBBBB" was picked first and then Ann.
Despite that line looking good when the ComboBoxItems are dropped down, the first one chosen seems to have stuck around for some reason.
Working round this particular aspect is very tricky and the author has explored numerous alternatives.

Work Round Fails

There is a "known issue" with binding ActualWidth in Silverlight.  Maybe this issue is related.
If you load hard coded items:

<ComboBoxItem >
    <Border HorizontalAlignment="Left">
        <Grid VerticalAlignment="Center">
            <TextBlock 
                               Text="aaa" Foreground="Gray" />
 
            <Line StrokeThickness="1"
                          VerticalAlignment="Center"
                          X1="1"
                          X2="200"
                          Stroke="Black" />
 
        </Grid>
    </Border>
</ComboBoxItem>
<ComboBoxItem >
    <Border HorizontalAlignment="Left">
        <Grid VerticalAlignment="Center">
            <TextBlock 
                               Text="bbbbbbbbbbbbbbbbbbbbbbbb" Foreground="Gray" />
 
            <Line StrokeThickness="1"
                          VerticalAlignment="Center"
                          X1="1"
                          X2="20"
                          Stroke="Black" />
 
        </Grid>
    </Border>
</ComboBoxItem>
<ComboBoxItem >
    <Border HorizontalAlignment="Left">
        <Grid VerticalAlignment="Center">
            <TextBlock 
                               Text="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" Foreground="Gray" />
 
            <Line StrokeThickness="1"
                          VerticalAlignment="Center"
                          X1="1"
                          X2="400"
                          Stroke="Black" />
 
        </Grid>
    </Border>
</ComboBoxItem>

The Combo behaves as expected.

Setting the X2 Value

This attempt finds the line object and sets x2 on it in the Border Loaded event.

<DataTemplate x:Key="TestTemplate">
    <Border HorizontalAlignment="Left" Loaded="Border_Loaded">
        <Grid VerticalAlignment="Center">
            <TextBlock
                   Text="{Binding Name}" Foreground="Gray" />
            <Line StrokeThickness="1"
              VerticalAlignment="Center"
              X1="1"
              X2="1"
              Stroke="Black" />
        </Grid>
    </Border>
</DataTemplate>

As you can see there the Border_Loaded handler will fire for each Border.
That looks like this.

private void  Border_Loaded(object  sender, RoutedEventArgs e)
{
    if (sender == null)
        return;
    Border border = sender as  Border;
    Line ln = FindControlByType<Line>(border, null);
    if (ln != null)
    {
        if (ln.X2 == 1)
        {
            ln.X2 = 1;
            ln.X2 = border.ActualWidth;
        }
    }
}

That takes the ActualWidth of the Border and sets X2 on the line to that value.
Thus no bindings are involved in setting that width.
That produces the familiar effect as dropped down.
Unfortunately, it also misbehaves in the familiar way when the second entry is chosen

It seems pretty obvious at this point that there is a bug at work here.

FindControlByType

This is a fairly common implementation which can be found in various places on the internet.  It's anyone's guess who the original author was.

private T FindControlByType<T>(DependencyObject container,  string  name) where T : DependencyObject
{
    T foundControl = null;
    for (int i = 0; i < VisualTreeHelper.GetChildrenCount(container); i++)
    {
        if (VisualTreeHelper.GetChild(container, i) is T && (VisualTreeHelper.GetChild(container, i).GetValue(FrameworkElement.NameProperty).Equals(name) || name == null))
        {
            foundControl = (T)VisualTreeHelper.GetChild(container, i);
            break;
        }
        else if  (VisualTreeHelper.GetChildrenCount(VisualTreeHelper.GetChild(container, i)) > 0)
        {
            foundControl = FindControlByType<T>(VisualTreeHelper.GetChild(container, i), name);
            if (foundControl != null)
                break;
        }
    }
    return foundControl;
}

A Reliable Solution

Even if you solve these issues there will still be problems if you want text wrapping.  One way round this is to make the overlay a TextBlock.
This approach uses a converter to substitute an underline for each non space character and a negative margin to shift the underline up into the middle of the text.

MainPage

A DataGrid and ComboBox are used to illustrate the approach working.

<UserControl x:Class="SL_StrikeThrough.MainPage"
    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:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk"
    xmlns:local="clr-namespace:SL_StrikeThrough"
    mc:Ignorable="d"
    d:DesignHeight="300" d:DesignWidth="300"
    FontFamily="Courier New"        
             >
    <UserControl.DataContext>
        <local:MainViewModel/>
    </UserControl.DataContext>
    <Grid x:Name="LayoutRoot" Background="White" HorizontalAlignment="Left">
        <Grid.Resources>
            <local:StrikeThroughConverter x:Key="strikeThroughConverter"/>
            <DataTemplate x:Key="TestTemplate">
                <Border HorizontalAlignment="Left">
                    <Grid VerticalAlignment="Center">
                        <TextBlock TextWrapping="Wrap"
                               Text="{Binding Name}" Foreground="Gray" />
                        <TextBlock TextWrapping="Wrap"
                               Text="{Binding Name, Converter={StaticResource strikeThroughConverter}}" Foreground="Black" Margin="0,-6,0,0" />
                    </Grid>
                </Border>
            </DataTemplate>
 
        </Grid.Resources>
        <StackPanel>
 
            <sdk:DataGrid ItemsSource="{Binding Items}" AutoGenerateColumns="False">
                <sdk:DataGrid.Columns>
                    <sdk:DataGridTemplateColumn Header="Name" CellTemplate="{StaticResource TestTemplate}"  Width="100"/>
                </sdk:DataGrid.Columns>
            </sdk:DataGrid>
 
            <ComboBox Name="cbo"
                      ItemsSource="{Binding Items}"
                      ItemTemplate="{StaticResource TestTemplate}" Height="30" Width="300"
     
                  />
 
        </StackPanel>
    </Grid>
</UserControl>

As you can see, the template has two TextBlocks, the second of which will become the line over the letters of the first.
Setting the font for the window to Courier New makes it fixed width so the underlines are the same width as the characters they replace.  If you don't choose a fixed width font then the two will almost certainly be out of step.
As mentioned, the negative margin moves the underline up into the middle of the previous TextBlock.
Because these are both TextBlocks with text in them and spaces in the same position, they will wrap the same way.

StrikeThroughConverter

This converter replaces everything but a space with an underline.  It relies on the fact that a string is an array of characters. This converter uses a bit of LINQ to do that substitution from the input string to a returned string.

public class  StrikeThroughConverter : IValueConverter
{
    public object  Convert(object  value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        if (value != null)
        {
            string str = value as string;
            string underLines = new String(str.Select(c => c == ' ' ? ' '  : '_').ToArray());
            return underLines;
        }
        return "";
    }
 
    public object  ConvertBack(object  value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        throw new  NotImplementedException();
    }
}

MainViewModel

This pretty much just sets up the collection of Person with a set of names,

public class  MainViewModel : INotifyPropertyChanged
  {
 
      private List<Person> items = new List<Person> 
      {
        new Person{Name="Ann"},
        new Person{Name="Bbbbbbb Bbbbbb Bbbbbbbbb"},
        new Person{Name="Cccc ccccc ccccc"},
        new Person{Name="Ddddddd ddddddddddddd dddddddddd ddddd"}
      };
 
      public List<Person> Items
      {
          get
          {
              return items;
          }
          set
          {
              items = value;
          }
      }


 
      public MainViewModel( )
      {
 
      }
      public event  PropertyChangedEventHandler PropertyChanged;
      private void  OnPropertyChanged(string propertyName)
      {
          if (this.PropertyChanged != null)
          {
              PropertyChanged(this, new  PropertyChangedEventArgs(propertyName));
          }
      }
  }
  public class  Person
  {
      public string  Name {get; set;}
 
  }

This then looks like:

You can see the working sample here.

See Also

Silverlight Resources on the Technet WIki