How to adjust repetition delay for WPF Command-KeyBindings.


This blog post has been followed up with another post you can find here.

In WPF, it is very easy to invoke commands with key presses by using InputBindings. Imagine a tool, which displays images and the user can move through these. You already have created the Commands PreviousCommand and NextCommand which navigate to the previous or next image, respectively. When bound to buttons, it could look like this (click to play):

Naturally, you also want your users to move through the images using the arrow keys on their keyboard. The standard way of doing so, is using WPF’s KeyBinding. For instance, we could bind our two aforementioned commands to the left and right arrows like this:

<Window.InputBindings>
        <KeyBinding Command="{Binding PreviousCommand}" Key="Left"/>
        <KeyBinding Command="{Binding NextCommand}" Key="Right"/>
</Window.InputBindings>

This works, but what happens if the user keeps the key pressed to quickly slide through the images:

/// <summary>
/// The repeat delay in milliseconds
/// </summary>
public int RepeatDelay
{
    get { return (int)GetValue(RepeatDelayProperty); }
    set { SetValue(RepeatDelayProperty, value); }
}        

static DelayKeyBinding()
{
    // register a change callback to the command property
    CommandProperty.OverrideMetadata(typeof(DelayKeyBinding), new FrameworkPropertyMetadata(CommandPropertyChangedCallback));
}

static void CommandPropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (e.NewValue != null && !(e.NewValue is DelayedWrapperCommand))
    {
        ((DelayKeyBinding)d).Command = new DelayedWrapperCommand((ICommand)e.NewValue, ((DelayKeyBinding)d).RepeatDelay);
    }
}

static void RepeatDelayPropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    if (((DelayKeyBinding)d).Command is DelayedWrapperCommand dwc)
    {
        dwc.RepeatDelay = (int)e.NewValue;
    }
}

}

class DelayedWrapperCommand : ICommand { ICommand InnerCommand { get; } int lastExecutionTick = 0; public int RepeatDelay { get; set; }

public event EventHandler CanExecuteChanged
{
    add => InnerCommand.CanExecuteChanged += value;
    remove => InnerCommand.CanExecuteChanged -= value;
}

public DelayedWrapperCommand(ICommand innerCommand, int repeatDelay)
{
    InnerCommand = innerCommand;
    RepeatDelay = repeatDelay;
}

public bool CanExecute(object parameter) => InnerCommand.CanExecute(parameter);

public void Execute(object parameter)
{
    if (Environment.TickCount - lastExecutionTick >= RepeatDelay)
    {
        InnerCommand.Execute(parameter);
        lastExecutionTick = Environment.TickCount;
    }
}

}


By using this, the KeyBinding definition stays really concise and our ViewModel stays clutter-free.

<Window.InputBindings> <local:DelayKeyBinding Command="{Binding PreviousCommand}" Key=“Left” RepeatDelay=“2000” /> <local:DelayKeyBinding Command="{Binding NextCommand}" Key=“Right” RepeatDelay=“2000” /> </Window.InputBindings>

And since the RepeatDelay property is a dependency property we can even use binding to set its value.
With this, we can achieve this much more user-friendly behavior (right arrow key is pressed the whole time during this example):
<div class="gif-player">
    <img class="gif" src="/images/post/delaykeybinding/delayed_command_by_key.png"/>
    <img class="gif-play-btn" src="/images/tools/play.png" />
</div>

<br>
image attributions:<br>
*article banner image: [www.freeimages.co.uk](http://www.freeimages.co.uk)*<br>
*images in sample app: [unsplash.com](http://www.unsplash.com)*

This blog post has been followed up with another post you can find here.

<script> function clickFunc() { gif = this; gif_btn = gif.parentElement.getElementsByClassName("gif-play-btn")[0]; if (gif.classList.contains("gif-stopped")) { gif.classList.remove("gif-stopped"); gif.classList.add("gif-playing"); gifImgPath = gif.src.substring(0, gif.src.length - 3) + "gif"; gif.setAttribute("src", gifImgPath); gif_btn.style.opacity = 0; } else { gif.classList.remove("gif-playing"); gif.classList.add("gif-stopped"); staticImgPath = gif.src.substring(0, gif.src.length - 3) + "png"; gif.setAttribute("src", staticImgPath); gif_btn.style.opacity = 1; } }; function initGifPlayer(element) { gif = element.getElementsByClassName("gif")[0]; gif.classList.add("gif-stopped"); gif.onclick = clickFunc; } gifs = document.getElementsByClassName("gif-player"); [].forEach.call(gifs, function(gif) { initGifPlayer(gif); }); </script>