Andrew Jackson
Andrew Jackson

Reputation: 774

How can I determine if my TextBlock text is being trimmed?

The following textblock wraps and trims as expected. The elipsis "..." is displayed when the text is trimmed.

<TextBlock 
    MaxWidth="60" 
    MaxHeight="60" 
    Text="This is some long text which I would like to wrap."
    TextWrapping="Wrap" 
    TextTrimming="CharacterEllipsis" />

I would like to display a tooltip over the text with the full text, but only if the text is trimmed. I'm not sure how to reliably determine if the "..." is being shown or not.

How do I determine if the text is being trimmed or not?

Upvotes: 50

Views: 20226

Answers (6)

LWChris
LWChris

Reputation: 4221

Building upon the answers of Alek Davis and subsequently Scott Chamberlain, I came up with this modernized version of the code they mentioned:

public static class TextBlockService
{
    static TextBlockService()
    {
        // Register for the SizeChanged event on all TextBlocks, even if the event was handled.
        EventManager.RegisterClassHandler(
            typeof(TextBlock),
            FrameworkElement.SizeChangedEvent,
            new SizeChangedEventHandler(OnTextBlockSizeChanged),
            handledEventsToo: true);
    }

    private static readonly DependencyPropertyKey IsTextTrimmedKey = DependencyProperty.RegisterAttachedReadOnly(
        "IsTextTrimmed",
        typeof(bool),
        typeof(TextBlockService),
        new PropertyMetadata(false));

    public static readonly DependencyProperty IsTextTrimmedProperty = IsTextTrimmedKey.DependencyProperty;

    [AttachedPropertyBrowsableForType(typeof(TextBlock))]
    public static bool GetIsTextTrimmed(TextBlock target)
    {
        return (bool)target.GetValue(IsTextTrimmedProperty);
    }

    public static readonly DependencyProperty AutomaticToolTipEnabledProperty = DependencyProperty.RegisterAttached(
        "AutomaticToolTipEnabled",
        typeof(bool),
        typeof(TextBlockService),
        new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.Inherits));

    [AttachedPropertyBrowsableForType(typeof(DependencyObject))]
    public static bool GetAutomaticToolTipEnabled(DependencyObject element)
    {
        ArgumentNullException.ThrowIfNull(element);
        return (bool)element.GetValue(AutomaticToolTipEnabledProperty);
    }

    public static void SetAutomaticToolTipEnabled(DependencyObject element, bool value)
    {
        ArgumentNullException.ThrowIfNull(element);
        element.SetValue(AutomaticToolTipEnabledProperty, value);
    }

    private static void OnTextBlockSizeChanged(object sender, SizeChangedEventArgs e)
    {
        _ = TriggerTextRecalculationAsync(sender);
    }

    private static async Task TriggerTextRecalculationAsync(object sender)
    {
        if (sender is TextBlock textBlock)
        {
            if (textBlock.TextTrimming is TextTrimming.None)
            {
                textBlock.SetValue(IsTextTrimmedKey, false);
            }
            else
            {
                // If this function is called before databinding has finished, the tooltip will never show.
                // This invoke defers the calculation of the text trimming till after all current pending databinding
                // has completed.
                var isTextTrimmed = await textBlock.Dispatcher.InvokeAsync(() => CalculateIsTextTrimmed(textBlock), DispatcherPriority.DataBind);
                textBlock.SetValue(IsTextTrimmedKey, isTextTrimmed);
            }
        }
    }

    private static bool CalculateIsTextTrimmed(TextBlock textBlock)
    {
        if (!textBlock.IsArrangeValid)
        {
            return GetIsTextTrimmed(textBlock);
        }

        var typeface = new Typeface(
            textBlock.FontFamily,
            textBlock.FontStyle,
            textBlock.FontWeight,
            textBlock.FontStretch);

        // FormattedText is used to measure the whole width of the text held up by TextBlock container
        var formattedText = new FormattedText(
            textBlock.Text,
            Thread.CurrentThread.CurrentCulture,
            textBlock.FlowDirection,
            typeface,
            textBlock.FontSize,
            textBlock.Foreground,
            VisualTreeHelper.GetDpi(textBlock).PixelsPerDip)
        {
            MaxTextWidth = textBlock.ActualWidth
        };

        var height = textBlock.UseLayoutRounding
            ? Math.Round(formattedText.Height, 0)
            : formattedText.Height;

        // When the maximum text width of the FormattedText instance is set to the actual
        // width of the textBlock, if the textBlock is being trimmed to fit then the formatted
        // text will report a larger height than the textBlock. Should work whether the
        // textBlock is single or multi-line.
        // The "formattedText.MinWidth > formattedText.MaxTextWidth" check detects if any 
        // single line is too long to fit within the text area, this can only happen if there is a 
        // long span of text with no spaces.
        return height > textBlock.ActualHeight || formattedText.MinWidth > formattedText.MaxTextWidth;
    }
}

This version fixes three problems I encountered:

  1. The FormattedText constructor overload without pixelsPerDip parameter is now obsolete.
  2. I had a massive performance degradation under some specific circumstances that are probably unnoticeable for most people.
  3. If the TextBlock was used in a UseLayoutRounding="True" context, formattedText.Height might have been calculated to be 21.1 pixels, whereas the textBlock.ActualHeight was rounded to 21 pixels; therefore formattedText.Height > textBlock.ActualHeight (which was meant to detect line wraps) was always true, meaning the tool tip was always shown, even if the text wasn't trimmed.

Regarding 2: the circumstance was that I used these TextBlockServices in the DataTemplate of ListBox A with ~10 items. It was filtered using a TextBox input and a CollectionViewSource. The same input was also filtering a second ListBox B, that didn't use the TextBlockService but had ~2000 items with complex templates.

The issue was that I could use the filter and the lists would update in ~150 milliseconds. But once I opened the context menu of an item of list B for the first time, the filtering would become slow, taking 1.5 seconds in-between keypresses until the UI was updated and responsive again.

I managed to identify this service was causing a staggered UI thread loop execution by constantly waiting for the textBlock.Dispatcher.Invoke call in TriggerTextRecalculation.

At first those invocations would take 0-5 milliseconds per item, but after the context menu was opened once, they would take 150-350 milliseconds per item.

I am still not sure what's going on under the hood of WPF that cause this change in behavior, but making TriggerTextRecalculation asynchronous and using await ... InvokeAsync solved that problem, reducing every invocation to 10-15 milliseconds (so duration "before" got worse a little bit, but "after" was massively improved).

I tinkered with different variants like making CalculateIsTextTrimmed asynchronous as well and awaiting an empty callback in there, or using lower dispatcher priorities than DataBind, but it didn't feel like any of those changes influenced the perceived or measured performance beyond imprecision of my poor man's Stopwatch profiling.

Anyway I deemed it "good enough" for my use-case so I stopped optimizing further for now. But if anyone has any insights into what's going on, and how to get the times consistently back to the 0-5 milliseconds for before and after the context menu, feel free to comment and I'll try it out in the app.

Upvotes: 0

CameO73
CameO73

Reputation: 103

Expanding on bidy's answer. This will create a TextBlock that only shows the tooltip when not all text is shown. The tooltip will be resized to the contents (as opposed to the default tooltip which will remain a one-line box with the text cut off).

using System;
using System.Windows;
using System.Windows.Controls;

namespace MyComponents
{
    public class CustomTextBlock : TextBlock
    {
        protected override void OnInitialized(EventArgs e)
        {
            // we want a tooltip that resizes to the contents -- a textblock with TextWrapping.Wrap will do that
            var toolTipTextBlock = new TextBlock();
            toolTipTextBlock.TextWrapping = TextWrapping.Wrap;
            // bind the tooltip text to the current textblock Text binding
            var binding = GetBindingExpression(TextProperty);
            if (binding != null)
            {
                toolTipTextBlock.SetBinding(TextProperty, binding.ParentBinding);
            }

            var toolTipPanel = new StackPanel();
            toolTipPanel.Children.Add(toolTipTextBlock);
            ToolTip = toolTipPanel;

            base.OnInitialized(e);
        }

        protected override void OnToolTipOpening(ToolTipEventArgs e)
        {
            if (TextTrimming != TextTrimming.None)
            {
                e.Handled = !IsTextTrimmed();
            }
        }

        private bool IsTextTrimmed()
        {
            Measure(new Size(Double.PositiveInfinity, Double.PositiveInfinity));
            return ActualWidth < DesiredSize.Width;
        }
    }
}

XAML usage:

    <Window ...
        xmlns:components="clr-namespace:MyComponents"
     ... >
    
    <components:CustomTextBlock Text="{Binding Details}" TextTrimming="CharacterEllipsis" />

Upvotes: 3

Alek Davis
Alek Davis

Reputation: 10752

I haven't done a lot of WPF lately, so I'm not sure if this is what you're looking for, but check out this article: Customizing “lookful” WPF controls – Take 2. It's a bit complex, but it seems to address the same question you're asking. UPDATE: The website seems gone, but you can find the article in the archive. SEE Scott Chamberlain's ANSWER WITH THE SAMPLE CODE (thanks Scott).

Upvotes: 9

Nicole D
Nicole D

Reputation: 1

I had a small issue using Alex answer and had to change my logic a bit to clarify if the text in the text block was being trimmed.

var formattedText = new FormattedText(
            Text, System.Threading.Thread.CurrentThread.CurrentCulture, FlowDirection, typeface, FontSize,
            Foreground, VisualTreeHelper.GetDpi( this ).PixelsPerDip ) { MaxTextWidth = ActualWidth };
        //Measure(new Size(Double.PositiveInfinity, Double.PositiveInfinity));

 return ( Math.Floor(formattedText.Height ) > ActualHeight || Math.Floor( formattedText.MinWidth ) > ActualWidth;

This works perfectly for me.

I defined a user control that was a TextBlock with ellipsis enabled. Then I defined 2 functions for OnMouseUp and OnMouseDown, so that when the user clicked on the textblock that had overflow it would display a tooltip with the full value.

This is the OnMouseDown function

private void TextBlockWithToolTipView_OnMouseDown(
        object sender,
        MouseButtonEventArgs e )
    {
        var typeface = new Typeface(
            FontFamily,
            FontStyle,
            FontWeight,
            FontStretch);

        var formattedText = new FormattedText(
            Text, System.Threading.Thread.CurrentThread.CurrentCulture, FlowDirection, typeface, FontSize,
            Foreground, VisualTreeHelper.GetDpi( this ).PixelsPerDip ) { MaxTextWidth = ActualWidth };

        if (Math.Floor(formattedText.Height) > ActualHeight || Math.Floor(formattedText.MinWidth) > ActualWidth )
        {
            if( ToolTip is ToolTip tt )

            {
                {
                    if( tt.PlacementTarget == null )
                    {
                        tt.PlacementTarget = this;
                    }

                    tt.IsOpen = true;
                    e.Handled = true;
                }
            }
        }
    }

And this was the Xaml bit

<TextBlock 
         ToolTipService.IsEnabled="True"
         MouseDown="TextBlockWithToolTipView_OnMouseDown"
         MouseLeave="TextBlockWithToolTipView_OnMouseLeave"   
         TextTrimming="CharacterEllipsis"
         TextWrapping="WrapWithOverflow">
<TextBlock.ToolTip>
        <ToolTip 
            DataContext="{Binding Path=PlacementTarget, RelativeSource={x:Static RelativeSource.Self}}">
        <TextBlock Text="{Binding Path=Text, Mode=OneWay }"
                       TextWrapping="Wrap"/>
        </ToolTip>
    </TextBlock.ToolTip>
</TextBlock>

Upvotes: 0

Scott Chamberlain
Scott Chamberlain

Reputation: 127583

Because the link in Alek's answer is down, I found a cached copy of the link from the wayback machine. You can not download the code linked in the article, so here is a pre-assembled version of the code. There was one or two issues I ran in to while trying to make it work so this code is slightly different then the code in the examples in the article.

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;

namespace TextBlockService
{
    //Based on the project from http://web.archive.org/web/20130316081653/http://tranxcoder.wordpress.com/2008/10/12/customizing-lookful-wpf-controls-take-2/
    public static class TextBlockService
    {
        static TextBlockService()
        {
            // Register for the SizeChanged event on all TextBlocks, even if the event was handled.
            EventManager.RegisterClassHandler(
                typeof(TextBlock),
                FrameworkElement.SizeChangedEvent,
                new SizeChangedEventHandler(OnTextBlockSizeChanged),
                true);
        }


        private static readonly DependencyPropertyKey IsTextTrimmedKey = DependencyProperty.RegisterAttachedReadOnly("IsTextTrimmed", 
            typeof(bool), 
            typeof(TextBlockService), 
            new PropertyMetadata(false));

        public static readonly DependencyProperty IsTextTrimmedProperty = IsTextTrimmedKey.DependencyProperty;

        [AttachedPropertyBrowsableForType(typeof(TextBlock))]
        public static Boolean GetIsTextTrimmed(TextBlock target)
        {
            return (Boolean)target.GetValue(IsTextTrimmedProperty);
        }


        public static readonly DependencyProperty AutomaticToolTipEnabledProperty = DependencyProperty.RegisterAttached(
            "AutomaticToolTipEnabled",
            typeof(bool),
            typeof(TextBlockService),
            new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.Inherits));

        [AttachedPropertyBrowsableForType(typeof(DependencyObject))]
        public static Boolean GetAutomaticToolTipEnabled(DependencyObject element)
        {
            if (null == element)
            {
                throw new ArgumentNullException("element");
            }
            return (bool)element.GetValue(AutomaticToolTipEnabledProperty);
        }

        public static void SetAutomaticToolTipEnabled(DependencyObject element, bool value)
        {
            if (null == element)
            {
                throw new ArgumentNullException("element");
            }
            element.SetValue(AutomaticToolTipEnabledProperty, value);
        }

        private static void OnTextBlockSizeChanged(object sender, SizeChangedEventArgs e)
        {
            TriggerTextRecalculation(sender);
        }

        private static void TriggerTextRecalculation(object sender)
        {
            var textBlock = sender as TextBlock;
            if (null == textBlock)
            {
                return;
            }

            if (TextTrimming.None == textBlock.TextTrimming)
            {
                textBlock.SetValue(IsTextTrimmedKey, false);
            }
            else
            {
                //If this function is called before databinding has finished the tooltip will never show.
                //This invoke defers the calculation of the text trimming till after all current pending databinding
                //has completed.
                var isTextTrimmed = textBlock.Dispatcher.Invoke(() => CalculateIsTextTrimmed(textBlock), DispatcherPriority.DataBind);
                textBlock.SetValue(IsTextTrimmedKey, isTextTrimmed);
            }
        }

        private static bool CalculateIsTextTrimmed(TextBlock textBlock)
        {
            if (!textBlock.IsArrangeValid)
            {
                return GetIsTextTrimmed(textBlock);
            }

            Typeface typeface = new Typeface(
                textBlock.FontFamily,
                textBlock.FontStyle,
                textBlock.FontWeight,
                textBlock.FontStretch);

            // FormattedText is used to measure the whole width of the text held up by TextBlock container
            FormattedText formattedText = new FormattedText(
                textBlock.Text,
                System.Threading.Thread.CurrentThread.CurrentCulture,
                textBlock.FlowDirection,
                typeface,
                textBlock.FontSize,
                textBlock.Foreground);

            formattedText.MaxTextWidth = textBlock.ActualWidth;

            // When the maximum text width of the FormattedText instance is set to the actual
            // width of the textBlock, if the textBlock is being trimmed to fit then the formatted
            // text will report a larger height than the textBlock. Should work whether the
            // textBlock is single or multi-line.
            // The "formattedText.MinWidth > formattedText.MaxTextWidth" check detects if any 
            // single line is too long to fit within the text area, this can only happen if there is a 
            // long span of text with no spaces.
            return (formattedText.Height > textBlock.ActualHeight || formattedText.MinWidth > formattedText.MaxTextWidth);
        }

    }
}
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:tbs="clr-namespace:TextBlockService">
    <!--
    Rather than forcing *all* TextBlocks to adopt TextBlockService styles,
    using x:Key allows a more friendly opt-in model.
    -->

    <Style TargetType="TextBlock" x:Key="TextBlockService">
        <Style.Triggers>
            <MultiTrigger>
                <MultiTrigger.Conditions>
                    <Condition Property="tbs:TextBlockService.AutomaticToolTipEnabled" Value="True" />
                    <Condition Property="tbs:TextBlockService.IsTextTrimmed" Value="True"/>
                </MultiTrigger.Conditions>

                <Setter Property="ToolTip" Value="{Binding RelativeSource={x:Static RelativeSource.Self}, Path=Text}" />
            </MultiTrigger>
        </Style.Triggers>
    </Style>
</ResourceDictionary>

Upvotes: 32

Christian Regli
Christian Regli

Reputation: 2246

The solution above didn't work for me if the TextBlock is part of a ListBoxItem DataTemplate. I propose another solution:

public class MyTextBlock : System.Windows.Controls.TextBlock
{

    protected override void OnToolTipOpening(WinControls.ToolTipEventArgs e)
    {
        if (TextTrimming != TextTrimming.None)
        {
            e.Handled = !IsTextTrimmed(); 
        }
    }

    private bool IsTextTrimmed()
    {
        Measure(new Size(Double.PositiveInfinity, Double.PositiveInfinity));
        return ActualWidth < DesiredSize.Width;
    }
}

XAML:

  <MyTextBlock Text="{Binding Text}" TextTrimming="CharacterEllipsis" ToolTip="{Binding Text}" />

Upvotes: 5

Related Questions