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.