WPF Tips n' Tricks – Preventing ScrollViewer from handling the mouse wheel
In the category of the pot talking to the pan, I present you ScrollViewer. It's the main control to implement scrolling in your templates, but it's also the one not respecting a very fundamental rule of scrolling: if you're done scrolling, let your parent scroll!
Not only does ScrollViewer handles the mouse scrolling even when no more scrolling is needed, but it also does so when there's nothing to scroll, or worse when it is told not to scroll! Let's take an example XAML file.
<Window x:Class="CaffeineIT.Blog.ScrollViewerExample.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="423" Width="596">
<Grid>
<ScrollViewer>
<StackPanel>
<TextBlock>Inside First ScrollViewer</TextBlock>
<ScrollViewer Name="NoScrollingScrollViewer">
<TextBlock>Content that doesn't need scrolling</TextBlock>
</ScrollViewer>
<ScrollViewer Height="235" Name="ScrollingNeededScrollViewer">
<StackPanel>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<ListView>
<TextBlock>Inside Thrid ListView</TextBlock>
<TextBlock>Inside Thrid ListView</TextBlock>
<TextBlock>Inside Thrid ListView</TextBlock>
<TextBlock>Inside Thrid ListView</TextBlock>
<TextBlock>Inside Thrid ListView</TextBlock>
<TextBlock>Inside Thrid ListView</TextBlock>
</ListView>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
<TextBlock>Inside Second ScrollViewer</TextBlock>
</StackPanel>
</ScrollViewer>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
<TextBlock>Inside First ScrollViewer</TextBlock>
</StackPanel>
</ScrollViewer>
</Grid>
</Window>
How can we change the ScrollViewer to behave more like it's supposed to? The most direct approach is to leverage the tunneling and bubbling events and use them against the buggy control.
The idea is that if the PreviewMouseWheel is handled, WPF will not generate the MouseWheel event, and in turn the ScrollViewer will not scroll.
Let's add a handler for the PreviewMouseWheel event on one of our ScrollViewers.
<ScrollViewer Height="235" Name="ScrollingNeededScrollViewer" PreviewMouseWheel="HandlePreviewMouseWheel">
private void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (sender is ScrollViewer && !e.Handled)
{
e.Handled = true;
var eventArg = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta);
eventArg.RoutedEvent = UIElement.MouseWheelEvent;
eventArg.Source = sender;
var parent = ((Control)sender).Parent as UIElement;
parent.RaiseEvent(eventArg);
}
}
This does exactly what we want. It marks the tunneling PreviewMouseWheel event as handled, so as to prevent WPF from raising the bubbling MouseWheel event, which is the one causing the actual scrolling. This is fine in case you don't want a ScrollViewer to scroll at all and let its parent do the scrolling, but what if you only want your ScrollViewer to scroll until it cannot anymore, and then let the parent scroll (this is the behavior in Internet Explorer)? Let's tweak the code a bit.
private void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
var scrollControl = sender as ScrollViewer;
if (!e.Handled && sender != null)
{
bool cancelScrolling = false;
if ((e.Delta > 0 && scrollControl.VerticalOffset == 0)
|| (e.Delta <= 0 && scrollControl.VerticalOffset >= scrollControl.ExtentHeight - scrollControl.ViewportHeight))
{
e.Handled = true;
var eventArg = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta);
eventArg.RoutedEvent = UIElement.MouseWheelEvent;
eventArg.Source = sender;
var parent = ((Control)sender).Parent as UIElement;
parent.RaiseEvent(eventArg);
}
}
}
Now, we check on every mouse wheel scroll if any content needs scrolling in the direction the wheel was scrolled. We check the VerticalOffset property, as it is 0 when you can't scroll up anymore and ExtentHeight-ViewportHeight when you can't scroll down anymore. If there's nothing to scroll, we cancel the event and re-raise it just like we did before.
That's all well so far, but what if I have another child ScrollViewer, like the ListView in our example? The ListView will not receive any notifications if the parent ScrollViewer is scrolled to the max in either direction, because we stop the PreviewMouseWheel before it can reach the ListView. We need to change the code a bit more and do the work the framework would've done.
private void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
var scrollControl = sender as ScrollViewer;
if (!e.Handled && sender != null && !_reentrantList.Contains(e))
{
var previewEventArg = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta)
{
RoutedEvent = UIElement.PreviewMouseWheelEvent,
Source = sender
};
var originalSource = e.OriginalSource as UIElement;
_reentrantList.Add(previewEventArg);
originalSource.RaiseEvent(previewEventArg);
_reentrantList.Remove(previewEventArg);
// at this point if no one else handled the event in our children, we do our job
if (!previewEventArg.Handled && ((e.Delta > 0 && scrollControl.VerticalOffset == 0)
|| (e.Delta <= 0 && scrollControl.VerticalOffset >= scrollControl.ExtentHeight - scrollControl.ViewportHeight)))
{
e.Handled = true;
var eventArg = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta);
eventArg.RoutedEvent = UIElement.MouseWheelEvent;
eventArg.Source = sender;
var parent = ((Control)sender).Parent as UIElement;
parent.RaiseEvent(eventArg);
}
}
}
The main difference is that before we try to cancel the PreviewMouseWheel event by marking it Handled, we check if any child of the control would mark it Handled before us, which by WPF design would mean we shouldn't handle the event at all.
If you try this example now, you'll notice now that our ListView still prevents the scrolling to happen properly. That's because we only changed the behavior of the ScrollViewer we attached an event handler to, and not the one inside the ListView. Using the attached property initialization hack we used before, we can define an attached property that will do all the hookup work whenever attached to a ScrollViewer.
public class ScrollViewerCorrector
{
public static bool GetFixScrolling(DependencyObject obj)
{
return (bool)obj.GetValue(FixScrollingProperty);
}
public static void SetFixScrolling(DependencyObject obj, bool value)
{
obj.SetValue(FixScrollingProperty, value);
}
public static readonly DependencyProperty FixScrollingProperty =
DependencyProperty.RegisterAttached("FixScrolling", typeof(bool), typeof(ScrollViewerCorrector), new FrameworkPropertyMetadata(false,ScrollViewerCorrector.OnFixScrollingPropertyChanged));
public static void OnFixScrollingPropertyChanged(object sender, DependencyPropertyChangedEventArgs e)
{
ScrollViewer viewer = sender as ScrollViewer;
if (viewer == null)
throw new ArgumentException("The dependency property can only be attached to a ScrollViewer", "sender");
if ((bool)e.NewValue == true)
viewer.PreviewMouseWheel += HandlePreviewMouseWheel;
else if ((bool)e.NewValue == false)
viewer.PreviewMouseWheel -= HandlePreviewMouseWheel;
}
private static List<MouseWheelEventArgs> _reentrantList = new List<MouseWheelEventArgs>();
private static void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
var scrollControl = sender as ScrollViewer;
if (!e.Handled && sender != null && !_reentrantList.Contains(e))
{
var previewEventArg = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta)
{
RoutedEvent = UIElement.PreviewMouseWheelEvent,
Source = sender
};
var originalSource = e.OriginalSource as UIElement;
_reentrantList.Add(previewEventArg);
originalSource.RaiseEvent(previewEventArg);
_reentrantList.Remove(previewEventArg);
// at this point if no one else handled the event in our children, we do our job
if (!previewEventArg.Handled && ((e.Delta > 0 && scrollControl.VerticalOffset == 0)
|| (e.Delta <= 0 && scrollControl.VerticalOffset >= scrollControl.ExtentHeight - scrollControl.ViewportHeight)))
{
e.Handled = true;
var eventArg = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta);
eventArg.RoutedEvent = UIElement.MouseWheelEvent;
eventArg.Source = sender;
var parent = (UIElement)((FrameworkElement)sender).Parent;
parent.RaiseEvent(eventArg);
}
}
}
}
And the only thing left to do is to change the template for ScrollViewer to always define the attached property by adding the Style to the resources on the Window, and pronto, all your ScrollViewers are now behaving properly.
<Window x:Class="CaffeineIT.Blog.ScrollViewerExample.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="423" Width="596" xmlns:my="clr-namespace:CaffeineIT.Blog.ScrollViewerExample">
<Window.Resources>
<Style TargetType="{x:Type ScrollViewer}">
<Style.Setters>
<Setter Property="my:ScrollViewerCorrector.FixScrolling" Value="True" />
</Style.Setters>
</Style>
</Window.Resources>
As usual, you can download the ScrollViewerFixer.zip code and sample.