Ellipsis at start of string in WPF ListView
Asked Answered
R

8

13

I have a WPF ListView (GridView) and the cell template contains a TextBlock. If I add: TextTrimming="CharacterEllipsis" TextWrapping="NoWrap" on the TextBlock, an ellipsis will appear at the end of my string when the column gets smaller than the length of the string. What I need is to have the ellipsis at the beginning of the string.

I.e. if I have the string Hello World!, I would like ...lo World!, instead of Hello W....

Any ideas?

Reformation answered 4/3, 2009 at 22:34 Comment(4)
Maybe change the title to "Ellipsis at start of string in WPF ListView"Gayton
I agree with Dave, but if you don't want to go that far, at least name your post "Left side ellipsis."Visor
Still cant get my left and my right correct :PReformation
Any idea how to change the title?Reformation
H
2

You could try to use a ValueConverter (cf. IValueConverter interface) to change the strings that should be displayed in the list box yourself. That is, in the implementation of the Convert method, you would test if the strings are longer than the available space, and then change them to ... plus the right side of the string.

Haematosis answered 9/3, 2009 at 9:20 Comment(4)
Yeah that's what I did in the end and it works like a charm ;) Thanks!Reformation
I understand how a ValueConverter provides a point at which you can intercept things, but where are you getting the available space from, and what means are you using to judge whether the string/substrings fit within that space?Canaan
Answer the above question please!! We are all aware of the existence of IValueConverter interface! Your answer is like saying, "In order to do that, write the code that does it!" We KNOW we should write some code in an IVlaueConverter. What is it? How? When? Who? Where?!Cicada
@SimonLevy: Could you please post your solution here?Haematosis
D
11

I was facing the same problem and wrote an attached property to solve this (or to say, provide this feature). Donate my code here:

USAGE

<controls:TextBlockTrimmer EllipsisPosition="Start">
    <TextBlock Text="Excuse me but can I be you for a while"
               TextTrimming="CharacterEllipsis" />
</controls:TextBlockTrimmer>

Don't forget to add a namespace declaration at your Page/Window/UserControl root:

xmlns:controls="clr-namespace:Hillinworks.Wpf.Controls"

TextBlockTrimmer.EllipsisPosition can be Start, Middle (mac style) or End. Pretty sure you can figure out which is which from their names.

CODE

TextBlockTrimmer.cs

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Markup;

namespace Hillinworks.Wpf.Controls
{
    enum EllipsisPosition
    {
        Start,
        Middle,
        End
    }

    [DefaultProperty("Content")]
    [ContentProperty("Content")]
    internal class TextBlockTrimmer : ContentControl
    {
        private class TextChangedEventScreener : IDisposable
        {
            private readonly TextBlockTrimmer _textBlockTrimmer;

            public TextChangedEventScreener(TextBlockTrimmer textBlockTrimmer)
            {
                _textBlockTrimmer = textBlockTrimmer;
                s_textPropertyDescriptor.RemoveValueChanged(textBlockTrimmer.Content,
                                                            textBlockTrimmer.TextBlock_TextChanged);
            }

            public void Dispose()
            {
                s_textPropertyDescriptor.AddValueChanged(_textBlockTrimmer.Content,
                                                         _textBlockTrimmer.TextBlock_TextChanged);
            }
        }

        private static readonly DependencyPropertyDescriptor s_textPropertyDescriptor =
            DependencyPropertyDescriptor.FromProperty(TextBlock.TextProperty, typeof(TextBlock));

        private const string ELLIPSIS = "...";

        private static readonly Size s_inifinitySize = new Size(double.PositiveInfinity, double.PositiveInfinity);

        public EllipsisPosition EllipsisPosition
        {
            get { return (EllipsisPosition)GetValue(EllipsisPositionProperty); }
            set { SetValue(EllipsisPositionProperty, value); }
        }

        public static readonly DependencyProperty EllipsisPositionProperty =
            DependencyProperty.Register("EllipsisPosition",
                                        typeof(EllipsisPosition),
                                        typeof(TextBlockTrimmer),
                                        new PropertyMetadata(EllipsisPosition.End,
                                                             TextBlockTrimmer.OnEllipsisPositionChanged));

        private static void OnEllipsisPositionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            ((TextBlockTrimmer)d).OnEllipsisPositionChanged((EllipsisPosition)e.OldValue,
                                                             (EllipsisPosition)e.NewValue);
        }

        private string _originalText;

        private Size _constraint;

        protected override void OnContentChanged(object oldContent, object newContent)
        {
            var oldTextBlock = oldContent as TextBlock;
            if (oldTextBlock != null)
            {
                s_textPropertyDescriptor.RemoveValueChanged(oldTextBlock, TextBlock_TextChanged);
            }

            if (newContent != null && !(newContent is TextBlock))
                // ReSharper disable once LocalizableElement
                throw new ArgumentException("TextBlockTrimmer access only TextBlock content", nameof(newContent));

            var newTextBlock = (TextBlock)newContent;
            if (newTextBlock != null)
            {
                s_textPropertyDescriptor.AddValueChanged(newTextBlock, TextBlock_TextChanged);
                _originalText = newTextBlock.Text;
            }
            else
                _originalText = null;

            base.OnContentChanged(oldContent, newContent);
        }


        private void TextBlock_TextChanged(object sender, EventArgs e)
        {
            _originalText = ((TextBlock)sender).Text;
            this.TrimText();
        }

        protected override Size MeasureOverride(Size constraint)
        {
            _constraint = constraint;
            return base.MeasureOverride(constraint);
        }

        protected override Size ArrangeOverride(Size arrangeBounds)
        {
            var result = base.ArrangeOverride(arrangeBounds);
            this.TrimText();
            return result;
        }

        private void OnEllipsisPositionChanged(EllipsisPosition oldValue, EllipsisPosition newValue)
        {
            this.TrimText();
        }

        private IDisposable BlockTextChangedEvent()
        {
            return new TextChangedEventScreener(this);
        }


        private static double MeasureString(TextBlock textBlock, string text)
        {
            textBlock.Text = text;
            textBlock.Measure(s_inifinitySize);
            return textBlock.DesiredSize.Width;
        }

        private void TrimText()
        {
            var textBlock = (TextBlock)this.Content;
            if (textBlock == null)
                return;

            if (DesignerProperties.GetIsInDesignMode(textBlock))
                return;


            var freeSize = _constraint.Width
                           - this.Padding.Left
                           - this.Padding.Right
                           - textBlock.Margin.Left
                           - textBlock.Margin.Right;

            // ReSharper disable once CompareOfFloatsByEqualityOperator
            if (freeSize <= 0)
                return;

            using (this.BlockTextChangedEvent())
            {
                // this actually sets textBlock's text back to its original value
                var desiredSize = TextBlockTrimmer.MeasureString(textBlock, _originalText);


                if (desiredSize <= freeSize)
                    return;

                var ellipsisSize = TextBlockTrimmer.MeasureString(textBlock, ELLIPSIS);
                freeSize -= ellipsisSize;
                var epsilon = ellipsisSize / 3;

                if (freeSize < epsilon)
                {
                    textBlock.Text = _originalText;
                    return;
                }

                var segments = new List<string>();

                var builder = new StringBuilder();

                switch (this.EllipsisPosition)
                {
                    case EllipsisPosition.End:
                        TextBlockTrimmer.TrimText(textBlock, _originalText, freeSize, segments, epsilon, false);
                        foreach (var segment in segments)
                            builder.Append(segment);
                        builder.Append(ELLIPSIS);
                        break;

                    case EllipsisPosition.Start:
                        TextBlockTrimmer.TrimText(textBlock, _originalText, freeSize, segments, epsilon, true);
                        builder.Append(ELLIPSIS);
                        foreach (var segment in ((IEnumerable<string>)segments).Reverse())
                            builder.Append(segment);
                        break;

                    case EllipsisPosition.Middle:
                        var textLength = _originalText.Length / 2;
                        var firstHalf = _originalText.Substring(0, textLength);
                        var secondHalf = _originalText.Substring(textLength);

                        freeSize /= 2;

                        TextBlockTrimmer.TrimText(textBlock, firstHalf, freeSize, segments, epsilon, false);
                        foreach (var segment in segments)
                            builder.Append(segment);
                        builder.Append(ELLIPSIS);

                        segments.Clear();

                        TextBlockTrimmer.TrimText(textBlock, secondHalf, freeSize, segments, epsilon, true);
                        foreach (var segment in ((IEnumerable<string>)segments).Reverse())
                            builder.Append(segment);
                        break;
                    default:
                        throw new NotSupportedException();
                }

                textBlock.Text = builder.ToString();
            }
        }


        private static void TrimText(TextBlock textBlock,
                                     string text,
                                     double size,
                                     ICollection<string> segments,
                                     double epsilon,
                                     bool reversed)
        {
            while (true)
            {
                if (text.Length == 1)
                {
                    var textSize = TextBlockTrimmer.MeasureString(textBlock, text);
                    if (textSize <= size)
                        segments.Add(text);

                    return;
                }

                var halfLength = Math.Max(1, text.Length / 2);
                var firstHalf = reversed ? text.Substring(halfLength) : text.Substring(0, halfLength);
                var remainingSize = size - TextBlockTrimmer.MeasureString(textBlock, firstHalf);
                if (remainingSize < 0)
                {
                    // only one character and it's still too large for the room, skip it
                    if (firstHalf.Length == 1)
                        return;

                    text = firstHalf;
                    continue;
                }

                segments.Add(firstHalf);

                if (remainingSize > epsilon)
                {
                    var secondHalf = reversed ? text.Substring(0, halfLength) : text.Substring(halfLength);
                    text = secondHalf;
                    size = remainingSize;
                    continue;
                }

                break;
            }
        }
    }
}
Dust answered 22/4, 2016 at 8:52 Comment(3)
I don't think this works if your TextBlock has it's Text property bound to a VM. Whenever the binding updates, the TextTrimmer doesn't get the changed value.Fedak
@JasonStevenson bcunning's answer provides a solution to this issueIlan
Insanely useful. Combine with other comments and you have a working solution! Tnx!Manganese
U
7

I implemented (copied) the above TextBlockTrimmer code and it worked great for loading but the TextBlock.Text would not update afterwards, if bound to a View Model property that changed. What I found that worked was to

  1. Define a DependencyProperty called TextBlockText in TextBlockTrimmer, similar to the EllipsisPosition property above, including an OnTextBlockTextChanged() method.
  2. In the OnTextBlockTextChanged() method, set _originalText to newValue before calling TrimText().
  3. Bind the TextBlockText property to the View Model property (called SomeText in the XAML below)
  4. Bind the TextBlock.Text property to the TextBlockTrimmer.TextBlockText property in the XAML:

    <controls:TextBlockTrimmer EllipsisPosition="Middle" TextBlockText="{Binding SomeText, Mode=OneWay}"
        <TextBlock Text="{Binding TextBlockText, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type controls:TextBlockTrimmer}}}" HorizontalAlignment="Stretch"/>
    </controls:TextBlockTrimmer>
    

It also worked if I bound both TextBlockTrimmer.TextBlockText and TextBlock.Text to SomeText (but doing so bugs me).

Upas answered 27/9, 2017 at 23:40 Comment(0)
P
4

Unfortunately, this is not possible in WPF today, as you can see from the documentation.

(I used to work at Microsoft on WPF, this was a feature we unfortunately did not get around to doing -- not sure if it's planned for a future version)

Polyclitus answered 5/3, 2009 at 13:46 Comment(0)
D
3

Here is an example how to do an efficient text clipping with a recursive logarithmic algorithm:

private static string ClipTextToWidth(
    TextBlock reference, string text, double maxWidth)
{
    var half = text.Substring(0, text.Length/2);

    if (half.Length > 0)
    {
        reference.Text = half;
        var actualWidth = reference.ActualWidth;

        if (actualWidth > maxWidth)
        {
            return ClipTextToWidth(reference, half, maxWidth);
        }

        return half + ClipTextToWidth(
            reference,
            text.Substring(half.Length, text.Length - half.Length),
            maxWidth - actualWidth);
    }
    return string.Empty;
}

Suppose you have a TextBlock field named textBlock, and you want to clip the text in it at a given maximal width, with the ellipsis appended. The following method calls ClipTextToWidth to set the text for the textBlock field:

public void UpdateTextBlock(string text, double maxWidth)
{
    if (text != null)
    {
        this.textBlock.Text = text;

        if (this.textBlock.ActualWidth > maxWidth)
        {
            this.textBlock.Text = "...";
            var ellipsisWidth = this.textBlock.ActualWidth;

            this.textBlock.Text = "..." + ClipTextToWidth(
                this.textBlock, text, maxWidth - ellipsisWidth);
        }
    }
    else
    {
        this.textBlock.Text = string.Empty;
    }
}

Hope that helps!

Dabchick answered 18/2, 2010 at 16:6 Comment(1)
You can't rely on TextBlock.ActualWidth being accurate immediately after you set the Text. A layout pass needs to occur.Supremacist
P
3

Thanks for your help hillin and bcunning.
For completeness here is the code that has to be appended to hillin's code described by bcunning.

TextBlockTrimmer.cs

public string TextBlockText
{
  get => (string)GetValue(TextBlockTextProperty);
  set => SetValue(TextBlockTextProperty, value);
}

public static readonly DependencyProperty TextBlockTextProperty =
  DependencyProperty.Register("TextBlockText",
                              typeof(string),
                              typeof(TextBlockTrimmer),
                              new PropertyMetadata("", OnTextBlockTextChanged));

private static void OnTextBlockTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
  ((TextBlockTrimmer)d).OnTextBlockTextChanged((string)e.OldValue, (string)e.NewValue);
}

private void OnTextBlockTextChanged(string oldValue, string newValue)
{
  _originalText = newValue;
  this.TrimText();
}

I use it in a ComboBox and for me it worked this way.
XAML:

<ComboBox ItemsSource="{Binding MyPaths}" SelectedItem="{Binding SelectedPath}" ToolTip="{Binding SelectedPath}">
  <ComboBox.ItemTemplate>
    <DataTemplate>
      <controls:TextBlockTrimmer EllipsisPosition="Start" TextBlockText="{Binding Mode=OneWay}">
        <TextBlock Text="{Binding}" ToolTip="{Binding}"/>
      </controls:TextBlockTrimmer>
    </DataTemplate>
  </ComboBox.ItemTemplate>
</ComboBox>
Prosimian answered 5/1, 2021 at 13:49 Comment(0)
H
2

You could try to use a ValueConverter (cf. IValueConverter interface) to change the strings that should be displayed in the list box yourself. That is, in the implementation of the Convert method, you would test if the strings are longer than the available space, and then change them to ... plus the right side of the string.

Haematosis answered 9/3, 2009 at 9:20 Comment(4)
Yeah that's what I did in the end and it works like a charm ;) Thanks!Reformation
I understand how a ValueConverter provides a point at which you can intercept things, but where are you getting the available space from, and what means are you using to judge whether the string/substrings fit within that space?Canaan
Answer the above question please!! We are all aware of the existence of IValueConverter interface! Your answer is like saying, "In order to do that, write the code that does it!" We KNOW we should write some code in an IVlaueConverter. What is it? How? When? Who? Where?!Cicada
@SimonLevy: Could you please post your solution here?Haematosis
K
1

You can achieve that using a IMultiValueConverter to trim the text yourself.

In the convert method, you test the string length and trim it if it is longer than the TextBlock.ActualWidth.

Here is the implementation I used :

public class StartTrimmingConverter :IMultiValueConverter
{
  public object Convert(object[] values, Type targetType, object parameter,
      CultureInfo culture)
  {
    if (values.Length != 2 || !(values[1] is TextBlock))
      return string.Empty;

    TextBlock reference = values[1] as TextBlock;
    return GetTrimmedText(reference, values[0].ToString());
  }

  public object[] ConvertBack(object value, Type[] targetTypes, object parameter,
      CultureInfo culture) => throw new NotImplementedException();

  private static string GetTrimmedText(TextBlock reference, string text)
  {
    if (text != null)
    {
      double maxWidth = reference.ActualWidth - 
              reference.Padding.Left - reference.Padding.Right;

      if (MeasureString(reference, text).Width > maxWidth)
      {
        double ellipsisWidth = MeasureString(reference, "...").Width;

        return "..." + ClipTextToWidth(reference, text,
            maxWidth - ellipsisWidth);
      }
      else
        return text;
    }
    else
      return string.Empty;
  }

  private static string ClipTextToWidth(TextBlock reference, string text,
      double maxWidth)
  {
    int start = (int)Math.Ceiling(text.Length / 2.0f);
    string half = text.Substring(start, text.Length / 2);

    if (half.Length > 0)
    {
      double actualWidth = MeasureString(reference, half).Width;

      if (MeasureString(reference, half).Width > maxWidth)
      {
        return ClipTextToWidth(reference, half, maxWidth);
      }

      return ClipTextToWidth(reference, text.Substring(0, start),
        maxWidth - actualWidth) + half;
    }
    return string.Empty;
  }

  private static Size MeasureString(TextBlock reference, string candidate)
  {
    FormattedText formattedText = new FormattedText(
        candidate,
        CultureInfo.CurrentCulture,
        FlowDirection.LeftToRight,
        new Typeface(reference.FontFamily, reference.FontStyle,
                     reference.FontWeight, reference.FontStretch),
        reference.FontSize,
        Brushes.Black,
        new NumberSubstitution(),
        1);

    return new Size(formattedText.Width, formattedText.Height);
  }
}

And for XAML usage :

<Resources>
    <my:StartTrimmingConverter x:Key="trimConv" />
</Resources>
...
<TextBlock>
    <TextBlock.Text>
        <MultiBinding Converter="{StaticResource trimConv}">
            <Binding Path="PropertyName"/>
            <Binding RelativeSource="{RelativeSource Self}"/>
        </MultiBinding>
    </TextBlock.Text>
</TextBlock>

(Thanks to Daniel's answer for the recursive logarithmic algorithm for text clipping)

Kath answered 26/8, 2021 at 7:26 Comment(0)
C
-5

In case someone else stumbles on this question as I did, here's another thread with a much better answer (not taking credit):

Auto clip and append dots in WPF label

Chrystal answered 7/2, 2011 at 6:2 Comment(1)
@simon wants the ellipsis at the start of the text. He already pointed out that he knows about TextTrimming="CharacterEllipsis".Mingy

© 2022 - 2024 — McMap. All rights reserved.