Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements to DispatcherQueueTimer.Debounce extension #569

Merged
merged 11 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Page x:Class="ExtensionsExperiment.Samples.DispatcherQueueExtensions.KeyboardDebounceSample"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
mc:Ignorable="d">

<StackPanel Spacing="8">
<TextBox PlaceholderText="Type here..."
TextChanged="TextBox_TextChanged" />
<TextBlock x:Name="ResultText" />
</StackPanel>
</Page>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using CommunityToolkit.WinUI;
#if WINAPPSDK
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
#else
using DispatcherQueue = Windows.System.DispatcherQueue;
using DispatcherQueueTimer = Windows.System.DispatcherQueueTimer;
#endif

namespace ExtensionsExperiment.Samples.DispatcherQueueExtensions;

[ToolkitSample(id: nameof(KeyboardDebounceSample), "DispatcherQueueTimer Debounce Keyboard", description: "A sample for showing how to use the DispatcherQueueTimer Debounce extension to smooth keyboard input.")]
[ToolkitSampleNumericOption("Interval", 120, 60, 240)]
public sealed partial class KeyboardDebounceSample : Page
{
public DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();

public KeyboardDebounceSample()
{
InitializeComponent();
}

private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox textBox)
{
_debounceTimer.Debounce(() =>
{
ResultText.Text = textBox.Text;
},
//// i.e. if another keyboard press comes in within 120ms of the last, we'll wait before we fire off the request
interval: TimeSpan.FromMilliseconds(Interval),
//// If we're blanking out or the first character type, we'll start filtering immediately instead to appear more responsive.
//// We want to switch back to trailing as the user types more so that we still capture all the input.
immediate: textBox.Text.Length <= 1);
}
}
}
14 changes: 14 additions & 0 deletions components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Page x:Class="ExtensionsExperiment.Samples.DispatcherQueueExtensions.MouseDebounceSample"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
mc:Ignorable="d">

<StackPanel Spacing="8">
<Button Click="Button_Click"
Content="Click Me" />
<TextBlock x:Name="ResultText" />
</StackPanel>
</Page>
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using CommunityToolkit.WinUI;
#if WINAPPSDK
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
#else
using DispatcherQueue = Windows.System.DispatcherQueue;
using DispatcherQueueTimer = Windows.System.DispatcherQueueTimer;
#endif

namespace ExtensionsExperiment.Samples.DispatcherQueueExtensions;

[ToolkitSample(id: nameof(MouseDebounceSample), "DispatcherQueueTimer Debounce Mouse", description: "A sample for showing how to use the DispatcherQueueTimer Debounce extension to smooth mouse input.")]
[ToolkitSampleNumericOption("Interval", 400, 300, 1000)]
public sealed partial class MouseDebounceSample : Page
{
public DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();

private int _count = 0;

public MouseDebounceSample()
{
InitializeComponent();
}

private void Button_Click(object sender, RoutedEventArgs e)
{
_debounceTimer.Debounce(() =>
{
ResultText.Text = $"You hit the button {++_count} times!";
},
interval: TimeSpan.FromMilliseconds(Interval),
// By being on the leading edge, we ignore inputs past the first for the duration of the interval
immediate: true);
}
}
42 changes: 42 additions & 0 deletions components/Extensions/samples/DispatcherQueueTimerExtensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
title: DispatcherQueueTimerExtensions
author: michael-hawker
description: Helpers for executing code at specific times on a UI thread through a DispatcherQueue instance with a DispatcherQueueTimer.
keywords: dispatcher, dispatcherqueue, DispatcherHelper, DispatcherQueueExtensions, DispatcherQueueTimer, DispatcherQueueTimerExtensions
dev_langs:
- csharp
category: Extensions
subcategory: Miscellaneous
discussion-id: 0
issue-id: 0
icon: Assets/Extensions.png
---

The `DispatcherQueueTimerExtensions` static class provides an extension method for [`DispatcherQueueTimer`](https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.dispatching.dispatcherqueue) objects that make it easier to execute code on a specific UI thread at a specific time.

The `DispatcherQueueTimerExtensions` provides a single extension method, `Debounce`. This is a standard technique used to rate-limit input from a user to not overload requests on an underlying service or query elsewhere.

> [!WARNING]
> You should exclusively use the `DispatcherQueueTimer` instance calling `Debounce` for the purposes of Debouncing one specific action/scenario only and not configure it for other additional uses.

For each scenario that you want to Debounce, you'll want to create a separate `DispatcherQueueTimer` instance to track that specific scenario. For instance, if the below samples were both within your application. You'd need two separate timers to track debouncing both scenarios. One for the keyboard input, and a different one for the mouse input.

> [!NOTE]
> Using the `Debounce` method will set `DispatcherQueueTimer.IsRepeating` to `false` to ensure proper operation. Do not change this value.

> [!NOTE]
> If additionally registering to the `DispatcherQueueTimer.Tick` event (uncommon), it will be raised in one of two ways: 1. For a trailing debounce, it will be raised alongside the requested Action passed to the Debounce method. 2. For a leading debounce, it will be raised when the cooldown has expired and another call to Debounce would result in running the action.

## Syntax

It can be used in a number of ways, but most simply like so as a keyboard limiter:

> [!SAMPLE KeyboardDebounceSample]

Or for preventing multiple inputs from occuring accidentally (e.g. ignoring a double/multi-click):

> [!SAMPLE MouseDebounceSample]

## Examples

You can find more examples in the [unit tests](https://github.com/CommunityToolkit/Windows/blob/rel/8.1.240916/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs).
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// See the LICENSE file in the project root for more information.

using System.Collections.Concurrent;
using System.Runtime.CompilerServices;


#if WINAPPSDK
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
Expand All @@ -17,25 +19,26 @@ namespace CommunityToolkit.WinUI;
/// </summary>
public static class DispatcherQueueTimerExtensions
{
private static ConcurrentDictionary<DispatcherQueueTimer, Action> _debounceInstances = new ConcurrentDictionary<DispatcherQueueTimer, Action>();
/// <inheritdoc cref="System.Runtime.CompilerServices.ConditionalWeakTable{TKey,TValue}" />
private static ConditionalWeakTable<DispatcherQueueTimer, Action> _debounceInstances = new();

/// <summary>
/// <para>Used to debounce (rate-limit) an event. The action will be postponed and executed after the interval has elapsed. At the end of the interval, the function will be called with the arguments that were passed most recently to the debounced function.</para>
/// <para>Used to debounce (rate-limit) an event. The action will be postponed and executed after the interval has elapsed. At the end of the interval, the function will be called with the arguments that were passed most recently to the debounced function. Useful for smoothing keyboard input, for instance.</para>
/// <para>Use this method to control the timer instead of calling Start/Interval/Stop manually.</para>
/// <para>A scheduled debounce can still be stopped by calling the stop method on the timer instance.</para>
/// <para>Each timer can only have one debounced function limited at a time.</para>
/// </summary>
/// <param name="timer">Timer instance, only one debounced function can be used per timer.</param>
/// <param name="action">Action to execute at the end of the interval.</param>
/// <param name="interval">Interval to wait before executing the action.</param>
/// <param name="immediate">Determines if the action execute on the leading edge instead of trailing edge.</param>
/// <param name="immediate">Determines if the action execute on the leading edge instead of trailing edge of the interval. Subsequent input will be ignored into the interval has completed. Useful for ignore extraneous extra input like multiple mouse clicks.</param>
/// <example>
/// <code>
/// private DispatcherQueueTimer _typeTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
///
/// _typeTimer.Debounce(async () =>
/// {
/// // Only executes this code after 0.3 seconds have elapsed since last trigger.
/// // Only executes code put here after 0.3 seconds have elapsed since last call to Debounce.
/// }, TimeSpan.FromSeconds(0.3));
/// </code>
/// </example>
Expand All @@ -52,8 +55,20 @@ public static void Debounce(this DispatcherQueueTimer timer, Action action, Time
timer.Tick -= Timer_Tick;
timer.Interval = interval;

// Ensure we haven't been misconfigured and won't execute more times than we expect.
timer.IsRepeating = false;

if (immediate)
{
// If we have a _debounceInstance queued, then we were running in trailing mode,
// so if we now have the immediate flag, we should ignore this timer, and run immediately.
if (_debounceInstances.TryGetValue(timer, out var _))
{
timeout = false;

_debounceInstances.Remove(timer);
}

// If we're in immediate mode then we only execute if the timer wasn't running beforehand
if (!timeout)
{
Expand All @@ -66,7 +81,7 @@ public static void Debounce(this DispatcherQueueTimer timer, Action action, Time
timer.Tick += Timer_Tick;

// Store/Update function
_debounceInstances.AddOrUpdate(timer, action, (k, v) => action);
_debounceInstances.AddOrUpdate(timer, action);
}

// Start the timer to keep track of the last call here.
Expand All @@ -81,8 +96,9 @@ private static void Timer_Tick(object sender, object e)
timer.Tick -= Timer_Tick;
timer.Stop();

if (_debounceInstances.TryRemove(timer, out Action? action))
if (_debounceInstances.TryGetValue(timer, out Action? action))
{
_debounceInstances.Remove(timer);
action?.Invoke();
}
}
Expand Down
Loading
Loading