WPFで文字の縁取りをしたかったんですが、色々と手段があるようだったので、調べた内容をまとめておきます。
縁取り文字用のカスタムコントロールを作る方法
まず、Microsoft公式が「方法: 中抜きの文字列を作成する」という、まさにそのようなドキュメントを用意しています。
ここに記載してある例をコピペしたら、縁取り文字をレンダリングすることができました。
しかし、このサンプルだと TextBlock のような TextAlignment プロパティがなかったり、色々と XAML 上で扱うのに足りていないプロパティがあります。その辺をひとつひとつ実装してくのも骨が折れそうなのでやりたくはないです。
試しにこのサンプルの基底クラスを FrameworkElement ではなく、TextBlock に変更してみたんですが、TextBlock.OnRender() が sealed になっていたため、override できなくてダメでした。
TextBlock.Effect を使用した方法
他の方法を探してみると TextBlock に DropShadowEffect や BlurEffect で影を描く方法が見つかりました。
https://stackoverflow.com/a/35976509
以下はDropShadowEffectを使う例です。
<TextBlock Foreground="White" >
<TextBlock.Effect>
<DropShadowEffect ShadowDepth="0"
Color="Black"
Opacity="1"
BlurRadius="5"/>
</TextBlock.Effect>
Some text that we want outlined
</TextBlock>
以下は影用の BlurEffect を適用した TextBlock の上にもう一度通常の文字を描く例です。
<Grid>
<TextBlock
FontSize="30"
Foreground="Black"
Text="あいうえお">
<TextBlock.Effect>
<BlurEffect KernelType="Box" Radius="5.0" />
</TextBlock.Effect>
</TextBlock>
<TextBlock
FontSize="30"
Foreground="White"
Text="あいうえお">
</TextBlock>
</Grid>
どちらの例もXAMLだけで完結してお手軽ではあります。この方法で描画すると以下のような影ができました。
この影用の TextBlock を更に複数個配置することで、縁っぽい見た目にはなりました。
しかし、細い縁ならいいんですが、太い縁を描こうとすると何個もぼかした TextBlock を重ねないといけないので、制御が難しい上に、無駄に描画コストもかかってしまってイマイチだなぁと思いました。
本当にフォントに影をつけたいだけだったら適しているんですが、くっきりした縁取り文字に応用するにはイマイチです。
Adorner(装飾)を使う方法(個人的にベストな方法)
更に他にいい方法がないか探してみると Adorner を使う方法を発見しました(Adorner の詳細は Microsoftの「装飾の概要」に記載されているのでそちらを参照してください)。
https://stackoverflow.com/a/36061935
ここに記載のソースコードを丸コピしたところ、縁取りの文字が出ました。しかもちゃんとTextBlockに対して縁取りしているので、<Grid>の中に描画すれば、Grid が TextBlock が収まるサイズに拡縮してくれる(最初の方法では文字サイズが無視されて親 Grid は豆粒みたいになってしまっていた)。
ちゃんとコピペで動くコードを質問への回答に貼ってくれる方は神。本当にありがたい。
私の用途ではグラデーションは不要だったのでForegroundを単色ブラシに変更しました。
こちらにも私が動作を確認したソースコードを転載しておきます。以下が .cs のソースです。
using System;
using System.Linq;
using System.Globalization;
using System.Windows.Media;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.ComponentModel;
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.
,1.0
);
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);
}
}
public class StrokeTextBlock : TextBlock
{
private StrokeAdorner _adorner;
private bool _adorned = false;
public StrokeTextBlock()
{
_adorner = new StrokeAdorner(this);
this.LayoutUpdated += StrokeTextBlock_LayoutUpdated;
}
private void StrokeTextBlock_LayoutUpdated(object sender, EventArgs e)
{
if (_adorned) return;
_adorned = true;
var adornerLayer = AdornerLayer.GetAdornerLayer(this);
adornerLayer.Add(_adorner);
this.LayoutUpdated -= StrokeTextBlock_LayoutUpdated;
}
private static void strokeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var stb = (StrokeTextBlock)d;
stb._adorner.Stroke = e.NewValue as Brush;
}
private static void strokeThicknessChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var stb = (StrokeTextBlock)d;
stb._adorner.StrokeThickness = DependencyProperty.UnsetValue.Equals(e.NewValue) ? (ushort)0 : (ushort)e.NewValue;
}
/// <summary>
/// Specifies the brush to use for the stroke and optional hightlight of the formatted text.
/// </summary>
public Brush Stroke
{
get
{
return (Brush)GetValue(StrokeProperty);
}
set
{
SetValue(StrokeProperty, value);
}
}
/// <summary>
/// Identifies the Stroke dependency property.
/// </summary>
public static readonly DependencyProperty StrokeProperty = DependencyProperty.Register(
"Stroke",
typeof(Brush),
typeof(StrokeTextBlock),
new FrameworkPropertyMetadata(
new SolidColorBrush(Colors.Teal),
FrameworkPropertyMetadataOptions.AffectsRender,
new PropertyChangedCallback(strokeChanged),
null
)
);
/// <summary>
/// The stroke thickness of the font.
/// </summary>
public ushort StrokeThickness
{
get
{
return (ushort)GetValue(StrokeThicknessProperty);
}
set
{
SetValue(StrokeThicknessProperty, value);
}
}
/// <summary>
/// Identifies the StrokeThickness dependency property.
/// </summary>
public static readonly DependencyProperty StrokeThicknessProperty = DependencyProperty.Register(
"StrokeThickness",
typeof(ushort),
typeof(StrokeTextBlock),
new FrameworkPropertyMetadata(
(ushort)0,
FrameworkPropertyMetadataOptions.AffectsRender,
new PropertyChangedCallback(strokeThicknessChanged),
null
)
);
}
public static class Adorning
{
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;
});
}
}
以下が XAML の TextBlock の部分です。
<TextBlock Grid.Row="2" local:Adorning.Stroke="Black"
local:Adorning.StrokeThickness="3"
FontWeight="Bold" FontSize="90" Foreground="White" Text="あいうえお" />