10
votes

How do you enable WPF to respond to horizontal scrolling using the mouse tilt wheel? For example, I have a Microsoft Explorer mini mouse and have tried horizontally scrolling content contained within a ScrollViewer with

HorizontalScrollBarVisibility="Visible"

but the content will not scroll horizontally. Vertical scrolling, however, works reliably as usual.

If such input is not directly supported by WPF at this time, is there a way to do this using interop with unmanaged code?

Thanks!

5
This doesn't just work? Very disappointing.Pieter van Ginkel
Not with .NET 3.5 on Windows XP, not on my machine.T. Webster

5 Answers

8
votes

I just made a class that adds the PreviewMouseHorizontalWheel and MouseHorizontalWheel attached events to all UIElements. These events include as parameter a MouseHorizontalWheelEventArgs HorizontalDelta.

Update 3

The tilt value was reversed according to WPF standards, where up is positive and down is negative, so made left positive and right negative.

Update 2

If the AutoEnableMouseHorizontalWheelSupport is set to true (as it is by default) there's no special requirement to use those events.

Only if it is set to false then you will need to call either MouseHorizontalWheelEnabler.EnableMouseHorizontalWheel(X) where X is the top level element (Window, Popup or ContextMenu) or MouseHorizontalWheelEnabler.EnableMouseHorizontalWheelForParentOf(X) with the Element to enable support for. You can read the provided docs for more info on those methods.

Note that all this does nothing on XP, since WM_MOUSE-H-WHEEL was added on Vista.

MouseHorizontalWheelEnabler.cs

using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Interop;
using JetBrains.Annotations;

namespace WpfExtensions
{
    public static class MouseHorizontalWheelEnabler
    {
        /// <summary>
        ///   When true it will try to enable Horizontal Wheel support on parent windows/popups/context menus automatically
        ///   so the programmer does not need to call it.
        ///   Defaults to true.
        /// </summary>
        public static bool AutoEnableMouseHorizontalWheelSupport = true;

        private static readonly HashSet<IntPtr> _HookedWindows = new HashSet<IntPtr>();

        /// <summary>
        ///   Enable Horizontal Wheel support for all the controls inside the window.
        ///   This method does not need to be called if AutoEnableMouseHorizontalWheelSupport is true.
        ///   This does not include popups or context menus.
        ///   If it was already enabled it will do nothing.
        /// </summary>
        /// <param name="window">Window to enable support for.</param>
        public static void EnableMouseHorizontalWheelSupport([NotNull] Window window) {
            if (window == null) {
                throw new ArgumentNullException(nameof(window));
            }

            if (window.IsLoaded) {
                // handle should be available at this level
                IntPtr handle = new WindowInteropHelper(window).Handle;
                EnableMouseHorizontalWheelSupport(handle);
            }
            else {
                window.Loaded += (sender, args) => {
                    IntPtr handle = new WindowInteropHelper(window).Handle;
                    EnableMouseHorizontalWheelSupport(handle);
                };
            }
        }

        /// <summary>
        ///   Enable Horizontal Wheel support for all the controls inside the popup.
        ///   This method does not need to be called if AutoEnableMouseHorizontalWheelSupport is true.
        ///   This does not include sub-popups or context menus.
        ///   If it was already enabled it will do nothing.
        /// </summary>
        /// <param name="popup">Popup to enable support for.</param>
        public static void EnableMouseHorizontalWheelSupport([NotNull] Popup popup) {
            if (popup == null) {
                throw new ArgumentNullException(nameof(popup));
            }

            if (popup.IsOpen) {
                // handle should be available at this level
                // ReSharper disable once PossibleInvalidOperationException
                EnableMouseHorizontalWheelSupport(GetObjectParentHandle(popup.Child).Value);
            }

            // also hook for IsOpened since a new window is created each time
            popup.Opened += (sender, args) => {
                // ReSharper disable once PossibleInvalidOperationException
                EnableMouseHorizontalWheelSupport(GetObjectParentHandle(popup.Child).Value);
            };
        }

        /// <summary>
        ///   Enable Horizontal Wheel support for all the controls inside the context menu.
        ///   This method does not need to be called if AutoEnableMouseHorizontalWheelSupport is true.
        ///   This does not include popups or sub-context menus.
        ///   If it was already enabled it will do nothing.
        /// </summary>
        /// <param name="contextMenu">Context menu to enable support for.</param>
        public static void EnableMouseHorizontalWheelSupport([NotNull] ContextMenu contextMenu) {
            if (contextMenu == null) {
                throw new ArgumentNullException(nameof(contextMenu));
            }

            if (contextMenu.IsOpen) {
                // handle should be available at this level
                // ReSharper disable once PossibleInvalidOperationException
                EnableMouseHorizontalWheelSupport(GetObjectParentHandle(contextMenu).Value);
            }

            // also hook for IsOpened since a new window is created each time
            contextMenu.Opened += (sender, args) => {
                // ReSharper disable once PossibleInvalidOperationException
                EnableMouseHorizontalWheelSupport(GetObjectParentHandle(contextMenu).Value);
            };
        }

        private static IntPtr? GetObjectParentHandle([NotNull] DependencyObject depObj) {
            if (depObj == null) {
                throw new ArgumentNullException(nameof(depObj));
            }

            var presentationSource = PresentationSource.FromDependencyObject(depObj) as HwndSource;
            return presentationSource?.Handle;
        }

        /// <summary>
        ///   Enable Horizontal Wheel support for all the controls inside the HWND.
        ///   This method does not need to be called if AutoEnableMouseHorizontalWheelSupport is true.
        ///   This does not include popups or sub-context menus.
        ///   If it was already enabled it will do nothing.
        /// </summary>
        /// <param name="handle">HWND handle to enable support for.</param>
        /// <returns>True if it was enabled or already enabled, false if it couldn't be enabled.</returns>
        public static bool EnableMouseHorizontalWheelSupport(IntPtr handle) {
            if (_HookedWindows.Contains(handle)) {
                return true;
            }

            _HookedWindows.Add(handle);
            HwndSource source = HwndSource.FromHwnd(handle);
            if (source == null) {
                return false;
            }

            source.AddHook(WndProcHook);
            return true;
        }

        /// <summary>
        ///   Disable Horizontal Wheel support for all the controls inside the HWND.
        ///   This method does not need to be called in most cases.
        ///   This does not include popups or sub-context menus.
        ///   If it was already disabled it will do nothing.
        /// </summary>
        /// <param name="handle">HWND handle to disable support for.</param>
        /// <returns>True if it was disabled or already disabled, false if it couldn't be disabled.</returns>
        public static bool DisableMouseHorizontalWheelSupport(IntPtr handle) {
            if (!_HookedWindows.Contains(handle)) {
                return true;
            }

            HwndSource source = HwndSource.FromHwnd(handle);
            if (source == null) {
                return false;
            }

            source.RemoveHook(WndProcHook);
            _HookedWindows.Remove(handle);
            return true;
        }

        /// <summary>
        ///   Disable Horizontal Wheel support for all the controls inside the window.
        ///   This method does not need to be called in most cases.
        ///   This does not include popups or sub-context menus.
        ///   If it was already disabled it will do nothing.
        /// </summary>
        /// <param name="window">Window to disable support for.</param>
        /// <returns>True if it was disabled or already disabled, false if it couldn't be disabled.</returns>
        public static bool DisableMouseHorizontalWheelSupport([NotNull] Window window) {
            if (window == null) {
                throw new ArgumentNullException(nameof(window));
            }

            IntPtr handle = new WindowInteropHelper(window).Handle;
            return DisableMouseHorizontalWheelSupport(handle);
        }

        /// <summary>
        ///   Disable Horizontal Wheel support for all the controls inside the popup.
        ///   This method does not need to be called in most cases.
        ///   This does not include popups or sub-context menus.
        ///   If it was already disabled it will do nothing.
        /// </summary>
        /// <param name="popup">Popup to disable support for.</param>
        /// <returns>True if it was disabled or already disabled, false if it couldn't be disabled.</returns>
        public static bool DisableMouseHorizontalWheelSupport([NotNull] Popup popup) {
            if (popup == null) {
                throw new ArgumentNullException(nameof(popup));
            }

            IntPtr? handle = GetObjectParentHandle(popup.Child);
            if (handle == null) {
                return false;
            }

            return DisableMouseHorizontalWheelSupport(handle.Value);
        }

        /// <summary>
        ///   Disable Horizontal Wheel support for all the controls inside the context menu.
        ///   This method does not need to be called in most cases.
        ///   This does not include popups or sub-context menus.
        ///   If it was already disabled it will do nothing.
        /// </summary>
        /// <param name="contextMenu">Context menu to disable support for.</param>
        /// <returns>True if it was disabled or already disabled, false if it couldn't be disabled.</returns>
        public static bool DisableMouseHorizontalWheelSupport([NotNull] ContextMenu contextMenu) {
            if (contextMenu == null) {
                throw new ArgumentNullException(nameof(contextMenu));
            }

            IntPtr? handle = GetObjectParentHandle(contextMenu);
            if (handle == null) {
                return false;
            }

            return DisableMouseHorizontalWheelSupport(handle.Value);
        }


        /// <summary>
        ///   Enable Horizontal Wheel support for all that control and all controls hosted by the same window/popup/context menu.
        ///   This method does not need to be called if AutoEnableMouseHorizontalWheelSupport is true.
        ///   If it was already enabled it will do nothing.
        /// </summary>
        /// <param name="uiElement">UI Element to enable support for.</param>
        public static void EnableMouseHorizontalWheelSupportForParentOf(UIElement uiElement) {
            // try to add it right now
            if (uiElement is Window) {
                EnableMouseHorizontalWheelSupport((Window)uiElement);
            }
            else if (uiElement is Popup) {
                EnableMouseHorizontalWheelSupport((Popup)uiElement);
            }
            else if (uiElement is ContextMenu) {
                EnableMouseHorizontalWheelSupport((ContextMenu)uiElement);
            }
            else {
                IntPtr? parentHandle = GetObjectParentHandle(uiElement);
                if (parentHandle != null) {
                    EnableMouseHorizontalWheelSupport(parentHandle.Value);
                }

                // and in the rare case the parent window ever changes...
                PresentationSource.AddSourceChangedHandler(uiElement, PresenationSourceChangedHandler);
            }
        }

        private static void PresenationSourceChangedHandler(object sender, SourceChangedEventArgs sourceChangedEventArgs) {
            var src = sourceChangedEventArgs.NewSource as HwndSource;
            if (src != null) {
                EnableMouseHorizontalWheelSupport(src.Handle);
            }
        }

        private static void HandleMouseHorizontalWheel(IntPtr wParam) {
            int tilt = -Win32.HiWord(wParam);
            if (tilt == 0) {
                return;
            }

            IInputElement element = Mouse.DirectlyOver;
            if (element == null) {
                return;
            }

            if (!(element is UIElement)) {
                element = VisualTreeHelpers.FindAncestor<UIElement>(element as DependencyObject);
            }
            if (element == null) {
                return;
            }

            var ev = new MouseHorizontalWheelEventArgs(Mouse.PrimaryDevice, Environment.TickCount, tilt) {
                RoutedEvent = PreviewMouseHorizontalWheelEvent
                //Source = handledWindow
            };

            // first raise preview
            element.RaiseEvent(ev);
            if (ev.Handled) {
                return;
            }

            // then bubble it
            ev.RoutedEvent = MouseHorizontalWheelEvent;
            element.RaiseEvent(ev);
        }

        private static IntPtr WndProcHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) {
            // transform horizontal mouse wheel messages 
            switch (msg) {
                case Win32.WM_MOUSEHWHEEL:
                    HandleMouseHorizontalWheel(wParam);
                    break;
            }
            return IntPtr.Zero;
        }

        private static class Win32
        {
            // ReSharper disable InconsistentNaming
            public const int WM_MOUSEHWHEEL = 0x020E;
            // ReSharper restore InconsistentNaming

            public static int GetIntUnchecked(IntPtr value) {
                return IntPtr.Size == 8 ? unchecked((int)value.ToInt64()) : value.ToInt32();
            }

            public static int HiWord(IntPtr ptr) {
                return unchecked((short)((uint)GetIntUnchecked(ptr) >> 16));
            }
        }

        #region MouseWheelHorizontal Event

        public static readonly RoutedEvent MouseHorizontalWheelEvent =
          EventManager.RegisterRoutedEvent("MouseHorizontalWheel", RoutingStrategy.Bubble, typeof(RoutedEventHandler),
            typeof(MouseHorizontalWheelEnabler));

        public static void AddMouseHorizontalWheelHandler(DependencyObject d, RoutedEventHandler handler) {
            var uie = d as UIElement;
            if (uie != null) {
                uie.AddHandler(MouseHorizontalWheelEvent, handler);

                if (AutoEnableMouseHorizontalWheelSupport) {
                    EnableMouseHorizontalWheelSupportForParentOf(uie);
                }
            }
        }

        public static void RemoveMouseHorizontalWheelHandler(DependencyObject d, RoutedEventHandler handler) {
            var uie = d as UIElement;
            uie?.RemoveHandler(MouseHorizontalWheelEvent, handler);
        }

        #endregion

        #region PreviewMouseWheelHorizontal Event

        public static readonly RoutedEvent PreviewMouseHorizontalWheelEvent =
          EventManager.RegisterRoutedEvent("PreviewMouseHorizontalWheel", RoutingStrategy.Tunnel, typeof(RoutedEventHandler),
            typeof(MouseHorizontalWheelEnabler));

        public static void AddPreviewMouseHorizontalWheelHandler(DependencyObject d, RoutedEventHandler handler) {
            var uie = d as UIElement;
            if (uie != null) {
                uie.AddHandler(PreviewMouseHorizontalWheelEvent, handler);

                if (AutoEnableMouseHorizontalWheelSupport) {
                    EnableMouseHorizontalWheelSupportForParentOf(uie);
                }
            }
        }

        public static void RemovePreviewMouseHorizontalWheelHandler(DependencyObject d, RoutedEventHandler handler) {
            var uie = d as UIElement;
            uie?.RemoveHandler(PreviewMouseHorizontalWheelEvent, handler);
        }

        #endregion
    }
}

MouseHorizontalWheelEventArgs.cs

using System.Windows.Input;

namespace WpfExtensions
{
    public class MouseHorizontalWheelEventArgs : MouseEventArgs
    {
        public int HorizontalDelta { get; }

        public MouseHorizontalWheelEventArgs(MouseDevice mouse, int timestamp, int horizontalDelta)
          : base(mouse, timestamp) {
            HorizontalDelta = horizontalDelta;
        }
    }
}

As for VisualTreeHelpers.FindAncestor, it is defined as follows:

/// <summary>
///   Returns the first ancestor of specified type
/// </summary>
public static T FindAncestor<T>(DependencyObject current) where T : DependencyObject {
  current = GetVisualOrLogicalParent(current);

  while (current != null) {
    if (current is T) {
      return (T)current;
    }
    current = GetVisualOrLogicalParent(current);
  }

  return null;
}

private static DependencyObject GetVisualOrLogicalParent(DependencyObject obj) {
  if (obj is Visual || obj is Visual3D) {
    return VisualTreeHelper.GetParent(obj);
  }
  return LogicalTreeHelper.GetParent(obj);
}
6
votes

Call the AddHook() method in your Window constructor so you can spy on the messages. Look for WM_MOUSEHWHEEL, message 0x20e. Use wParam.ToInt32() >> 16 to get the movement amount, a multiple of 120.

3
votes

Another solution using Attached Properties. It works for any Control that is either a ScrollViewer or contains a ScrollViewer. It is a fairly simple solution, and most importantly it is very easy to re-use. What I've done with my project is set this attached property in Generic.xaml for DataGrid, ListBox, ListView and friends. This way, it always just works.

This will work with multiple scroll viewers in the same UI, and will apply to whichever one currently has the mouse over it.

Here's the code for the attached property, as well as a helper class

note: C#6 syntax

Needed code

using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;

namespace MyTestProject
{
    public class TiltWheelHorizontalScroller
    {
        public static bool GetEnableTiltWheelScroll(DependencyObject obj) => (bool)obj.GetValue(EnableTiltWheelScrollProperty);
        public static void SetEnableTiltWheelScroll(DependencyObject obj, bool value) => obj.SetValue(EnableTiltWheelScrollProperty, value);

        public static readonly DependencyProperty EnableTiltWheelScrollProperty =
                DependencyProperty.RegisterAttached("EnableTiltWheelScroll", typeof(bool), typeof(TiltWheelHorizontalScroller), new UIPropertyMetadata(false, OnHorizontalMouseWheelScrollingEnabledChanged));

        static HashSet<int> controls = new HashSet<int>();
        static void OnHorizontalMouseWheelScrollingEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs args)
        {
            Control control = d as Control;
            if (control != null && GetEnableTiltWheelScroll(d) && controls.Add(control.GetHashCode()))
            {
                control.MouseEnter += (sender, e) =>
                {
                    var scrollViewer = d.FindChildOfType<ScrollViewer>();
                    if (scrollViewer != null)
                    {
                        new TiltWheelMouseScrollHelper(scrollViewer, d);
                    }
                };
            }
        }
    }

    class TiltWheelMouseScrollHelper
    {
        /// <summary>
        /// multiplier of how far to scroll horizontally. Change as desired.
        /// </summary>
        private const int scrollFactor = 3;
        private const int WM_MOUSEHWEEL = 0x20e;
        ScrollViewer scrollViewer;
        HwndSource hwndSource;
        HwndSourceHook hook;
        static HashSet<int> scrollViewers = new HashSet<int>();

        public TiltWheelMouseScrollHelper(ScrollViewer scrollViewer, DependencyObject d)
        {
            this.scrollViewer = scrollViewer;
            hwndSource = PresentationSource.FromDependencyObject(d) as HwndSource;
            hook = WindowProc;
            hwndSource?.AddHook(hook);
            if (scrollViewers.Add(scrollViewer.GetHashCode()))
            {
                scrollViewer.MouseLeave += (sender, e) =>
                {
                    hwndSource.RemoveHook(hook);
                };
            }
        }

        IntPtr WindowProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            switch (msg)
            {
                case WM_MOUSEHWEEL:
                    Scroll(wParam);
                    handled = true;
                    break;
            }
            return IntPtr.Zero;
        }

        private void Scroll(IntPtr wParam)
        {
            int delta = (HIWORD(wParam) > 0 ? 1 : -1) * scrollFactor;
            scrollViewer.ScrollToHorizontalOffset(scrollViewer.HorizontalOffset + delta);
        }

        private static int HIWORD(IntPtr ptr) => (short)((((int)ptr.ToInt64()) >> 16) & 0xFFFF);
    }
}

And you will need this extension method if you don't already have it.

/// <summary>
/// Finds first child of provided type. If child not found, null is returned
/// </summary>
/// <typeparam name="T">Type of chiled to be found</typeparam>
/// <param name="source"></param>
/// <returns></returns>
public static T FindChildOfType<T>(this DependencyObject originalSource) where T : DependencyObject
{
    T ret = originalSource as T;
    DependencyObject child = null;
    if (originalSource != null && ret == null)
    {
        for (int i = 0; i < VisualTreeHelper.GetChildrenCount(originalSource); i++)
        {
            child = VisualTreeHelper.GetChild(originalSource, i);
            if (child != null)
            {
                if (child is T)
                {
                    ret = child as T;
                    break;
                }
                else
                {
                    ret = child.FindChildOfType<T>();
                    if (ret != null)
                    {
                        break;
                    }
                }
            }
        }
    }
    return ret;
}

Usage

Simple example of a Window with a DataGrid. Here DataItems is just some fake data I made up for the test case.

<Window x:Class="MyTestProject.MainWindow"
                xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                xmlns:ap="clr-namespace:MyTestProject"
                Title="MainWindow" Height="350" Width="525"
                DataContext="{Binding RelativeSource={RelativeSource Self}}">
    <Grid>
        <DataGrid x:Name="dataGrid"
                  ItemsSource="{Binding DataItems}"
                  ap:TiltWheelHorizontalScroller.EnableTiltWheelScroll="True"/>
    </Grid>
</Window>

Or, what I ended up doing, put this style in Generic.xaml, or your Window.Resources to apply to all datagrids. You can attach this property to any control that has a ScrollViewer in it (and of course that horizontal scrolling is not disabled).

<Style TargetType="{x:Type DataGrid}" BasedOn="{StaticResource {x:Type DataGrid}}">
    <Setter Property="ap:TiltWheelHorizontalScroller.EnableTiltWheelScroll" Value="True"/>
</Style>
0
votes

T. Webster posted a WPF code snippet that adds horizontal mouse scroll support to any ScrollViewer and DependancyObject. It utilizes the AddHook and window messages as others have described.

I was able to adapt this to a behavior pretty quickly and attach it to a ScrollViewer in XAML.

0
votes

This Microsoft link provides your exact requirement: horizontal scrolling

and then just override this method -

private void OnMouseTilt(int tilt)
    {
        // Write your horizontal handling codes here.
        if(!mainScrollViewer.IsVisible) return;

        if (tilt > 0)
        {
            mainScrollViewer.LineLeft();
        }
        else
        {
            mainScrollViewer.LineRight();
        }
    }