3

So, I'm having an interesting problem. I have a ListView and two columns:

<ListView x:Name="dataView">
    <ListView.View>
        <GridView>
            <GridViewColumn Header="R1" DisplayMemberBinding="{Binding Path=R1}"/>
            <GridViewColumn Header="R1 Icon">
                <GridViewColumn.CellTemplate>
                    <DataTemplate>
                        <Image Source="{Binding Path=R1Icon}"/>
                    </DataTemplate>
                </GridViewColumn.CellTemplate>
            </GridViewColumn>
        </GridView>
    </ListView.View>
</ListView>

No matter if I set first column's width to auto or leave it default as it is shown above, the initial width will be set to the widest item within the rendered window. So, if I have my window height set at 400 and the next element is wider than what's being rendered, it won't account for its width. Instead, it'll use the width of the widest rendered item. If I set my height to say... 410, it'll take the width of that next element into consideration. However, with several hundred items, I can't be using height for that purpose. Is there a way to set that column's width as the widest element in it, regardless if it's in the initial render?

Note that I don't want to use ScrollViewer.CanContentScroll="False" solution from a related SO question. That would have a huge performance consequence with a very large list.

B.K.
  • 9,982
  • 10
  • 73
  • 105
  • Have you tried SharedSizeScope like in [this answer](http://stackoverflow.com/a/4527489/3411327)? – user3411327 Mar 15 '14 at 08:20
  • @user3411327 Unfortunately that doesn't work for unrendered elements. Even that question is for visible objects only. – B.K. Mar 15 '14 at 14:52

4 Answers4

2

This answer is based on the discussion safetyOtter and I had earlier. There was a problem of not having a dynamic accounting for the text size based on its rendering per user's resolution. Another problem with his solution was that the event would fire off every time the size would change. Therefore, I restricted it to the initial loading event, which occurs prior to rendering. Here's what I came up with:

private void View_Loaded(object sender, RoutedEventArgs e)
{
    var listView = (sender as ListView);
    var gridView = (listView.View as GridView);

    // Standard safety check.
    if (listView == null || gridView == null)
    {
        return;
    }

    // Initialize a new typeface based on the currently used font.
    var typeFace = new Typeface(listView.FontFamily, listView.FontStyle, 
                                listView.FontWeight, listView.FontStretch);

    // This variable will hold the longest string from the source list.
    var longestString = dataList.OrderByDescending(s => s.Length).First();

    // Initialize a new FormattedText instance based on our longest string.
    var text = new System.Windows.Media.FormattedText(longestString, 
                       System.Globalization.CultureInfo.CurrentCulture,
                       System.Windows.FlowDirection.LeftToRight, typeFace,  
                       listView.FontSize, listView.Foreground);

    // Assign the width of the FormattedText to the column width.
    gridView.Columns[0].Width = text.Width;
}

There was a slight width error that cut off the last two characters of the string. I measured it to be 12 pixels. A buffer could be added to the column width of somewhere between 12-20 pixels (+ 12.0f) to account for that error. It appears it's pretty common and I'll need to do some more research.

Other methods that I've tried:

using (Graphics g = Graphics.FromHwnd(IntPtr.Zero))
{
    SizeF size = g.MeasureString(longestString, 
                     System.Drawing.SystemFonts.DefaultFont);
    gridView.Columns[0].Width = size.Width;
}

That one had approximately 14 pixel error on the measurement. The problem with that method and the one that I'll show below is that both of them rely on System.Drawing.SystemFonts.DefaultFont, due to the fact that errors are too great if the font is retrieved from the control. This reliance on the system font is very restrictive if the control is using something different.

Last method that I tried (provides too high of an error on measurement):

gridView.Columns[0].Width = System.Windows.Forms.TextRenderer.MeasureText(
                                longestString, 
                                System.Drawing.SystemFonts.DefaultFont).Width;

I'm pretty content with the first method, and I haven't been able to find anything that does a perfect text measurement. So, having just a few characters cut off and fixing it with a buffer zone is not that bad.

EDIT:

Here's another method that I found @ WPF equivalent to TextRenderer It provided a ~14 pixel error. So, the first method is the best performer thus far.

    private void View_Loaded(object sender, RoutedEventArgs e)
    {
        var listView = (sender as ListView);
        var gridView = (listView.View as GridView);

        if (listView == null || gridView == null)
        {
            return;
        }

        gridView.Columns[0].Width = MeasureText(dataList.OrderByDescending(
                                        s => s.Length).First(),
                                        listView.FontFamily, 
                                        listView.FontStyle, 
                                        listView.FontWeight, 
                                        listView.FontStretch, 
                                        listView.FontSize).Width;
    }

    public static System.Windows.Size MeasureTextSize(string text, 
                                          System.Windows.Media.FontFamily fontFamily, 
                                          System.Windows.FontStyle fontStyle, 
                                          FontWeight fontWeight, 
                                          FontStretch fontStretch, double fontSize)
    {
        FormattedText ft = new FormattedText(text,
                                             CultureInfo.CurrentCulture,
                                             FlowDirection.LeftToRight,
                                             new Typeface(fontFamily, fontStyle, 
                                                 fontWeight, fontStretch),
                                                 fontSize,
                                                 System.Windows.Media.Brushes.Black);
        return new System.Windows.Size(ft.Width, ft.Height);
    }

    public static System.Windows.Size MeasureText(string text, 
                                          System.Windows.Media.FontFamily fontFamily, 
                                          System.Windows.FontStyle fontStyle, 
                                          FontWeight fontWeight, 
                                          FontStretch fontStretch, double fontSize)
    {
        Typeface typeface = new Typeface(fontFamily, fontStyle, fontWeight,
                                         fontStretch);
        GlyphTypeface glyphTypeface;

        if (!typeface.TryGetGlyphTypeface(out glyphTypeface))
        {
            return MeasureTextSize(text, fontFamily, fontStyle, fontWeight, 
                                   fontStretch, fontSize);
        }

        double totalWidth = 0;
        double height = 0;

        for (int n = 0; n < text.Length; n++)
        {
            ushort glyphIndex = glyphTypeface.CharacterToGlyphMap[text[n]];

            double width = glyphTypeface.AdvanceWidths[glyphIndex] * fontSize;

            double glyphHeight = glyphTypeface.AdvanceHeights[glyphIndex] * fontSize;

            if (glyphHeight > height)
            {
                height = glyphHeight;
            }

            totalWidth += width;
        }

        return new System.Windows.Size(totalWidth, height);
    }
Community
  • 1
  • 1
B.K.
  • 9,982
  • 10
  • 73
  • 105
  • Very nice, I'm stealing this! – safetyOtter Mar 15 '14 at 07:11
  • @safetyOtter Heh, glad it can benefit someone besides me. :) – B.K. Mar 15 '14 at 07:12
  • hehe I've got a listview that bugs me every time I see it, this will make my screen crawl less. Thanks! – safetyOtter Mar 15 '14 at 07:14
  • @safetyOtter No problem, thank you for leading me in the right direction. – B.K. Mar 15 '14 at 07:14
  • 1
    I've found two problems with this approach that might account for the size error you describe: 1. This implementation assumes monospaced fonts. For variable-width fonts, the assumption "longest string = widest label" does not hold. 2. The FormattedText class has a WidthIncludingTrailingWhitespace property that you might want to use for calculating the grid width. – Nathan Strong Jul 13 '17 at 21:46
1

attach a handler to the listview's sizechanged event and set the width in there. I've done something like this in the past, you could probably modify it to suit your needs

    private void dataView_SizeChanged(object sender, SizeChangedEventArgs e)
    {
        ListView listView = sender as ListView;
        if (listView == null) return;
        GridView gView = listView.View as GridView;
        if (gView == null) return;
        gView.Columns[0].Width = 
               YourObjectHere.OrderByDescending(s => s.Length).First().Length * 11;
        gView.Columns[1].Width = 100; // width of your icon
    }
B.K.
  • 9,982
  • 10
  • 73
  • 105
safetyOtter
  • 1,430
  • 14
  • 26
  • What does `11` represent? – B.K. Mar 15 '14 at 05:27
  • The width in pixels of the W in the font i was using when i wrote that. It's a hacky way to estimate the width of a string in pixels based on the string length, it will always be a little too wide, but never too short. There may be a better way but I don't know it. – safetyOtter Mar 15 '14 at 05:30
  • 1
    Hmm... static width. By the way, I'm surprised that it compiled for you at all. `Length` is a property, not a method. – B.K. Mar 15 '14 at 05:31
  • oops, rewrote that from memory, will edit :P Hope you are able to find a better solution and post it! – safetyOtter Mar 15 '14 at 05:33
  • Well, I'm testing your code and it does affect the width; however, the numbers are a bit off. Modifier of `6` gives me a "close-enough" width, but I'm not fond of the static width modifier, due to the restriction it imposes. We're probably running at different resolutions, and thus it would vary for each of the users... I need something a bit more dynamic. – B.K. Mar 15 '14 at 05:36
  • Yah 11 was for the font i was using, i hope there is a better solution, this is just the way i stopped caring and moved on :P – safetyOtter Mar 15 '14 at 05:39
  • @Noobacode I remembered that I saw a real solution that i couldn't use for performance reasons here on stack a while ago. [Link](http://stackoverflow.com/questions/6307289/wpf-gridviewcolumn-width-auto-only-works-for-items-in-the-current-scroll-scope) Which i why i switched to the hacky estimation route. – safetyOtter Mar 15 '14 at 05:45
  • I don't think it really depends on the font size; I think it has to do more with the resolution, because it's pixel-based. If you substitute that `11` with `listView.Fontsize`, then you'll be using the actual font size (causes width to be too large for me, though). – B.K. Mar 15 '14 at 05:58
  • That link you gave me actually does the job. But you're right, on large scale and low performance machines, this could cause a problem. I'll try to figure out a solution using your method... there must be one. – B.K. Mar 15 '14 at 05:59
  • I didn't pick `11` to represent the font size, but the width in pixels of the widest letter of the font, I took a screenshot and dropped it into Photoshop. Anyhow, if you find anything better please post it here and I'll start using it! Thanks – safetyOtter Mar 15 '14 at 06:01
1

The answer given by @B.K. got me 90% of the way there (I had to adapt it to work with a ListBox control). However, as I commented on his solution, it doesn't take variable width fonts into account.

Below is a method that, given a collection of strings, will find which one is widest when rendered by a given TypeFace at a given point size.

private FormattedText getLongestFormattedString(IEnumerable<string> list, Typeface typeface, double size)
{
    FormattedText longest = null;

    foreach(string item in list)
    {
        var renderedText = new FormattedText(filter.Filter.Name,
                               System.Globalization.CultureInfo.CurrentCulture,
                               FlowDirection.LeftToRight, typeface,
                               size, Brushes.Black);
        longest = (longest == null || renderedText.WidthIncludingTrailingWhitespace > longest.WidthIncludingTrailingWhitespace) ? renderedText : longest;
    }

    return longest;
}

Inside the Loaded handler, you might use it as such:

void listView_Loaded(object sender, RoutedEventArgs e)
{
    var listView = sender as System.Windows.Controls.ListView;
    if(listView == null) return;

    var gridView = listView.View as GridView;
    if(gridView == null) return;

    // this assumes the items are coming from data binding.
    // generating an iteration of strings from your actual data source
    // is left as an exercise to the reader
    var dataList = listView.Items.SourceCollection as List<string>;
    if(dataList == null) return;

    var typeFace = new Typeface(listView.FontFamily, listView.FontStyle,
                                    listView.FontWeight, listView.FontStretch);
    var text = getLongestFormattedString(dataList, typeFace, listView.FontSize);

    gridView.Columns[0].Width = text.WidthIncludingTrailingWhitespace;
}
Nathan Strong
  • 2,360
  • 13
  • 17
1

I know this is an old post, but for anyone coming across it who is binding to the ListView ItemsSource property from their ViewModel, perhaps a MultiValue converter can help. I know there are other ways of solving the issue, but I like the level of control I can maintain using this method and it's equally applicable to a ListView and GridViewColumn.

I also like the fact that, using the converter, my ICollection object can either contain strings/primitive types or class objects whose properties can be accessed by reflection.

In brief: the array of values passed in to the converter are those required to create a typeface and to instantiate a FormattedText object, min and max widths of the UIElement, and the bound ICollection object itself. The parameter argument is actually an array containing the property name of a class object (or null if none is required) and a Thickness object to add padding to the calculated width. The Thickness object allows me to cater for any padding/margin designs that form part of my ListView.

Note: the default padding on a ListView ItemsPane is {12,0,12,0} and on a GridViewColumn {6,0,6,0}. This might account for the 12-20 pixel error mentioned by BK.

The converter itself looks like this:

/// <summary>
/// Iterates a collection of items to calculate the maximum text width of those items.
/// Items can either be primitive types and strings or objects with a property that is
/// a primitive type or string.
/// </summary>
public sealed class ItemsToWidthConverter : IMultiValueConverter
{
    //Constants for array indexes.
    private const int FONTFAMILY_ID = 0;
    private const int FONTSTYLE_ID = 1;
    private const int FONTWEIGHT_ID = 2;
    private const int FONTSTRETCH_ID = 3;
    private const int FONTSIZE_ID = 4;
    private const int FOREGROUND_ID = 5;
    private const int MINWIDTH_ID = 6;
    private const int MAXWIDTH_ID = 7;
    private const int ICOLLECTION_ID = 8;
    private const int PARAMETERPROPERTY_ID = 0;
    private const int PARAMETERGAP_ID = 1;

    /// <summary>
    /// Converts collection items to a width.
    /// Parameter[0] is the property name of an object. If no property name is needed, pass in null.
    /// Parameter[1] is the padding to be added to the calculated width. If no padding is needed, pass in a Thickness of 0.
    /// Note: a ListViewItem has default padding of {12,0,12,0}. A GridViewColumn has default padding of {6,0,6,0}.
    /// </summary>
    /// <param name="values">Array of 9 objects {FontFamily, FontStyle, FontWeight, FontStretch, double [FontSize], Brush, double [MinWidth], double [MaxWidth], ICollection}</param>
    /// <param name="targetType">Double</param>
    /// <param name="parameter">Array of 2 objects {string [Property Name], Thickness}</param>
    /// <param name="culture">Desired CultureInfo</param>
    /// <returns>Width of widest item including padding or Nan if none is calculated.</returns>
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        // Throw error if passed parameters are incorrect.
        if (values.Length != 9) throw new Exception("Incorrect number of items passed in 'values'.");
        if (!(parameter.GetType().IsArray)) throw new Exception("'Parameter' must be an array.");
        var prm = (object[])parameter;
        if (prm.Length !=2) throw new Exception("Incorrect number of items passed in 'parameter'.");
        if (prm[PARAMETERPROPERTY_ID] != null && !(prm[PARAMETERPROPERTY_ID] is string property)) throw new Exception("'Parameter['" + PARAMETERPROPERTY_ID + "]' is neither null nor of type 'string'.");
        if (!(prm[PARAMETERGAP_ID] is Thickness margin)) throw new Exception("'Parameter['" + PARAMETERGAP_ID + "]' is not of type 'Thickness'.");
        if (values[ICOLLECTION_ID] == null) return double.NaN;
        if (!(values[FONTFAMILY_ID] is FontFamily family)) throw new Exception("'Value['" + FONTFAMILY_ID + "]' is not of type 'FontFamily'.");
        if (!(values[FONTSTYLE_ID] is FontStyle style)) throw new Exception("'Value['" + FONTSTYLE_ID + "]' is not of type 'FontStyle'.");
        if (!(values[FONTWEIGHT_ID] is FontWeight weight)) throw new Exception("'Value['" + FONTWEIGHT_ID + "]' is not of type 'FontWeight'.");
        if (!(values[FONTSTRETCH_ID] is FontStretch stretch)) throw new Exception("'Value['" + FONTSTRETCH_ID + "]' is not of type 'FontStretch'.");
        if (!(values[FONTSIZE_ID] is double size)) throw new Exception("'Value['" + FONTSIZE_ID + "]' is not of type 'double'.");
        if (!(values[FOREGROUND_ID] is Brush foreground)) throw new Exception("'Value['" + FOREGROUND_ID + "]' is not of type 'Brush'.");
        if (!(values[MINWIDTH_ID] is double minWidth)) throw new Exception("'Value['" + MINWIDTH_ID + "]' is not of type 'double'.");
        if (!(values[MAXWIDTH_ID] is double maxWidth)) throw new Exception("'Value['" + MAXWIDTH_ID + "]' is not of type 'double'.");
        if (!(values[ICOLLECTION_ID] is ICollection col)) throw new Exception("'Value['" + ICOLLECTION_ID + "]' is not of type 'ICollection'.");

        // Conver font properties to a typeface.
        var typeFace = new Typeface(family, style, weight, stretch);

        // Initialise the max_width variable at 0.
        var widest = 0.0;
        foreach (var item in col)
        {
            // If property parameter is null, assume the ICollection contains primitives or strings.
            if (prm[PARAMETERPROPERTY_ID] == null)
            {
                if (item.GetType().IsPrimitive || item is string)
                {
                    var text = new FormattedText(item.ToString(),
                                                 culture,
                                                 FlowDirection.LeftToRight,
                                                 typeFace,
                                                 size,
                                                 foreground,
                                                 null,
                                                 TextFormattingMode.Ideal);

                    if (text.WidthIncludingTrailingWhitespace > widest)
                        widest = text.WidthIncludingTrailingWhitespace;
                }
            }
            else
            // Property parameter contains a string, so assume ICollection is an object
            // and use reflection to get property value.
            {
                if (item.GetType().GetProperty(prm[PARAMETERPROPERTY_ID].ToString()) != null)
                {
                    var propertyValue = item.GetType().GetProperty(prm[PARAMETERPROPERTY_ID].ToString()).GetValue(item);
                    if (propertyValue.GetType().IsPrimitive || propertyValue is string)
                    {
                        var text = new FormattedText(propertyValue.ToString(),
                                                     culture,
                                                     FlowDirection.LeftToRight,
                                                     typeFace,
                                                     size,
                                                     foreground,
                                                     null,
                                                     TextFormattingMode.Display);

                        if (text.WidthIncludingTrailingWhitespace > widest)
                            widest = text.WidthIncludingTrailingWhitespace;
                    }
                }
            }
        }

        // If no width could be calculated, return Nan which sets the width to 'Automatic'
        if (widest == 0) return double.NaN;

        // Add the left and right thickness values to the calculated width and
        // check result is within min and max values.
        {
            widest += ((Thickness)prm[PARAMETERGAP_ID]).Left + ((Thickness)prm[PARAMETERGAP_ID]).Right;
            if (widest < minWidth || widest > maxWidth) return double.NaN;
            return widest;
        }
    }

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

And it can be implemented in XAML as below.

Eg 1 - a simple ListView within a template:

<ListView ItemsSource="{Binding MyStrings}">
    <ListView.Width>
        <MultiBinding Converter="{StaticResource ItemsToWidthConverter}">
            <MultiBinding.ConverterParameter>
                <x:Array Type="sys:Object">
                    <x:Null />
                    <Thickness>12,0,12,0</Thickness>
                </x:Array>
            </MultiBinding.ConverterParameter>
            <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="FontFamily" />
            <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="FontStyle" />
            <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="FontWeight" />
            <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="FontStretch" />
            <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="FontSize" />
            <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="Foreground" />
            <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="MinWidth" />
            <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="MaxWidth" />
            <Binding Path="MyStrings" />
        </MultiBinding>
    </ListView.Width>
</ListView>

Eg 2 - ListView with GridView

<ListView ItemsSource="{Binding Employees}">
    <ListView.View>
        <GridView>
            <GridViewColumn DisplayMemberBinding="{Binding EmployeeName}">
                <GridViewColumn.Width>
                    <MultiBinding Converter="{StaticResource ItemsToWidthConverter}">
                        <MultiBinding.ConverterParameter>
                            <x:Array Type="sys:Object">
                                <sys:String>EmployeeName</sys:String>
                                <Thickness>6,0,6,0</Thickness>
                            </x:Array>
                        </MultiBinding.ConverterParameter>
                        <Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=ListView}" Path="FontFamily" />
                        <Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=ListView}" Path="FontStyle" />
                        <Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=ListView}" Path="FontWeight" />
                        <Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=ListView}" Path="FontStretch" />
                        <Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=ListView}" Path="FontSize" />
                        <Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=ListView}" Path="Foreground" />
                        <Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=ListView}" Path="MinWidth" />
                        <Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=ListView}" Path="MaxWidth" />
                        <Binding Path="Employees" />
                    </MultiBinding>
                </GridViewColumn.Width>
            </GridViewColumn>
        </GridView>
    </ListView.View>
</ListView>

There are plenty of other binding variations/combinations, of course.

The only issue with this approach is that the MultiBinding isn't notified if, say, an ObservableCollection is changed (for example by adding an item), so additional notification code might be needed. I haven't encountered this issue as I mostly use this technique for conditional lists whereby the entire collection is replaced (and therefore OnPropertyChanged is fired, which is consumed by the MultiBinding), but SO gives examples of how to code such notification.

Ambie
  • 4,872
  • 2
  • 12
  • 26