Monday, 3 September 2007

WPF Tips'n'Tricks #6: 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.

4 comments:

C# Disciple said...

this was a very good article.... kepp it up

Dave said...

Hi, thanks for the informative article, it's exactly what I was looking for.

However, I've found a slight problem with it :-(

When you run your demo, if you hover over the "Second ScollViewer" and scroll it a click or two down, you can't then scroll it back up!

It will only let you scroll a child ScrollViewer up, if the parent ScrollViewer is not scrolled to the very top.

Similarly, it will only let you scroll a child ScrollViewer down, if the parent ScrollViewer is not already scrolled to the very bottom (which you can see if you extend the vertical height of the window a bit).

I've tried stepping through the code, but couldn't see an easy way to fix it... any ideas would be much appreciated.

Cheers.

梦中林 said...

When the Wow Gold wolf finally found the Buy Wow Goldhole in the chimney he crawled wow gold cheap down and KERSPLASH right into that kettle of water and that was cheap wow gold the end of his troubles with the big bad wolf.
The next day the cheapest wow gold little pig invited his mother over . She said "You see it is just as mygamegoldI told you. The way to get along in the world is to do world of warcraft gold things as well as you can." Fortunately for that little pig, he buy cheap wow gold learned that lesson. And he just lived happily ever after! wow gold .

greatRGB said...

I found a little bug. Around line 88.

"var originalSource = e.OriginalSource as UIElement;

if that returns null for some reason it will bomb at:

"originalSource.RaiseEvent(previewEventArg);"

just do a null check right before that line:

"if(originalSource != null)
originalSource.RaiseEvent(previewEventArg);"

thanks for the great tip!!!!

Post a Comment