The blog of dlaa.me

Fewer gotchas to getcha [Enhancing the ScrollIntoViewCentered method for WPF's ListBox]

Earlier this month I blogged about adding the ScrollIntoViewCentered method to WPF's ListBox control. At the time, I explained why it was necessary to set ScrollViewer.CanContentScroll to False for the code I'd written to function. The limitation didn't matter for my scenario, so I didn't spend too much time worrying about it then...

However, after putting the code into use for my RepositoryExplorer version control system browser side-project, I discovered a bug. And further investigation proved that it wasn't a bug in my code - it was a bug in WPF! Specifically, setting ScrollViewer.CanContentScroll to False for a ListBox with a horizontal ScrollBar breaks the Page Down key under most circumstances. (FYI, I've already reported this issue to the WPF team to fix in a future release.) Here's a complete demonstration of the problem:

<ListBox
    ItemsSource="{x:Static Fonts.SystemFontFamilies}"
    ScrollViewer.CanContentScroll="False"
    ScrollViewer.HorizontalScrollBarVisibility="Visible"/>

Just paste that XAML in a new application (or XamlPad), run it, and try to Page Down through the items in the list - you'll find that instead of advancing a page each time you press Page Down, the list advances only a single line. Specifically, the problem seems to occur if the bottom-most item in the ListBox can hide completely behind the horizontal ScrollBar - which it does for the default font size of a WPF application. :(

This bug is pretty bad and I didn't see a clean way of working around it, so I decided to revisit my ScrollIntoViewCentered implementation to see if there was some way I could get it working with ScrollViewer.CanContentScroll set to True...

And as luck would have it, I managed to do so! Scroll down to find an updated version of ScrollIntoViewCentered that works well with both settings. I've also updated the sample application to show ScrollIntoViewCentered working with a ListBox where ScrollViewer.CanContentScroll is False (left; same as before) and another where it is True (right; new scenario). I've forced the horizontal ScrollBar on for both scenarios so you can see the broken Page Down behavior in the first one.

ListBoxExtensionsDemo sample

There's just one thing to keep in mind: when using logical scrolling (item-based), WPF deals with item indices rather than pixel sizes for all of the items. The good news is that logical scrolling is noticeably faster than physical scrolling (pixel-based), so the new support for it by ScrollIntoViewCentered allows you to use the faster scrolling option. However, the bad news is that the calculations ScrollIntoViewCentered does for logical scrolling assume that all the items in the list are the same size. While this is nearly always the case with ListBox items, it doesn't need to be. So please consider the tradeoffs when choosing how you use ScrollIntoViewCentered.

[Click here to download the source code for ScrollIntoViewCentered and the ListBoxExtensionsDemo sample application.]

Happy (more flexible) scrolling!

 

/// <summary>
/// Class implementing helpful extensions to ListBox.
/// </summary>
public static class ListBoxExtensions
{
    /// <summary>
    /// Causes the object to scroll into view centered.
    /// </summary>
    /// <param name="listBox">ListBox instance.</param>
    /// <param name="item">Object to scroll.</param>
    [SuppressMessage("Microsoft.Design", "CA1011:ConsiderPassingBaseTypesAsParameters",
        Justification = "Deliberately targeting ListBox.")]
    public static void ScrollIntoViewCentered(this ListBox listBox, object item)
    {
        Debug.Assert(!VirtualizingStackPanel.GetIsVirtualizing(listBox),
            "VirtualizingStackPanel.IsVirtualizing must be disabled for ScrollIntoViewCentered to work.");

        // Get the container for the specified item
        var container = listBox.ItemContainerGenerator.ContainerFromItem(item) as FrameworkElement;
        if (null != container)
        {
            if (ScrollViewer.GetCanContentScroll(listBox))
            {
                // Get the parent IScrollInfo
                var scrollInfo = VisualTreeHelper.GetParent(container) as IScrollInfo;
                if (null != scrollInfo)
                {
                    // Need to know orientation, so parent must be a known type
                    var stackPanel = scrollInfo as StackPanel;
                    var virtualizingStackPanel = scrollInfo as VirtualizingStackPanel;
                    Debug.Assert((null != stackPanel) || (null != virtualizingStackPanel),
                        "ItemsPanel must be a StackPanel or VirtualizingStackPanel for ScrollIntoViewCentered to work.");

                    // Get the container's index
                    var index = listBox.ItemContainerGenerator.IndexFromContainer(container);

                    // Center the item by splitting the extra space
                    if (((null != stackPanel) && (Orientation.Horizontal == stackPanel.Orientation)) ||
                        ((null != virtualizingStackPanel) && (Orientation.Horizontal == virtualizingStackPanel.Orientation)))
                    {
                        scrollInfo.SetHorizontalOffset(index - Math.Floor(scrollInfo.ViewportWidth / 2));
                    }
                    else
                    {
                        scrollInfo.SetVerticalOffset(index - Math.Floor(scrollInfo.ViewportHeight / 2));
                    }
                }
            }
            else
            {
                // Get the bounds of the item container
                var rect = new Rect(new Point(), container.RenderSize);

                // Find constraining parent (either the nearest ScrollContentPresenter or the ListBox itself)
                FrameworkElement constrainingParent = container;
                do
                {
                    constrainingParent = VisualTreeHelper.GetParent(constrainingParent) as FrameworkElement;
                } while ((null != constrainingParent) &&
                         (listBox != constrainingParent) &&
                         !(constrainingParent is ScrollContentPresenter));

                if (null != constrainingParent)
                {
                    // Inflate rect to fill the constraining parent
                    rect.Inflate(
                        Math.Max((constrainingParent.ActualWidth - rect.Width) / 2, 0),
                        Math.Max((constrainingParent.ActualHeight - rect.Height) / 2, 0));
                }

                // Bring the (inflated) bounds into view
                container.BringIntoView(rect);
            }
        }
    }
}
Tags: WPF