Simon Levy
Simon Levy

Reputation: 165

Ellipsis at start of string in WPF ListView

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?

Upvotes: 13

Views: 9796

Answers (8)

Joel W.
Joel W.

Reputation: 11

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)

Upvotes: 1

ElVit
ElVit

Reputation: 79

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>

Upvotes: 3

bcunning
bcunning

Reputation: 71

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

Upvotes: 7

hillin
hillin

Reputation: 1779

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;
            }
        }
    }
}

Upvotes: 11

Nick W.
Nick W.

Reputation: 1090

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

Upvotes: -5

Daniel Dinnyes
Daniel Dinnyes

Reputation: 5017

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!

Upvotes: 3

EFrank
EFrank

Reputation: 1900

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.

Upvotes: 2

Fortes
Fortes

Reputation: 1476

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)

Upvotes: 4

Related Questions