Search code examples
wpftextviewword-wraptext-size

Combining text wrapping and shrinking in WPF


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: Example

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.


Solution

  • 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.