A Touch-Scrolling Attached Behaviour for ScrollViewer

I'm working on a WPF application here at work that will run on a touch-screen system, but one that's still running Windows XP. That means that all "touch" events are really just "mouse left button" events.

Nowadays when a user sees a list of things on a touch screen, they intuitively think they can scroll the list by dragging it up or down, just like they would on a phone. That, of course, doesn't work by default in WPF applications, so I wrote an attached behaviour to make it work.

A screenshot of PigFM DryWall

I don't think I'd call this "best practice" - there are, after all, some "touch specific" events in WPF that I should probably be handling as well as the mouse ones - but it works just fine.

First, the usage:

<ScrollViewer my:TouchScrolling.IsEnabled="True" />

(Or put the attached behaviour in a style that applies to all ScrollViewer instances, as I have.)

And secondly, the class itself:

Update - we're now capturing the mouse, which makes the scrolling work even if your underlying list contains clickable elements like buttons. You won't accidentally click things when you lift the mouse after scrolling.

public class TouchScrolling : DependencyObject
{
    public static bool GetIsEnabled(DependencyObject obj)
    {
        return (bool)obj.GetValue(IsEnabledProperty);
    }

    public static void SetIsEnabled(DependencyObject obj, bool value)
    {
        obj.SetValue(IsEnabledProperty, value);
    }

    public bool IsEnabled
    {
        get { return (bool)GetValue(IsEnabledProperty); }
        set { SetValue(IsEnabledProperty, value); }
    }

    public static readonly DependencyProperty IsEnabledProperty =
        DependencyProperty.RegisterAttached("IsEnabled", typeof(bool), typeof(TouchScrolling), new UIPropertyMetadata(false, IsEnabledChanged));

    static Dictionary<object, MouseCapture> _captures = new Dictionary<object, MouseCapture>();

    static void IsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var target = d as ScrollViewer;
        if (target == null) return;

        if ((bool)e.NewValue)
        {
            target.Loaded += target_Loaded;
        }
        else
        {
            target_Unloaded(target, new RoutedEventArgs());
        }
    }

    static void target_Unloaded(object sender, RoutedEventArgs e)
    {
        System.Diagnostics.Debug.WriteLine("Target Unloaded");

        var target = sender as ScrollViewer;
        if (target == null) return;

        _captures.Remove(sender);

        target.Loaded -= target_Loaded;
        target.Unloaded -= target_Unloaded;
        target.PreviewMouseLeftButtonDown -= target_PreviewMouseLeftButtonDown;
        target.PreviewMouseMove -= target_PreviewMouseMove;

        target.PreviewMouseLeftButtonUp -= target_PreviewMouseLeftButtonUp;
    }

    static void target_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        var target = sender as ScrollViewer;
        if (target == null) return;

        _captures[sender] = new MouseCapture
        {
            VerticalOffset = target.VerticalOffset,
            Point = e.GetPosition(target),
        };
    }

    static void target_Loaded(object sender, RoutedEventArgs e)
    {
        var target = sender as ScrollViewer;
        if (target == null) return;

        System.Diagnostics.Debug.WriteLine("Target Loaded");

        target.Unloaded += target_Unloaded;
        target.PreviewMouseLeftButtonDown += target_PreviewMouseLeftButtonDown;
        target.PreviewMouseMove += target_PreviewMouseMove;

        target.PreviewMouseLeftButtonUp += target_PreviewMouseLeftButtonUp;
    }

    static void target_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        var target = sender as ScrollViewer;
        if (target == null) return;

        target.ReleaseMouseCapture();
    }

    static void target_PreviewMouseMove(object sender, MouseEventArgs e)
    {
        if (!_captures.ContainsKey(sender)) return;

        if (e.LeftButton != MouseButtonState.Pressed)
        {
            _captures.Remove(sender);
            return;
        }

        var target = sender as ScrollViewer;
        if (target == null) return;

        var capture = _captures[sender];

        var point = e.GetPosition(target);

        var dy = point.Y - capture.Point.Y;
        if (Math.Abs(dy) > 5)
        {
            target.CaptureMouse();
        }

        target.ScrollToVerticalOffset(capture.VerticalOffset - dy);
    }

    internal class MouseCapture
    {
        public Double VerticalOffset { get; set; }
        public Point Point { get; set; }
    }
}

There are some quirks here. For example, I noticed that the ScrollViewer was actually being loaded, unloaded and loaded again when the content was shown. That meant that I couldn't just hook up the events in the IsEnabledChanged method and unhook them in the target_Unloaded event handler, because they were being unhooked immediately. Instead, I've had to hook them up in a handler for the Loaded event, which in turn never gets unhooked. That means that there's something of a "memory leak" in there, but it's one I'm prepared to live with. I'm sure I could work around this with weak events, but I've not done much with those and didn't want to do the learnin' this morning. :)

wpf
Posted by: Matt Hamilton
Last revised: 18 Dec, 2014 02:28 AM History

Trackbacks

Comments

Phil
Phil
18 Nov, 2012 12:57 PM @ version 2

Great work, do you intend to update to work for Windows 8 Apps?

18 Nov, 2012 09:34 PM @ version 2

Is such an update necessary, Phil? I haven't done any Windows 8 development yet, but I would assume that ListBoxes and their ilk would automatically scroll when you touched them and dragged up and down.

B
B
19 Apr, 2013 07:54 AM @ version 2

I was using this recently and It is a very useful utility, but I recently noticed a problem I've been trying to work around for a few hours.

Since this class is using Preview* events, there is no way that I can find to block them from content inside the scrollviewer. This can be a problem for slider controls. If you try do drag a horizontal slider and your mouse drifts up or down 5px, the ScrollViewer's mouse capture causes the slider to lose input, which can be very frustrating.

Any idea how to work around this?

No new comments are allowed on this post.