How to draw outlined text in WPF and then how to draw anything at all on that text
The question had a misleading title: How to inherit a textblock properties to a custom control in c# and had a 500 points reward on it (that's a lot) placed there by another person than the original requester. In fact, the question was more about how to use a normal TextBlock control, but also have it display outlined text, with a specific "stroke" and thickness. Funny thing, I had already answered this question a few days before. The bounty, though, was set on a more formal answer, one that would cover any graphical transformation on a TextBlock, considering that the control had sealed the OnRender overload and there was no way of reaching its drawing context.
We need to consider that WPF was designed to be modular, unlike ASP.Net or Windows Forms, for which inheritance was the preferred way to go. Instead WPF favors composition. That is why controls are sealing their OnRender implementation, because there is another way of getting to the drawing context and that is an Adorner. Now, it is also possible to use an Effect, but for the life of me I couldn't understand how to easily write one.
Anyway, adorners have their pros and cons. The pro is that you get to still use a TextBlock or whatever control you want to use and you just adorn it with what you need. It receives a UIElement in the constructor and in its OnRender method you get access to the drawing context of the control. Here is the code of the adorner that I presented in the StackOverflow question:
public class StrokeAdorner : Adorner
{
private TextBlock _textBlock;
private Brush _stroke;
private ushort _strokeThickness;
public Brush Stroke
{
get
{
return _stroke;
}
set
{
_stroke = value;
_textBlock.InvalidateVisual();
InvalidateVisual();
}
}
public ushort StrokeThickness
{
get
{
return _strokeThickness;
}
set
{
_strokeThickness = value;
_textBlock.InvalidateVisual();
InvalidateVisual();
}
}
public StrokeAdorner(UIElement adornedElement) : base(adornedElement)
{
_textBlock = adornedElement as TextBlock;
ensureTextBlock();
foreach (var property in TypeDescriptor.GetProperties(_textBlock).OfType<PropertyDescriptor>())
{
var dp = DependencyPropertyDescriptor.FromProperty(property);
if (dp == null) continue;
var metadata = dp.Metadata as FrameworkPropertyMetadata;
if (metadata == null) continue;
if (!metadata.AffectsRender) continue;
dp.AddValueChanged(_textBlock, (s, e) => this.InvalidateVisual());
}
}
private void ensureTextBlock()
{
if (_textBlock == null) throw new Exception("This adorner works on TextBlocks only");
}
protected override void OnRender(DrawingContext drawingContext)
{
ensureTextBlock();
base.OnRender(drawingContext);
var formattedText = new FormattedText(
_textBlock.Text,
CultureInfo.CurrentUICulture,
_textBlock.FlowDirection,
new Typeface(_textBlock.FontFamily, _textBlock.FontStyle, _textBlock.FontWeight, _textBlock.FontStretch),
_textBlock.FontSize,
Brushes.Black // This brush does not matter since we use the geometry of the text.
);
formattedText.TextAlignment = _textBlock.TextAlignment;
formattedText.Trimming = _textBlock.TextTrimming;
formattedText.LineHeight = _textBlock.LineHeight;
formattedText.MaxTextWidth = _textBlock.ActualWidth - _textBlock.Padding.Left - _textBlock.Padding.Right;
formattedText.MaxTextHeight = _textBlock.ActualHeight - _textBlock.Padding.Top;// - _textBlock.Padding.Bottom;
while (formattedText.Extent==double.NegativeInfinity)
{
formattedText.MaxTextHeight++;
}
// Build the geometry object that represents the text.
var _textGeometry = formattedText.BuildGeometry(new Point(_textBlock.Padding.Left, _textBlock.Padding.Top));
var textPen = new Pen(Stroke, StrokeThickness);
drawingContext.DrawGeometry(Brushes.Transparent, textPen, _textGeometry);
}
}
The first con is that you need to use it in code, there is no native way of using it from XAML. The second con, and the most brutal, is that when the control changes what it renders, the adorner doesn't follow suit! Someone answered it better than I can describe it here. Guess where? On StackOverflow, of course.
The first problem I have solved with another great WPF contraption: attached properties. Here is the code for the properties:
public static class Adorningand an example of use:
{
public static Brush GetStroke(DependencyObject obj)
{
return (Brush)obj.GetValue(StrokeProperty);
}
public static void SetStroke(DependencyObject obj, Brush value)
{
obj.SetValue(StrokeProperty, value);
}
// Using a DependencyProperty as the backing store for Stroke. This enables animation, styling, binding, etc...
public static readonly DependencyProperty StrokeProperty =
DependencyProperty.RegisterAttached("Stroke", typeof(Brush), typeof(Adorning), new PropertyMetadata(Brushes.Transparent, strokeChanged));
private static void strokeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var stroke= e.NewValue as Brush;
ensureAdorner(d,a=>a.Stroke=stroke);
}
private static void ensureAdorner(DependencyObject d, Action<StrokeAdorner> action)
{
var tb = d as TextBlock;
if (tb == null) throw new Exception("StrokeAdorner only works on TextBlocks");
EventHandler f = null;
f = new EventHandler((o, e) =>
{
var adornerLayer = AdornerLayer.GetAdornerLayer(tb);
if (adornerLayer == null) throw new Exception("AdornerLayer should not be empty");
var adorners = adornerLayer.GetAdorners(tb);
var adorner = adorners == null ? null : adorners.OfType<StrokeAdorner>().FirstOrDefault();
if (adorner == null)
{
adorner = new StrokeAdorner(tb);
adornerLayer.Add(adorner);
}
tb.LayoutUpdated -= f;
action(adorner);
});
tb.LayoutUpdated += f;
}
public static double GetStrokeThickness(DependencyObject obj)
{
return (double)obj.GetValue(StrokeThicknessProperty);
}
public static void SetStrokeThickness(DependencyObject obj, double value)
{
obj.SetValue(StrokeThicknessProperty, value);
}
// Using a DependencyProperty as the backing store for StrokeThickness. This enables animation, styling, binding, etc...
public static readonly DependencyProperty StrokeThicknessProperty =
DependencyProperty.RegisterAttached("StrokeThickness", typeof(double), typeof(Adorning), new PropertyMetadata(0.0, strokeThicknessChanged));
private static void strokeThicknessChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
ensureAdorner(d, a =>
{
if (DependencyProperty.UnsetValue.Equals(e.NewValue)) return;
a.StrokeThickness = (ushort)(double)e.NewValue;
});
}
}
<TextBlock
x:Name="t1"
HorizontalAlignment="Stretch"
FontSize="40"
FontWeight="Bold"
local:Adorning.Stroke="Red"
local:Adorning.StrokeThickness="2"
Text="Some text that needs to be outlined"
TextAlignment="Center"
TextWrapping="Wrap"
Width="600">
<TextBlock.Foreground>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="Green" />
<GradientStop Offset="1" Color="Blue" />
</LinearGradientBrush>
</TextBlock.Foreground>
</TextBlock>
Now for the second problem, the StrokeAdorner already has a fix in the code, but I need to be more specific about it, because as it is written now I believe it leaks memory. Nothing terribly serious, but still. The code I am talking about is in the constructor:
foreach (var property in TypeDescriptor.GetProperties(_textBlock).OfType<PropertyDescriptor>())Here I am enumerating each property of the target (the TextBlock) and checking if they are dependency properties and if they have in their metadata the AffectsRender flag, they I add a property change handler which calls InvalidateVisual on the adorner. Notice that in no part of the code do I remove those handlers. However, at this time I don't think it is a problem. Anyway, the code itself is more about the principles of the thing, rather than the implementation.
{
var dp = DependencyPropertyDescriptor.FromProperty(property);
if (dp == null) continue;
var metadata = dp.Metadata as FrameworkPropertyMetadata;
if (metadata == null) continue;
if (!metadata.AffectsRender) continue;
dp.AddValueChanged(_textBlock, (s, e) => this.InvalidateVisual());
}
If I were to talk about the implementation, I would say that this code doesn't always work. Even if I use the padding of the element and its actual dimensions, the FormattedText sometimes renders things differently from the TextBlock, especially if one plays with TextWrap and TextTrimming. But that is another subject altogether. Yay! 1000 points on StackOverflow! "And what do you do with the points?" [my wife :(]