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.

Ads

Google adSense

After having adSense for months (and a total earning of $0.18), the ads are finally showing up something connected with my blog:

  • DataGrid controls from Infragistics
  • The power of .net 3.0 from Xceed
  • Debugging Visual Studio from Microsoft
  • Automating your page composition from Dakota... Uh?

Three out of four is not bad, and it's three companies any .net 3 developer should visit quite often for the latest news, so the ads are in this instance quite useful.

As for automating my page... This is a blog?!? Oh well.

Technorati Tags: , ,

Ads

Programming WPF 2nd Edition is out

Well through Chris Sell's blog, http://www.sellsbrothers.com/news/showTopic.aspx?ixTopic=2122, the new edition is shipping. I liked the previous version, only real reference at the time I started WPF, together with the Petzold.

So I just placed an order for it, and at the same time ordered Nathan's and the other Chris book, so I can have a go at reading them all.

I'll do a comparison of all of them and report here. It's been done before but now that I've been working professionally for what seems like years but is in fact only a year (between the RTM and beta 2), I think I may see things differently.

It will also be fun to compare the first and the 2nd edition of Programming WPF :)

Question is, should I order the 3D Petzold? We'll see after I'm done with the rest.

Anyone wants to commission me to write "Writing controls for WPF: Put some sparkle in your cider!"?

Technorati Tags: , , , ,

Ads