Skip to content

Commit fd29ae4

Browse files
Add some DispatcherQueueTimer docs, initial sample, and more unit tests
TODO: Still have the opposite scenario of Trailing to leading which is broken to fix.
1 parent 9856d37 commit fd29ae4

File tree

5 files changed

+174
-6
lines changed

5 files changed

+174
-6
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Page
2+
x:Class="ExtensionsExperiment.Samples.DispatcherQueueExtensions.KeyboardDebounceSample"
3+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
4+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
5+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
6+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
7+
mc:Ignorable="d"
8+
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
9+
10+
<StackPanel Spacing="8">
11+
<TextBox PlaceholderText="Type here..." TextChanged="TextBox_TextChanged"/>
12+
<TextBlock x:Name="ResultText"/>
13+
</StackPanel>
14+
</Page>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.WinUI;
6+
#if WINAPPSDK
7+
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
8+
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
9+
#else
10+
using DispatcherQueue = Windows.System.DispatcherQueue;
11+
using DispatcherQueueTimer = Windows.System.DispatcherQueueTimer;
12+
#endif
13+
14+
namespace ExtensionsExperiment.Samples.DispatcherQueueExtensions;
15+
16+
[ToolkitSample(id: nameof(KeyboardDebounceSample), "DispatcherQueueTimer Debounce Keyboard", description: "A sample for showing how to use the DispatcherQueueTimer Debounce extension to smooth keyboard input.")]
17+
[ToolkitSampleNumericOption("Interval", 120, 60, 240)]
18+
public sealed partial class KeyboardDebounceSample : Page
19+
{
20+
public DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
21+
22+
public KeyboardDebounceSample()
23+
{
24+
InitializeComponent();
25+
}
26+
27+
private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
28+
{
29+
var interval = this.GeneratedPropertyMetadata?.FirstOrDefault(vm => vm.Name == "Interval")?.Value as double?;
30+
31+
if (sender is TextBox textBox && interval != null)
32+
{
33+
_debounceTimer.Debounce(() =>
34+
{
35+
ResultText.Text = textBox.Text;
36+
},
37+
//// i.e. if another keyboard press comes in within 120ms of the last, we'll wait before we fire off the request
38+
interval: TimeSpan.FromMilliseconds(interval.Value),
39+
//// If we're blanking out or the first character type, we'll start filtering immediately instead to appear more responsive.
40+
//// We want to switch back to trailing as the user types more so that we still capture all the input.
41+
immediate: textBox.Text.Length <= 1);
42+
43+
}
44+
}
45+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
title: DispatcherQueueTimerExtensions
3+
author: michael-hawker
4+
description: Helpers for executing code at specific times on a UI thread through a DispatcherQueue instance with a DispatcherQueueTimer.
5+
keywords: dispatcher, dispatcherqueue, DispatcherHelper, DispatcherQueueExtensions, DispatcherQueueTimer, DispatcherQueueTimerExtensions
6+
dev_langs:
7+
- csharp
8+
category: Extensions
9+
subcategory: Miscellaneous
10+
discussion-id: 0
11+
issue-id: 0
12+
icon: Assets/Extensions.png
13+
---
14+
15+
The `DispatcherQueueTimerExtensions` static class provides a collection of extensions methods 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.
16+
17+
## Syntax
18+
19+
The `DispatcherQueueTimerExtensions` static class currently exposes 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 of query elsewhere.
20+
21+
It can be used in a number of ways, but most simply like so:
22+
23+
> [!SAMPLE KeyboardDebounceSample]
24+
25+
## Examples
26+
27+
You can find more examples in the [unit tests](https://github.com/CommunityToolkit/Windows/blob/rel/8.1.240916/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs).

components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,22 @@ public static class DispatcherQueueTimerExtensions
2020
private static ConcurrentDictionary<DispatcherQueueTimer, Action> _debounceInstances = new ConcurrentDictionary<DispatcherQueueTimer, Action>();
2121

2222
/// <summary>
23-
/// <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>
23+
/// <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>
2424
/// <para>Use this method to control the timer instead of calling Start/Interval/Stop manually.</para>
2525
/// <para>A scheduled debounce can still be stopped by calling the stop method on the timer instance.</para>
2626
/// <para>Each timer can only have one debounced function limited at a time.</para>
2727
/// </summary>
2828
/// <param name="timer">Timer instance, only one debounced function can be used per timer.</param>
2929
/// <param name="action">Action to execute at the end of the interval.</param>
3030
/// <param name="interval">Interval to wait before executing the action.</param>
31-
/// <param name="immediate">Determines if the action execute on the leading edge instead of trailing edge.</param>
31+
/// <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>
3232
/// <example>
3333
/// <code>
3434
/// private DispatcherQueueTimer _typeTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
3535
///
3636
/// _typeTimer.Debounce(async () =>
3737
/// {
38-
/// // Only executes this code after 0.3 seconds have elapsed since last trigger.
38+
/// // Only executes code put here after 0.3 seconds have elapsed since last call to Debounce.
3939
/// }, TimeSpan.FromSeconds(0.3));
4040
/// </code>
4141
/// </example>

components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Interrupt()
8383
TimeSpan.FromMilliseconds(60));
8484

8585
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
86+
Assert.AreEqual(0, triggeredCount, "Function shouldn't have run yet.");
87+
Assert.IsNull(triggeredValue, "Function shouldn't have run yet.");
8688

8789
await Task.Delay(TimeSpan.FromMilliseconds(110));
8890

@@ -112,8 +114,17 @@ public async Task DispatcherQueueTimer_Debounce_Immediate()
112114
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
113115
Assert.AreEqual(1, triggeredCount, "Function should have run right away.");
114116
Assert.AreEqual(value, triggeredValue, "Should have expected immediate set of value");
117+
118+
await Task.Delay(TimeSpan.FromMilliseconds(80));
119+
120+
Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");
115121
}
116122

123+
/// <summary>
124+
/// Tests the immediate mode of the Debounce function ignoring subsequent inputs that come after the first within the specified time window.
125+
/// For instance, this could be useful to ignore extra multiple clicks on a button, but immediately start processing upon the first click.
126+
/// </summary>
127+
/// <returns></returns>
117128
[TestCategory("DispatcherQueueTimerExtensions")]
118129
[UIThreadTestMethod]
119130
public async Task DispatcherQueueTimer_Debounce_Immediate_Interrupt()
@@ -143,14 +154,85 @@ public async Task DispatcherQueueTimer_Debounce_Immediate_Interrupt()
143154
triggeredCount++;
144155
triggeredValue = value2;
145156
},
146-
TimeSpan.FromMilliseconds(60));
157+
TimeSpan.FromMilliseconds(60), true); // Ensure we're interrupting with immediate again
147158

148159
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
160+
Assert.AreEqual(1, triggeredCount, "2nd request coming within first period should have been ignored.");
161+
Assert.AreEqual(value, triggeredValue, "Value shouldn't have changed from 2nd request within time bound.");
149162

150163
await Task.Delay(TimeSpan.FromMilliseconds(110));
151164

152165
Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");
153-
Assert.AreEqual(value2, triggeredValue, "Expected to execute the last action.");
154-
Assert.AreEqual(2, triggeredCount, "Expected to postpone execution.");
166+
Assert.AreEqual(value, triggeredValue, "Expected to execute only the first action.");
167+
Assert.AreEqual(1, triggeredCount, "Expected 2nd request to be ignored.");
168+
}
169+
170+
/// <summary>
171+
/// Tests where we start with immediately processing a delay, but then want to switch to processing after.
172+
/// For instance, maybe we want to ensure we start processing the first letter of a search query to filter initial results.
173+
/// But then later want to delay and wait to execute until all the query string is available.
174+
/// </summary>
175+
/// <returns></returns>
176+
[TestCategory("DispatcherQueueTimerExtensions")]
177+
[UIThreadTestMethod]
178+
public async Task DispatcherQueueTimer_Debounce_Leading_Switch_Trailing_Interrupt_Twice()
179+
{
180+
var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
181+
182+
var triggeredCount = 0;
183+
string? triggeredValue = null;
184+
185+
var value = "H";
186+
debounceTimer.Debounce(
187+
() =>
188+
{
189+
triggeredCount++;
190+
triggeredValue = value;
191+
},
192+
TimeSpan.FromMilliseconds(100), true); // Start off right away
193+
194+
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
195+
Assert.AreEqual(1, triggeredCount, "Function should have run right away.");
196+
Assert.AreEqual(value, triggeredValue, "Function should have set value immediately.");
197+
198+
// Pragmatic pause
199+
await Task.Delay(TimeSpan.FromMilliseconds(30));
200+
201+
// Now interrupt with more data two times.
202+
var value2 = "Hel";
203+
debounceTimer.Debounce(
204+
() =>
205+
{
206+
triggeredCount++;
207+
triggeredValue = value2;
208+
},
209+
TimeSpan.FromMilliseconds(100), false); // We want to ensure we catch the latest data now
210+
211+
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected timer to still to be running.");
212+
Assert.AreEqual(1, triggeredCount, "Function should now haven't run again yet.");
213+
Assert.AreEqual(value, triggeredValue, "Function should still be the initial value");
214+
215+
// Pragmatic pause again
216+
await Task.Delay(TimeSpan.FromMilliseconds(30));
217+
218+
var value3 = "Hello";
219+
debounceTimer.Debounce(
220+
() =>
221+
{
222+
triggeredCount++;
223+
triggeredValue = value3;
224+
},
225+
TimeSpan.FromMilliseconds(100), false); // We want to ensure we catch the latest data now
226+
227+
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected timer to still to be running.");
228+
Assert.AreEqual(1, triggeredCount, "Function should still now haven't run again yet.");
229+
Assert.AreEqual(value, triggeredValue, "Function should still be the initial value x2");
230+
231+
// Wait to where the timer should have fired and is done
232+
await Task.Delay(TimeSpan.FromMilliseconds(120));
233+
234+
Assert.AreEqual(false, debounceTimer.IsRunning, "Expected timer to stopped at trailing edge to execute latest result.");
235+
Assert.AreEqual(value3, triggeredValue, "Expected value to now be the last value provided.");
236+
Assert.AreEqual(2, triggeredCount, "Expected to interrupt execution of 2nd request.");
155237
}
156238
}

0 commit comments

Comments
 (0)