The blog of dlaa.me

Using your own code is its own kind of encouragement [How to: Automatically update the widths of ListView columns - updated!]

Some time ago, I blogged about a helper class to automatically adjust the size of the columns of a ListView's GridView to fit their contents. In my scenario, I was changing the value of the ListView's ItemsSource property and wanted the columns to update each time to accommodate the new data. The solution I described and posted at that time has been working fine for me ever since. However, a new project has just revealed a shortcoming: if the collection is one that implements INotifyCollectionChanged (such as ObservableCollection<T>), its contents will change and provide notice they've done so, but the column widths won't - because the original implementation only cared about changes to the ItemsSource property itself...

I've updated sample application I wrote for the previous post to include a new "Add Detail" button that dynamically adds an item to the current collection. As you can see from the image below, the second ListView (which doesn't take advantage of the new code) hasn't expanded the widths of its two columns in response to the new item. However, the bottom ListView which uses the IsAutoUpdatingColumnWidths attached DependencyProperty has automatically detected the new item and updated itself accordingly:

ListViewColumnWidthAutoUpdate sample

[Click here to download the ListViewColumnWidthAutoUpdate sample application.]

 

The new code seemed like it should be easy enough, then turned out to be a bit more involved than I expected. But the important point is that if you're already using IsAutoUpdatingColumnWidths, then you don't need to change your code at all! Just drop in the new implementation of my ListViewBehaviors class, and you're set!

 

Notes:

  • At the end of the day, this update is really just about detecting that the ItemsSource collection implements INotifyCollectionChanged and listening to its CollectionChanged event. The complications arise because IsAutoUpdatingColumnWidths is an attached DependencyProperty and therefore static - so there's no convenient place to associate instance-specific state. In particular, the necessary state is a list of collection-owning ListViews for reverse-mapping and a reference to the previous collection instance because the handler that DependencyPropertyDescriptor.AddValueChanged calls when a value changes only gets the new value (unlike the PropertyChangedCallback that's typically associated with DependencyPropertys).
  • So I introduced a simple ListViewState helper class and some code to maintain a list of known ListView instances an their associated INotifyCollectionChanged collections.
  • But we don't want to create "false" references to user objects and introduce leaks, so both of ListViewState's properties are backed by a WeakReference to allow them to be garbage collected when possible.
  • At this point, you might wonder if it's necessary to employ the WeakEvent pattern (which I've previously written about here)... Well, for now I've managed to convince myself that it is not - because unlike the scenario I originally wrote about, in this case the "backwards references" are all to a single static method. The way I'm thinking about it right now, that method can't really "leak" - or if it can, its size and scope are small enough that they're not worth worrying about. That said, I'm open to being proven wrong, so please don't be shy about correcting me if you know better! :)
  • The only other detail worth calling out is that it's necessary to hook and unhook the INotifyCollectionChanged event in two circumstances: when the ItemsSource property changes and when the IsAutoUpdatingColumnWidths property changes.

 

And that's all there is to it! Here's the complete, updated implementation of ListViewBehaviors:

/// <summary>
/// Class implementing useful behaviors for the ListView control.
/// </summary>
public static class ListViewBehaviors
{
    /// <summary>
    /// Updates the column widths of a GridView to fit the contents.
    /// </summary>
    /// <param name="gridView">GridView to update.</param>
    public static void UpdateColumnWidths(GridView gridView)
    {
        // Validate parameter
        Debug.Assert(null != gridView,
            "UpdateColumnWidths requires a non-null gridView parameter.");

        // For each column...
        foreach (var column in gridView.Columns)
        {
            // If this is an "auto width" column...
            if (double.IsNaN(column.Width))
            {
                // Set its Width back to NaN so it will auto-size
                column.Width = 0;
                column.Width = double.NaN;
            }
        }
    }

    /// <summary>
    /// Represents the IsAutoUpdatingColumnWidths attached DependencyProperty.
    /// </summary>
    public static readonly DependencyProperty IsAutoUpdatingColumnWidthsProperty =
        DependencyProperty.RegisterAttached(
            "IsAutoUpdatingColumnWidths",
            typeof(bool),
            typeof(ListViewBehaviors),
            new UIPropertyMetadata(false, OnIsAutoUpdatingColumnWidthsChanged));

    /// <summary>
    /// Gets the value of the IsAutoUpdatingColumnWidths property.
    /// </summary>
    /// <param name="listView">ListView for which to get the value.</param>
    /// <returns>Value of the property.</returns>
    [SuppressMessage("Microsoft.Design", "CA1011:ConsiderPassingBaseTypesAsParameters",
        Justification = "Only applies to ListView instances.")]
    public static bool GetIsAutoUpdatingColumnWidths(ListView listView)
    {
        return (bool)listView.GetValue(IsAutoUpdatingColumnWidthsProperty);
    }

    /// <summary>
    /// Sets the value of the IsAutoUpdatingColumnWidths property.
    /// </summary>
    /// <param name="listView">ListView for which to set the value.</param>
    /// <param name="value">Value of the property.</param>
    [SuppressMessage("Microsoft.Design", "CA1011:ConsiderPassingBaseTypesAsParameters",
        Justification = "Only applies to ListView instances.")]
    public static void SetIsAutoUpdatingColumnWidths(ListView listView, bool value)
    {
        listView.SetValue(IsAutoUpdatingColumnWidthsProperty, value);
    }

    /// <summary>
    /// Change handler for the IsAutoUpdatingColumnWidths property.
    /// </summary>
    /// <param name="o">Instance for which it changed.</param>
    /// <param name="e">Change details.</param>
    private static void OnIsAutoUpdatingColumnWidthsChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
    {
        // Get the ListView instance and new bool value
        var listView = o as ListView;
        Debug.Assert(!VirtualizingStackPanel.GetIsVirtualizing(listView),
            "VirtualizingStackPanel.IsVirtualizing should be False for " +
            "ListViewBehaviors.IsAutoUpdatingColumnWidths to work best.");
        if ((null != listView) && (e.NewValue is bool))
        {
            // Get a descriptor for the ListView's ItemsSource property
            var descriptor = DependencyPropertyDescriptor.FromProperty(ListView.ItemsSourceProperty, typeof(ListView));
            if ((bool)e.NewValue)
            {
                // Enabling the feature, so add the change handler
                descriptor.AddValueChanged(listView, ListViewItemsSourceValueChanged);

                // Create a ListViewState instance for tracking
                var listViewState = new ListViewState(listView);

                // Hook the CollectionChanged event (if possible)
                var iNotifyCollectionChanged = listView.ItemsSource as INotifyCollectionChanged;
                if (null != iNotifyCollectionChanged)
                {
                    iNotifyCollectionChanged.CollectionChanged +=
                        new NotifyCollectionChangedEventHandler(ListViewItemsSourceCollectionChanged);
                    listViewState.INotifyCollectionChanged = iNotifyCollectionChanged;
                }

                // Track the instance
                _listViewStates.Add(listViewState);
            }
            else
            {
                // Disabling the feature, so remove the change handler
                descriptor.RemoveValueChanged(listView, ListViewItemsSourceValueChanged);

                // Look up the corresponding ListViewState instance
                var listViewState = _listViewStates
                    .Where(lvs => listView == lvs.ListView)
                    .FirstOrDefault();
                if (null != listViewState)
                {
                    // Unhook the CollectionChanged event (if present)
                    var iNotifyCollectionChanged = listViewState.INotifyCollectionChanged;
                    if (null != iNotifyCollectionChanged)
                    {
                        iNotifyCollectionChanged.CollectionChanged -=
                            new NotifyCollectionChangedEventHandler(ListViewItemsSourceCollectionChanged);
                        listViewState.INotifyCollectionChanged = null;
                    }
                }

                // Remove the ListViewState (and any other unnecessary ones)
                foreach (var lvs in _listViewStates
                    .Where(lvs => (listView == lvs.ListView) || (null == lvs.ListView))
                    .ToArray()) // ToArray avoids modifying the current collection
                {
                    _listViewStates.Remove(lvs);
                }
            }
        }
    }

    /// <summary>
    /// Handles changes to the ListView's ItemsSource and updates the column widths.
    /// </summary>
    /// <param name="sender">Event source.</param>
    /// <param name="e">Event args.</param>
    private static void ListViewItemsSourceValueChanged(object sender, EventArgs e)
    {
        // Get a reference to the ListView
        var listView = sender as ListView;
        if (null != listView)
        {
            // Look up the corresponding ListViewState instance
            var listViewState = _listViewStates
                .Where(lvs => listView == lvs.ListView)
                .FirstOrDefault();
            if (null != listViewState)
            {
                // Unhook the CollectionChanged event (if present)
                var oldINotifyCollectionChanged = listViewState.INotifyCollectionChanged;
                if (null != oldINotifyCollectionChanged)
                {
                    oldINotifyCollectionChanged.CollectionChanged -=
                        new NotifyCollectionChangedEventHandler(ListViewItemsSourceCollectionChanged);
                    listViewState.INotifyCollectionChanged = null;
                }

                // Hook the new CollectionChanged event (if possible)
                var newINotifyCollectionChanged = listView.ItemsSource as INotifyCollectionChanged;
                if (null != newINotifyCollectionChanged)
                {
                    newINotifyCollectionChanged.CollectionChanged +=
                        new NotifyCollectionChangedEventHandler(ListViewItemsSourceCollectionChanged);
                    listViewState.INotifyCollectionChanged = newINotifyCollectionChanged;
                }

                // Get a reference to the ListView's GridView
                var gridView = listView.View as GridView;
                if (null != gridView)
                {
                    // Update the ListView's column widths
                    UpdateColumnWidths(gridView);
                }
            }
        }
    }

    /// <summary>
    /// Handles changes to the ListView's ItemsSource's CollectionChanged event and updates the column widths.
    /// </summary>
    /// <param name="sender">Event source.</param>
    /// <param name="e">Event args.</param>
    private static void ListViewItemsSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        // Update the corresponding GridView by looking up the ListView by ItemsSource
        foreach (var gridView in _listViewStates
                .Select(lvs => lvs.ListView)
                .Where(lv => (null != lv) && (sender == lv.ItemsSource) && (lv.View is GridView))
                .Select(lv => (GridView)(lv.View)))
        {
            // Update the ListView's column widths
            UpdateColumnWidths(gridView);
        }
    }

    /// <summary>
    /// Stores a collection of ListViewState instances.
    /// </summary>
    private static List<ListViewState> _listViewStates = new List<ListViewState>();

    /// <summary>
    /// Stores state information about a ListView that allows ListViewItemsSourceCollectionChanged
    /// to map from a collection to the corresponding ListView owner.
    /// </summary>
    private class ListViewState
    {
        /// <summary>
        /// Weakly references the ListView.
        /// </summary>
        private WeakReference _listViewReference = new WeakReference(null);

        /// <summary>
        /// Gets or sets the ListView.
        /// </summary>
        public ListView ListView
        {
            get { return (ListView)(_listViewReference.Target); }
            private set { _listViewReference.Target = value; }
        }

        /// <summary>
        /// Weakly references the INotifyCollectionChanged.
        /// </summary>
        private WeakReference _iNotifyCollectionChangedReference = new WeakReference(null);

        /// <summary>
        /// Gets or sets the INotifyCollectionChanged.
        /// </summary>
        public INotifyCollectionChanged INotifyCollectionChanged
        {
            get { return (INotifyCollectionChanged)(_iNotifyCollectionChangedReference.Target); }
            set { _iNotifyCollectionChangedReference.Target = value; }
        }

        /// <summary>
        /// Creates a new instance of the ListViewState class.
        /// </summary>
        /// <param name="listView">Corresponding ListView.</param>
        public ListViewState(ListView listView)
        {
            ListView = listView;
        }
    }
}
Tags: WPF