Reputation: 165
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
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
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
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
TextBlockText
in TextBlockTrimmer
, similar to the EllipsisPosition
property above, including an OnTextBlockTextChanged()
method.OnTextBlockTextChanged()
method, set _originalText
to newValue
before calling TrimText()
.TextBlockText
property to the View Model property (called SomeText
in the XAML below)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
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:
<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.
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
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
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
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
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