7

I am trying to create a touch and hold event handler with a variable delay in a WPF application by calling a bool task which runs a timer. If the timer elapses, the task returns true. If another event such as touch leave or touch up occurs, the task immediately returns false. Below is my event handler code:

private static async void Element_PreviewTouchDown(object sender, TouchEventArgs e)
{
    // Set handled to true to avoid clicks
    e.Handled = true;

    var isTouchHold = await TouchHold((FrameworkElement)sender, variableTimespan);
    if (isTouchHold)
        TouchHoldCmd?.Execute(someParam);
    else
    {
        // Here is where I would like to re initiate bubbling up of the event.
        // This doesn't work:
        e.Handled = false;
    }
}

The reason I want it to propagate the event is because, for example, if the user wants to pan the scrollviewer that the element is part of and the panning gesture is started by touching my element, my touchhold works as intended in that the touch and hold command won't get triggered but neither will the scrollviewer start panning.

I tried raising the event manually but this also doesn't seem to work:

bool firedBySelf;
private static async void Element_PreviewTouchDown(object sender, TouchEventArgs e)
{
    if(firedBySelf) 
    {
        firedBySelf = false;   
        return;
    }

    ...
    else
    {
        firedBySelf = true;
        e.Handled = false;
        ((FrameworkElement)sender).RaiseEvent(e);
    }
}

How can I achieve my goal?

Edit: Here is the class containing the task:

public static class TouchHoldHelper
{
    private static DispatcherTimer _timer;
    private static TaskCompletionSource<bool> _task;
    private static FrameworkElement _element;

    private static void MouseUpCancel(object sender, MouseButtonEventArgs e) => CancelHold();
    private static void MouseLeaveCancel(object sender, System.Windows.Input.MouseEventArgs e) => CancelHold();
    private static void TouchCancel(object sender, TouchEventArgs e) => CancelHold();

    private static void AddCancellingHandlers()
    {
        if (_element == null) return;

        _element.PreviewMouseUp += MouseUpCancel;
        _element.MouseUp += MouseUpCancel;
        _element.MouseLeave += MouseLeaveCancel;

        _element.PreviewTouchUp += TouchCancel;
        _element.TouchUp += TouchCancel;
        _element.TouchLeave += TouchCancel;
    }

    private static void RemoveCancellingHandlers()
    {
        if (_element == null) return;

        _element.PreviewMouseUp -= MouseUpCancel;
        _element.MouseUp -= MouseUpCancel;
        _element.MouseLeave -= MouseLeaveCancel;

        _element.PreviewTouchUp -= TouchCancel;
        _element.TouchUp -= TouchCancel;
        _element.TouchLeave -= TouchCancel;
    }

    private static void CancelHold()
    {
        if (_timer != null)
        {
            _timer.Stop();
            _timer.Tick -= _timer_Tick;
            _timer = null;
        }
        if (_task?.Task.Status != TaskStatus.RanToCompletion)
            _task?.TrySetResult(false);

        RemoveCancellingHandlers();
    }

    private static void _timer_Tick(object sender, EventArgs e)
    {
        var timer = sender as DispatcherTimer;
        timer.Stop();
        timer.Tick -= _timer_Tick;
        timer = null;
        _task.TrySetResult(true);
        RemoveCancellingHandlers();
    }

    public static Task<bool> TouchHold(this FrameworkElement element, TimeSpan duration)
    {
        _element = element;

        _timer = new DispatcherTimer();
        _timer.Interval = duration;
        _timer.Tick += _timer_Tick;

        _task = new TaskCompletionSource<bool>();

        AddCancellingHandlers();

        _timer.Start();
        return _task.Task;
    }
}

Edit: to better explain my intended behavior, consider how icons on a smartphone's screen work. If I tap the icon, it starts the app the icon represents. If I touch and move on an icon, it pans the screen. If I touch and hold the icon, it allows me to move the icon so I can place it somewhere else without panning the screen. If I touch and hold the icon but I don't hold it long enough to trigger the moving of the icon, it acts as if I tapped it, starting the app. I am trying to replicate these last 2 behaviors.

I am not saying my current implementation is the right approach but it's what I was able to come up with. If there is any alternative approach, I would be glad to explore it.

user1969903
  • 810
  • 13
  • 26
  • Right. I guess it's time I switch to Node, Angular and all that jazz. – user1969903 Aug 02 '19 at 13:27
  • How do you intend to use the created task? Can you provide a usage example? – Theodor Zoulias Aug 02 '19 at 14:24
  • That is the example. The task isn't the issue, it is working works just fine. The issue is that the event handler (`Element_PreviewTouchDown`) is finished executing before the task is. By the time the task is finished, it doesn't make any difference if I change the e.Handled value. In any case, all I'm doing is attaching the event to some element: `some_UI_Element.PreviewTouchDown += Element_PreviewTouchDown;` – user1969903 Aug 02 '19 at 14:58
  • I guess what I'm after is a way to delay the execution of the event without blocking the UI thread until my Task returns and then set the e.Handled value. – user1969903 Aug 02 '19 at 15:01
  • Your event handler is `async`, why? I don't see any `await` in it. – Theodor Zoulias Aug 02 '19 at 15:24
  • The await is in the 6h line: `var isTouchHold = await TouchHold` – user1969903 Aug 02 '19 at 18:39
  • Oh, indeed, now I noticed. What does the `TouchHold` method do? – Theodor Zoulias Aug 02 '19 at 18:46
  • It's a method that creates, starts and returns a `Task`, the task that contains the timer. – user1969903 Aug 02 '19 at 18:55
  • Could you include the code of this method in your question? – Theodor Zoulias Aug 02 '19 at 18:58
  • Sure. Just that I changed it a bit, made it into an extension method. Functionally, it is the same. – user1969903 Aug 02 '19 at 19:47
  • So basically you want to implement an event `TouchAndHold` that could be attached to many different UI elements, and will be configurable regarding the duration between the initial `TouchDown` and the firing of the event. Is this configuration global or per element? Would you accept different implementations, that do not attempt to improve your existing code? – Theodor Zoulias Aug 02 '19 at 21:45
  • Yes, your understanding I correct. The configuration is per element. In fact, everything is configured through attached properties. And yes, I would accept a different implementation as long as it doesn't interfere with the normal functioning of UI elements, such as right click or scrollviewer panning, etc. – user1969903 Aug 03 '19 at 05:33

2 Answers2

3

Your workflow of setting e.Handled to true and then wanting to set it back to false again strikes me as odd.

From When to Mark Events as Handled

Another way to consider the "handled" issue is that you should generally mark a routed event handled if your code responded to the routed event in a significant and relatively complete way.

Seems like either you'd be using the wrong event or it's as if the folks at Microsoft had gotten it wrong ;)

// Set handled to true to avoid clicks

Nope, they even thought of that, ref Remarks.

You can set Stylus.IsPressAndHoldEnabled="False" to disable the 'click behavior'. Allowing you to fall back to the default WPF pattern of handling the event or letting it tunnel (in this case) forward.

private static async void Element_PreviewTouchDown(object sender, TouchEventArgs e)
{
    var isTouchHold = await TouchHold((FrameworkElement)sender, variableTimespan);
    if (isTouchHold)
    {
        TouchHoldCmd?.Execute(someParam);
        e.Handled = true;
    }
}

However, as you so aptly point out in the comments:

The issue is that the event handler (Element_PreviewTouchDown) is finished executing before the task is. By the time the task is finished, it doesn't make any difference if I change the e.Handled value.

Given the ship has already sailed and you don't want to interfere with the normal functioning of UI elements, we can remove the line that marks the event as handled all together.

private static async void Element_PreviewTouchDown(object sender, TouchEventArgs e)
{
    var isTouchHold = await TouchHold((FrameworkElement)sender, variableTimespan);
    if (isTouchHold)
    {
        TouchHoldCmd?.Execute(someParam);
    }
}
Funk
  • 10,976
  • 1
  • 17
  • 33
  • I agree with you on almost everything. In fact, what I am doing seems like a hack. The problem is that I want to delay the normal events of the UI element. For example, let's assume I have a button which has a right click context menu. For this menu to appear, I have to hold the finger on the button for about a second before windows decides to show the context menu. I also want to have my touchhold function with a delay of 2 seconds. That means that, if I don't set `e.Handled = true`, the context menu pops up before my touchhold function. – user1969903 Aug 04 '19 at 07:17
  • Another example would be the scrollviewer. If I don't set `.Handled =true` and I move my finger ever so slightly, the scrollviewer starts panning. This doesn't necessarily interfere with my touchhold function, but I also don't want it to start panning unless I specifically allow it, meaning that I canceled my touchhold function. I edited my question to include an example of my intended behaviour. – user1969903 Aug 04 '19 at 07:23
  • @user1969903 As mentioned in the post, you can set `IsPressAndHoldEnabled` to disable the context menu for your buttons. As for your second comment, I don't see how that's any different from what you explain in your _how icons on a smartphone's screen work_ edit. – Funk Aug 04 '19 at 08:19
  • Ok, but how can I make the context menu re appear if I don't hold the touch for as long as the delay? – user1969903 Aug 04 '19 at 09:01
  • @user1969903 Hmm context menu on a button, are you sure you're using the right control for the job? My smartphone doesn't have those... – Funk Aug 04 '19 at 09:03
  • It was just an example. It doesn't have to be a button. – user1969903 Aug 04 '19 at 09:12
  • @user1969903 My point is you're overcomplicating things. Your problem seems to be more related to UX design than anything else. Either you provide the relocate-button-behavior on hold, or you provide the context menu. If you go for the context menu, then you can provide a relocate option in there. – Funk Aug 04 '19 at 17:53
  • [Android](https://www.youtube.com/watch?v=3kZjWcMeOrA#t=36s) seems to be handling both relocating and context menu just fine. If it's good enough for an entire mobile OS, it's good enough for me. But that is not the point, the point is that I want to figure out how to replicate this behaviour in WPF. – user1969903 Aug 06 '19 at 17:02
  • I am trying to make this as universal as I can. If I want to use this with a button, I expect it to work with a button, if I use it with an image, I want it to work with an image, if I want to use it with Slider Control, I expect it to work with a Slider Control. Perhaps I want to have a UI where the user can move any control anywhere they want. Does that mean that I must give up on context menus? If so, then yeah, Microsoft had gotten it wrong for not implementing a touch and hold event in today's touch screen abundant world. – user1969903 Aug 06 '19 at 17:08
0

I don't have a touch-enabled device, so I experimented with the MouseDown/MouseUp events. I attempted to implement a ClickAndHold event, without interfering with the DoubleClick event. What worked for me was to clone the event args of both MouseDown and MouseUp events, and raise them again using the RaiseEvent method. I also had to check explicitly the e.ClickCount property on MouseDown, and allow the event to propagate unhandled in case of e.ClickCount > 1. I have no idea if this approach will work for implementing an interference-free TouchAndHold event. In any case, here is my code:

public static IDisposable OnClickAndHold(Control control, int delay,
    MouseButtonEventHandler handler)
{
    bool handleMouseDown = true;
    bool handleOtherEvents = false;
    RoutedEventArgs eventArgsToRepeat = null;
    CancellationTokenSource cts = null;

    control.MouseDown += Control_MouseDown;
    control.MouseUp += Control_MouseUp;

    return new Disposer(() =>
    {
        control.MouseDown -= Control_MouseDown;
        control.MouseUp -= Control_MouseUp;
    });

    async void Control_MouseDown(object sender, MouseButtonEventArgs e)
    {
        if (!handleMouseDown || e.ClickCount > 1) return;
        e.Handled = true;
        var clonedArgs = CloneArgs(e);
        try
        {
            cts = new CancellationTokenSource();
            handleOtherEvents = true;
            await Task.Delay(delay, cts.Token);
            handleOtherEvents = false;
        }
        catch (TaskCanceledException)
        {
            handleOtherEvents = false;
            try
            {
                handleMouseDown = false;
                control.RaiseEvent(clonedArgs);
            }
            finally
            {
                handleMouseDown = true;
            }
            control.RaiseEvent(eventArgsToRepeat);
            return;
        }
        handler(sender, clonedArgs);
    }

    void Control_MouseUp(object sender, MouseButtonEventArgs e)
    {
        if (!handleOtherEvents) return;
        e.Handled = true;
        eventArgsToRepeat = CloneArgs(e);
        cts?.Cancel();
    }

    MouseButtonEventArgs CloneArgs(MouseButtonEventArgs e)
    {
        return new MouseButtonEventArgs(e.MouseDevice, e.Timestamp,
            e.ChangedButton)
        {
            RoutedEvent = e.RoutedEvent,
            Source = control,
        };
    }
}

private struct Disposer : IDisposable
{
    private readonly Action _action;
    public Disposer(Action action) => _action = action;
    public void Dispose() => _action();
}

Usage example:

OnClickAndHold(Label1, 1000, (s, e) =>
{
    MessageBox.Show("ClickAndHold");
});
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • Thanks for your time! It seems to work for the normal events such as click. But if I have a few buttons inside a scrollview, if I touch them and then move my finger, the scrollviewer starts scrolling (or panning, same thing if you ask me). However, with the OnClickAndHold (adapted for touch) the scroll viewer refuses to budge. I used Snoop to look at the event being fired and as far as I can tell the only difference is that when not using the hold method, the scrollviewer itself fires a ManipulationStartingEvent. This event is not fired when using the OnHold method. – user1969903 Aug 03 '19 at 17:46
  • Furthermore, you can't raise such an event since the ManipulationStartingEventArgs does not have a public constructor. I guess the scrollviewer determines somehow whether to start panning or not based on a sequence of events (touchmove, touchenter, touchdown, whatever) perhaps? I'll try and see if I can replicate the events but I think I'm on a fool's errand. – user1969903 Aug 03 '19 at 17:56