I my WPF app I have a relatively small box of limited width, where I need to display some text which has been entered by the user. The text can realistically be expected to be between one and five words, but the words can easily be larger than the box.
If the text is too long, but contains multiple words which can be broken up into lines, I'd want the text to wrap. However, if any single word is too large to fit, then I want the text size to shrink until that word is small enough to fit, regardless of whether or not that text is also wrapping. I don't care how much vertical space the text takes up.
Here's an example I put together manually in Excel to demonstrate the intended behavior:
In example 1 the whole text fits in the box.
In example 2 the text is two words, so it can be wrapped without shrinking the text.
In example 3, the single word is too long so the text has to be shrunk.
In example 4 the text can be wrapped but it still contains a word that is too long to fit, so the text has to be shrunk until that longest word can fit.
How can I accomplish this in WPF? I haven't been able to find a combination of ViewBox
and TextBlock.TextWrapping
which does this.
EDIT:
If I do have to do this manually (which would be a bit of a nightmare), then is there at least a way I can figure out what the TextBlock decides is a "line"? I would need to know how it's going to break up the text before I could identify if any one "line" is going to be too long.
Seeing as no real solution to this exists, I ended up coding it myself:
Imports System.ComponentModel
Imports System.Windows
Imports System.Windows.Controls
Imports System.Windows.Documents
Public Class TextScalerBehavior
Public Shared ReadOnly ShrinkToFitProperty As DependencyProperty = DependencyProperty.RegisterAttached("ShrinkToFit", GetType(Boolean), GetType(TextScalerBehavior), New PropertyMetadata(False, New PropertyChangedCallback(AddressOf ShrinkToFitChanged)))
Public Shared Function GetShrinkToFit(obj As TextBlock) As Boolean
Return obj.GetValue(ShrinkToFitProperty)
End Function
Public Shared Sub SetShrinkToFit(obj As TextBlock, value As Boolean)
obj.SetValue(ShrinkToFitProperty, value)
End Sub
Protected Shared Sub ShrinkToFitChanged(d As DependencyObject, e As DependencyPropertyChangedEventArgs)
Dim tb As TextBlock = d
If e.NewValue Then
tb.AddHandler(TextBlock.SizeChangedEvent, TargetSizeChangedEventHandler)
With DependencyPropertyDescriptor.FromProperty(TextBlock.TextProperty, GetType(TextBlock))
.AddValueChanged(tb, TargetTextChangedEventHandler)
End With
tb.AddHandler(TextBlock.LoadedEvent, TargetLoadedEventHandler)
Else
tb.RemoveHandler(TextBlock.SizeChangedEvent, TargetSizeChangedEventHandler)
With DependencyPropertyDescriptor.FromProperty(TextBlock.TextProperty, GetType(TextBlock))
.RemoveValueChanged(tb, TargetTextChangedEventHandler)
End With
tb.RemoveHandler(TextBlock.LoadedEvent, TargetLoadedEventHandler)
End If
End Sub
Protected Shared ReadOnly TargetSizeChangedEventHandler As New RoutedEventHandler(AddressOf TargetSizeChanged)
Protected Shared Sub TargetSizeChanged(Target As TextBlock, e As RoutedEventArgs)
Update(Target)
End Sub
Protected Shared ReadOnly TargetTextChangedEventHandler As New EventHandler(AddressOf TargetTextChanged)
Protected Shared Sub TargetTextChanged(Target As TextBlock, e As EventArgs)
Update(Target)
End Sub
Protected Shared ReadOnly TargetLoadedEventHandler As New RoutedEventHandler(AddressOf TargetLoaded)
Protected Shared Sub TargetLoaded(Target As TextBlock, e As RoutedEventArgs)
Update(Target)
End Sub
Private Shared ReadOnly Shrinkging As New HashSet(Of TextBlock)
Protected Shared Sub Update(Target As TextBlock)
If Target.IsLoaded Then
Dim Clip = Primitives.LayoutInformation.GetLayoutClip(Target)
If Clip IsNot Nothing Then
If Not Shrinkging.Contains(Target) Then Shrinkging.Add(Target)
Target.FontSize -= 1
ElseIf Target.FontSize < TextElement.GetFontSize(Target.Parent) Then
If Shrinkging.Contains(Target) Then
Shrinkging.Remove(Target)
Else
Target.FontSize += 1
End If
End If
End If
End Sub
End Class
This class implements the behavior I need as a WPF attached behavior using attached dependency properties. The magic happens in the final routine: Update
.
In WPF, if a given element is being clipped (i.e. it's bigger than the space it's allowed to take up, so it gets cut off), then LayoutInformation.GetLayoutClip
returns data on what area of the element is visible. If an element is not clipped, this seems to return null (though the docs don't say that).
A TextBlock with TextWrapping="WrapWithOverflow"
will "overflow" past the edges of its container if any single line is too big to be broken correctly.
The Update
routine checks to see if this clipping is occurring and if so lowers the font size by 1. This changes the size of the TextBlock and triggers another round of Update
, which continues the cycle until the element no longer clips.
There is also additional logic to scale the font back up to its original size if the available space increases.
An example of usage is:
<TextBlock [YourNamespace]:TextScalerBehavior.ShrinkToFit="True" TextWrapping="WrapWithOverflow"/>
Remember that TextWrapping="WrapWithOverflow"
is required.