I'm trying to display a DateTime
formatted like 2019-10-07 17:00
in a TextBlock
. The text should be underlined and dashed. To do this I'm using the following xaml
<TextBlock Text="2019-10-07 17:00">
<TextBlock.TextDecorations>
<TextDecoration Location="Underline">
<TextDecoration.Pen>
<Pen Brush="Black">
<Pen.DashStyle>
<DashStyle Dashes="5"/>
</Pen.DashStyle>
</Pen>
</TextDecoration.Pen>
</TextDecoration>
</TextBlock.TextDecorations>
</TextBlock>
However, this produces some very unexpected results where it seems like each hyphen causes the dashed underline to restart its rendering. Notice the dash-pattern which looks almost random efter each hyphen.
If I change the "minus-sign-hyphen" to "non-breaking-hyphen" which looks very similar (- vs ‐), the rendering works as expected.
<TextBlock Text="2019‐10‐07 17:00" ...>
This buggy rendering of the dashed underline happends everytime I add a minus-sign-hyphen to the text but not with any other character that I could find. Has anyone else noticed this and does anyone have a solution? If not, what might be the reason for this weird behavior?
In the end, we built a custom control called DashTextBlock
to solve this issue. It derives from TextBox
and is styled like a TextBlock
with an added TextDecoration
that uses a Pen
with a LinearGradientBrush
that is set up according to whatever what specified as "dash-properties" and the thickness of DashThickness
.
To achieve this it uses the TextBox
method GetRectFromCharacterIndex
to figure out how to setup the LinearGradientBrush
.
TextBox.GetRectFromCharacterIndex Method
Returns the rectangle for an edge of the character at the specified index.
It produces results like this
Sample usage
<StackPanel>
<controls:DashTextBlock Text="Testing DashTextBlock"
DashThickness="1"
DashColor="Blue">
<controls:DashTextBlock.DashStyle>
<DashStyle Dashes="4,4,4,4" Offset="0" />
</controls:DashTextBlock.DashStyle>
</controls:DashTextBlock>
<controls:DashTextBlock Text="Testing DashTextBlock"
Margin="0 5 0 0"
DashThickness="2"
DashColor="Orange">
<controls:DashTextBlock.DashStyle>
<DashStyle Dashes="8 4 8 4" Offset="0" />
</controls:DashTextBlock.DashStyle>
</controls:DashTextBlock>
</StackPanel>
DashTextBlock
public class DashTextBlock : TextBox
{
public static readonly DependencyProperty DashColorProperty =
DependencyProperty.Register("DashColor",
typeof(Color),
typeof(DashTextBlock),
new FrameworkPropertyMetadata(Colors.Black, OnDashColorChanged));
public static readonly DependencyProperty DashThicknessProperty =
DependencyProperty.Register("DashThickness",
typeof(double),
typeof(DashTextBlock),
new FrameworkPropertyMetadata(1.0, OnDashThicknessChanged));
public static readonly DependencyProperty DashStyleProperty =
DependencyProperty.Register("DashStyle",
typeof(DashStyle),
typeof(DashTextBlock),
new FrameworkPropertyMetadata(DashStyles.Solid, OnDashStyleChanged));
private static readonly DependencyProperty FontSizeCallbackProperty =
DependencyProperty.Register("FontSizeCallback",
typeof(double),
typeof(DashTextBlock),
new FrameworkPropertyMetadata(0.0, OnFontSizeCallbackChanged));
public static readonly DependencyProperty TextLengthProperty =
DependencyProperty.Register("TextLength",
typeof(double),
typeof(DashTextBlock),
new FrameworkPropertyMetadata(0.0));
public static readonly DependencyProperty DashEnabledProperty =
DependencyProperty.Register("DashEnabled",
typeof(bool),
typeof(DashTextBlock),
new FrameworkPropertyMetadata(true, OnDashEnabledChanged));
private static void OnDashColorChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
DashTextBlock dashTextBlock = source as DashTextBlock;
dashTextBlock.DashColorChanged();
}
private static void OnDashThicknessChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
DashTextBlock dashTextBlock = source as DashTextBlock;
dashTextBlock.DashThicknessChanged();
}
private static void OnDashStyleChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
DashTextBlock dashTextBlock = source as DashTextBlock;
dashTextBlock.DashStyleChanged();
}
private static void OnFontSizeCallbackChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
DashTextBlock dashTextBlock = source as DashTextBlock;
dashTextBlock.FontSizeChanged();
}
private static void OnDashEnabledChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
DashTextBlock dashTextBlock = source as DashTextBlock;
dashTextBlock.DashEnabledChanged();
}
private static Pen _transparentPen;
static DashTextBlock()
{
_transparentPen = new Pen(Brushes.Transparent, 0);
_transparentPen.Freeze();
DefaultStyleKeyProperty.OverrideMetadata(typeof(DashTextBlock), new FrameworkPropertyMetadata(typeof(DashTextBlock)));
}
private TextDecoration _dashDecoration = new TextDecoration();
public DashTextBlock()
{
Binding fontSizeCallbackBinding = new Binding();
fontSizeCallbackBinding.Source = this;
fontSizeCallbackBinding.Path = new PropertyPath(TextBlock.FontSizeProperty);
this.SetBinding(FontSizeCallbackProperty, fontSizeCallbackBinding);
TextChanged += DashTextBlock_TextChanged;
this.LayoutUpdated += DashTextBlock_LayoutUpdated;
}
private void DashTextBlock_LayoutUpdated(object sender, EventArgs e)
{
if (IsLoaded)
{
var textRect = GetRectFromCharacterIndex(Text.Length);
double availableWidth = textRect.Right;
if (textRect.IsEmpty == false &&
availableWidth > 0)
{
this.LayoutUpdated -= DashTextBlock_LayoutUpdated;
UpdateTextWithDashing();
}
}
}
public Color DashColor
{
get { return (Color)GetValue(DashColorProperty); }
set { SetValue(DashColorProperty, value); }
}
public double DashThickness
{
get { return (double)GetValue(DashThicknessProperty); }
set { SetValue(DashThicknessProperty, value); }
}
public DashStyle DashStyle
{
get { return (DashStyle)GetValue(DashStyleProperty); }
set { SetValue(DashStyleProperty, value); }
}
private double FontSizeCallback
{
get { return (double)GetValue(FontSizeCallbackProperty); }
set { SetValue(FontSizeCallbackProperty, value); }
}
public double TextLength
{
get { return (double)GetValue(TextLengthProperty); }
set { SetValue(TextLengthProperty, value); }
}
public bool DashEnabled
{
get { return (bool)GetValue(DashEnabledProperty); }
set { SetValue(DashEnabledProperty, value); }
}
private void DashTextBlock_TextChanged(object sender, TextChangedEventArgs e)
{
UpdateTextWithDashing();
}
private void FontSizeChanged()
{
//UpdateTextWithDashing();
}
private void DashEnabledChanged()
{
UpdateTextWithDashing();
}
private void DashColorChanged()
{
UpdateTextWithDashing();
}
private void DashStyleChanged()
{
UpdateTextWithDashing();
}
private void DashThicknessChanged()
{
UpdateTextWithDashing();
}
public void UpdateTextWithDashing()
{
AddDashDecoration();
_dashDecoration.Pen = CreatePenFromProperties();
}
private Pen CreatePenFromProperties()
{
if (!DashEnabled)
{
return _transparentPen;
}
if (DashStyle.Dashes.Count < 2 ||
IsLoaded == false ||
Text.Length == 0)
{
return new Pen(new SolidColorBrush(DashColor), DashThickness);
}
double length = 0.0;
foreach (var dash in DashStyle.Dashes)
{
length += dash;
}
double stepLength = 1.0 / length;
TextBox textBox = this as TextBox;
Rect textRect = Rect.Empty;
for (int l = (textBox.Text.Length - 1); l >= 0; l--)
{
if (textBox.Text[l] != ' ')
{
try
{
textRect = textBox.GetRectFromCharacterIndex(l + 1);
}
catch
{
// See possible bug here:
// https://referencesource.microsoft.com/#PresentationFramework/src/Framework/System/Windows/Controls/VirtualizingStackPanel.cs,8060
// TODO: Revisit after migrate to .NET 5
}
break;
}
}
double target = FontSize;
double availableWidth = textRect.Right;
if (textRect.IsEmpty == false &&
availableWidth > 0)
{
TextLength = availableWidth;
double current = 0;
bool count = true;
bool foundTargetLength = false;
double savedDashes = 0.0;
while (!foundTargetLength)
{
for (int i = 0; i < DashStyle.Dashes.Count; i++)
{
var dash = DashStyle.Dashes[i];
savedDashes += dash;
double increase = (target * (dash * stepLength));
double preDiff = availableWidth - current;
current += increase;
double postDiff = current - availableWidth;
if (current > availableWidth)
{
if (!count)
{
if (postDiff < preDiff || Text.Length <= 2)
{
if ((i + 1) < DashStyle.Dashes.Count)
{
savedDashes += DashStyle.Dashes[i + 1];
}
else
{
savedDashes += DashStyle.Dashes[0];
}
}
else
{
if (i == 0)
{
savedDashes -= DashStyle.Dashes.Last();
}
else
{
savedDashes -= DashStyle.Dashes[i - 1];
}
}
}
foundTargetLength = true;
target = availableWidth / (savedDashes * stepLength);
break;
}
count = !count;
}
}
}
LinearGradientBrush dashBrush = new LinearGradientBrush();
dashBrush.StartPoint = new Point(0, 0);
dashBrush.EndPoint = new Point(target, 0);
dashBrush.MappingMode = BrushMappingMode.Absolute;
dashBrush.SpreadMethod = GradientSpreadMethod.Repeat;
double offset = 0.0;
bool isFill = true;
foreach (var dash in DashStyle.Dashes)
{
GradientStop gradientStop = new GradientStop();
gradientStop.Offset = offset;
gradientStop.Color = isFill ? DashColor : Colors.Transparent;
dashBrush.GradientStops.Add(gradientStop);
offset += (dash * stepLength);
gradientStop = new GradientStop();
gradientStop.Offset = offset;
gradientStop.Color = isFill ? DashColor : Colors.Transparent;
dashBrush.GradientStops.Add(gradientStop);
isFill = !isFill;
}
Pen dashPen = new Pen(dashBrush, DashThickness);
return dashPen;
}
private void AddDashDecoration()
{
foreach (TextDecoration textDecoration in TextDecorations)
{
if (textDecoration == _dashDecoration)
{
return;
}
}
TextDecorations.Add(_dashDecoration);
}
}
Style
<Style TargetType="{x:Type controls:DashTextBlock}">
<Setter Property="IsReadOnly" Value="True"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type controls:DashTextBlock}">
<Border x:Name="border"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="0"
Background="{TemplateBinding Background}"
SnapsToDevicePixels="True">
<ScrollViewer x:Name="PART_ContentHost"
Focusable="False"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Hidden"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" TargetName="border" Value="0.56"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>