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