diff --git a/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml
new file mode 100644
index 00000000..3de79e5e
--- /dev/null
+++ b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml.cs b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml.cs
new file mode 100644
index 00000000..e3ce6ee2
--- /dev/null
+++ b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml.cs
@@ -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);
+ }
+ }
+}
diff --git a/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml
new file mode 100644
index 00000000..d44d17f7
--- /dev/null
+++ b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml.cs b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml.cs
new file mode 100644
index 00000000..c441f529
--- /dev/null
+++ b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml.cs
@@ -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);
+ }
+}
diff --git a/components/Extensions/samples/DispatcherQueueTimerExtensions.md b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
new file mode 100644
index 00000000..d740aad8
--- /dev/null
+++ b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
@@ -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).
diff --git a/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs b/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
index 9788fbca..769139de 100644
--- a/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
+++ b/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
@@ -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;
@@ -17,10 +19,11 @@ namespace CommunityToolkit.WinUI;
///
public static class DispatcherQueueTimerExtensions
{
- private static ConcurrentDictionary _debounceInstances = new ConcurrentDictionary();
+ ///
+ private static ConditionalWeakTable _debounceInstances = new();
///
- /// 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.
+ /// 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.
/// Use this method to control the timer instead of calling Start/Interval/Stop manually.
/// A scheduled debounce can still be stopped by calling the stop method on the timer instance.
/// Each timer can only have one debounced function limited at a time.
@@ -28,14 +31,14 @@ public static class DispatcherQueueTimerExtensions
/// Timer instance, only one debounced function can be used per timer.
/// Action to execute at the end of the interval.
/// Interval to wait before executing the action.
- /// Determines if the action execute on the leading edge instead of trailing edge.
+ /// 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.
///
///
/// 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));
///
///
@@ -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)
{
@@ -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.
@@ -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();
}
}
diff --git a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
index 036513a3..dddd9a4a 100644
--- a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
+++ b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
@@ -4,7 +4,6 @@
using CommunityToolkit.Tests;
using CommunityToolkit.Tooling.TestGen;
-using CommunityToolkit.WinUI;
#if WINAPPSDK
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
@@ -24,10 +23,17 @@ public partial class DispatcherQueueTimerExtensionTests : VisualUITestBase
{
[TestCategory("DispatcherQueueTimerExtensions")]
[UIThreadTestMethod]
- public async Task DispatcherQueueTimer_Debounce_Interrupt()
+ public async Task DispatcherQueueTimer_Debounce_Trailing()
{
var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+ // Test custom event handler too
+ var customTriggeredCount = 0;
+ debounceTimer.Tick += (s, o) =>
+ {
+ customTriggeredCount++;
+ };
+
var triggeredCount = 0;
string? triggeredValue = null;
@@ -42,6 +48,94 @@ public async Task DispatcherQueueTimer_Debounce_Interrupt()
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
Assert.AreEqual(0, triggeredCount, "Function shouldn't have run yet.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function shouldn't have run yet.");
+ Assert.IsNull(triggeredValue, "Function shouldn't have run yet.");
+
+ await Task.Delay(TimeSpan.FromMilliseconds(80));
+
+ Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");
+ Assert.AreEqual(value, triggeredValue, "Expected result to be set.");
+ Assert.AreEqual(1, triggeredCount, "Expected to run once.");
+ Assert.AreEqual(1, customTriggeredCount, "Custom Function should have run once.");
+ }
+
+ [TestCategory("DispatcherQueueTimerExtensions")]
+ [UIThreadTestMethod]
+ public async Task DispatcherQueueTimer_Debounce_Trailing_Stop()
+ {
+ var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+
+ // Test custom event handler too
+ var customTriggeredCount = 0;
+ debounceTimer.Tick += (s, o) =>
+ {
+ customTriggeredCount++;
+ };
+
+ var triggeredCount = 0;
+ string? triggeredValue = null;
+
+ var value = "He";
+ debounceTimer.Debounce(
+ () =>
+ {
+ triggeredCount++;
+ triggeredValue = value;
+ },
+ TimeSpan.FromMilliseconds(60));
+
+ Assert.AreEqual(true, debounceTimer.IsRunning, "Expected timer to be running.");
+ Assert.AreEqual(0, triggeredCount, "Function shouldn't have run yet.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function shouldn't have run yet.");
+ Assert.IsNull(triggeredValue, "Function shouldn't have run yet.");
+
+ await Task.Delay(TimeSpan.FromMilliseconds(20));
+
+ // Stop the timer before it would fire.
+ debounceTimer.Stop();
+
+ Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");
+ Assert.IsNull(triggeredValue, "Expected result should be no value set.");
+ Assert.AreEqual(0, triggeredCount, "Expected not to have code run.");
+ Assert.AreEqual(0, customTriggeredCount, "Expected not to have custom code run.");
+
+ // Wait until timer would have fired
+ await Task.Delay(TimeSpan.FromMilliseconds(60));
+
+ Assert.AreEqual(false, debounceTimer.IsRunning, "Expected the timer to remain stopped.");
+ Assert.IsNull(triggeredValue, "Expected result should still be no value set.");
+ Assert.AreEqual(0, triggeredCount, "Expected not to have code run still.");
+ Assert.AreEqual(0, customTriggeredCount, "Expected not to have custom code run still.");
+ }
+
+ [TestCategory("DispatcherQueueTimerExtensions")]
+ [UIThreadTestMethod]
+ public async Task DispatcherQueueTimer_Debounce_Trailing_Interrupt()
+ {
+ var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+
+ // Test custom event handler too
+ var customTriggeredCount = 0;
+ debounceTimer.Tick += (s, o) =>
+ {
+ customTriggeredCount++;
+ };
+
+ var triggeredCount = 0;
+ string? triggeredValue = null;
+
+ var value = "He";
+ debounceTimer.Debounce(
+ () =>
+ {
+ triggeredCount++;
+ triggeredValue = value;
+ },
+ TimeSpan.FromMilliseconds(60));
+
+ Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
+ Assert.AreEqual(0, triggeredCount, "Function shouldn't have run yet.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function shouldn't have run yet.");
Assert.IsNull(triggeredValue, "Function shouldn't have run yet.");
var value2 = "Hello";
@@ -54,20 +148,72 @@ public async Task DispatcherQueueTimer_Debounce_Interrupt()
TimeSpan.FromMilliseconds(60));
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
+ Assert.AreEqual(0, triggeredCount, "Function shouldn't have run yet.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function shouldn't have run yet.");
+ Assert.IsNull(triggeredValue, "Function shouldn't have run yet.");
await Task.Delay(TimeSpan.FromMilliseconds(110));
Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");
Assert.AreEqual(value2, triggeredValue, "Expected to execute the last action.");
Assert.AreEqual(1, triggeredCount, "Expected to postpone execution.");
+ Assert.AreEqual(1, customTriggeredCount, "Expected to postpone execution of custom event handler.");
}
+ [TestCategory("DispatcherQueueTimerExtensions")]
+ [UIThreadTestMethod]
+ public async Task DispatcherQueueTimer_Debounce_Immediate()
+ {
+ var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+
+ // Test custom event handler too
+ var customTriggeredCount = 0;
+ debounceTimer.Tick += (s, o) =>
+ {
+ customTriggeredCount++;
+ };
+
+ var triggeredCount = 0;
+ string? triggeredValue = null;
+
+ var value = "He";
+ debounceTimer.Debounce(
+ () =>
+ {
+ triggeredCount++;
+ triggeredValue = value;
+ },
+ TimeSpan.FromMilliseconds(60), true);
+
+ Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
+ Assert.AreEqual(1, triggeredCount, "Function should have run right away.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function won't have run as cooldown hasn't elapsed.");
+ Assert.AreEqual(value, triggeredValue, "Should have expected immediate set of value");
+
+ await Task.Delay(TimeSpan.FromMilliseconds(80));
+
+ Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");
+ Assert.AreEqual(1, customTriggeredCount, "Custom Function should have run now that cooldown expired.");
+ }
+
+ ///
+ /// Tests the immediate mode of the Debounce function ignoring subsequent inputs that come after the first within the specified time window.
+ ///
+ /// For instance, this could be useful to ignore extra multiple clicks on a button, but immediately start processing upon the first click.
+ ///
[TestCategory("DispatcherQueueTimerExtensions")]
[UIThreadTestMethod]
public async Task DispatcherQueueTimer_Debounce_Immediate_Interrupt()
{
var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+ // Test custom event handler too
+ var customTriggeredCount = 0;
+ debounceTimer.Tick += (s, o) =>
+ {
+ customTriggeredCount++;
+ };
+
var triggeredCount = 0;
string? triggeredValue = null;
@@ -82,6 +228,7 @@ public async Task DispatcherQueueTimer_Debounce_Immediate_Interrupt()
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
Assert.AreEqual(1, triggeredCount, "Function should have run right away.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function should not have run as cooldown hasn't expired.");
Assert.AreEqual(value, triggeredValue, "Should have expected immediate set of value");
var value2 = "Hello";
@@ -91,14 +238,210 @@ public async Task DispatcherQueueTimer_Debounce_Immediate_Interrupt()
triggeredCount++;
triggeredValue = value2;
},
- TimeSpan.FromMilliseconds(60));
+ TimeSpan.FromMilliseconds(60), true); // Ensure we're interrupting with immediate again
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
+ Assert.AreEqual(1, triggeredCount, "2nd request coming within first period should have been ignored.");
+ Assert.AreEqual(0, customTriggeredCount, "Cooldown should be reset, so we still shouldn't have fired Tick.");
+ Assert.AreEqual(value, triggeredValue, "Value shouldn't have changed from 2nd request within time bound.");
+ // Wait for cooldown to expire
await Task.Delay(TimeSpan.FromMilliseconds(110));
Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");
- Assert.AreEqual(value2, triggeredValue, "Expected to execute the last action.");
- Assert.AreEqual(2, triggeredCount, "Expected to postpone execution.");
+ Assert.AreEqual(value, triggeredValue, "Expected to execute only the first action.");
+ Assert.AreEqual(1, triggeredCount, "Expected 2nd request to be ignored.");
+ Assert.AreEqual(1, customTriggeredCount, "Custom should have run now that cooldown expired.");
+ }
+
+ ///
+ /// Tests the scenario where we flip from wanting trailing to leading edge invocation.
+ ///
+ /// For instance, this could be for a case where a user has cleared the textbox, so you
+ /// want to immediately return new results vs. waiting for further input.
+ ///
+ [TestCategory("DispatcherQueueTimerExtensions")]
+ [UIThreadTestMethod]
+ public async Task DispatcherQueueTimer_Debounce_Trailing_Switch_Leading_Interrupt()
+ {
+ var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+
+ // Test custom event handler too
+ var customTriggeredCount = 0;
+ debounceTimer.Tick += (s, o) =>
+ {
+ customTriggeredCount++;
+ };
+
+ var triggeredCount = 0;
+ string? triggeredValue = null;
+
+ var value = "Hello";
+ debounceTimer.Debounce(
+ () =>
+ {
+ triggeredCount++;
+ triggeredValue = value;
+ },
+ TimeSpan.FromMilliseconds(100), false); // Start off waiting
+
+ // Intentional pause to mimic reality
+ await Task.Delay(TimeSpan.FromMilliseconds(30));
+
+ Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
+ Assert.AreEqual(0, triggeredCount, "Function shouldn't have run yet.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function shouldn't have run yet.");
+ Assert.IsNull(triggeredValue, "Function shouldn't have run yet.");
+
+ // Now interrupt with a scenario we want processed immediately, i.e. user started typing something new
+ var value2 = "He";
+ debounceTimer.Debounce(
+ () =>
+ {
+ triggeredCount++;
+ triggeredValue = value2;
+ },
+ TimeSpan.FromMilliseconds(100), true);
+
+ Assert.AreEqual(true, debounceTimer.IsRunning, "Expected timer should still be running.");
+ Assert.AreEqual(1, triggeredCount, "Function should now have run immediately.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function still shouldn't have run yet.");
+ Assert.AreEqual(value2, triggeredValue, "Function should have set value to 'He'");
+
+ // Wait to where all should be done
+ await Task.Delay(TimeSpan.FromMilliseconds(120));
+
+ Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");
+ Assert.AreEqual(value2, triggeredValue, "Expected value to remain the same.");
+ Assert.AreEqual(1, triggeredCount, "Expected to interrupt execution and ignore initial queued execution.");
+ Assert.AreEqual(1, customTriggeredCount, "Custom function should have run once at end of leading cooldown.");
+ }
+
+ ///
+ /// Tests where we start with immediately processing a delay, then switch to processing after.
+ ///
+ /// For instance, maybe we want to ensure we start processing the first letter of a search query to filter initial results. Then later, we might want to delay and wait to execute until all the query string is available.
+ ///
+ [TestCategory("DispatcherQueueTimerExtensions")]
+ [UIThreadTestMethod]
+ public async Task DispatcherQueueTimer_Debounce_Leading_Switch_Trailing_Interrupt_Twice()
+ {
+ var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+
+ // Test custom event handler too
+ var customTriggeredCount = 0;
+ debounceTimer.Tick += (s, o) =>
+ {
+ customTriggeredCount++;
+ };
+
+ var triggeredCount = 0;
+ string? triggeredValue = null;
+
+ var value = "H";
+ debounceTimer.Debounce(
+ () =>
+ {
+ triggeredCount++;
+ triggeredValue = value;
+ },
+ TimeSpan.FromMilliseconds(100), true); // Start off right away
+
+ Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
+ Assert.AreEqual(1, triggeredCount, "Function should have run right away.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function should not run right away on leading.");
+ Assert.AreEqual(value, triggeredValue, "Function should have set value immediately.");
+
+ // Pragmatic pause
+ await Task.Delay(TimeSpan.FromMilliseconds(30));
+
+ // Now interrupt with more data two times.
+ var value2 = "Hel";
+ debounceTimer.Debounce(
+ () =>
+ {
+ triggeredCount++;
+ triggeredValue = value2;
+ },
+ TimeSpan.FromMilliseconds(100), false); // We want to ensure we catch the latest data now
+
+ Assert.AreEqual(true, debounceTimer.IsRunning, "Expected timer to still to be running.");
+ Assert.AreEqual(1, triggeredCount, "Function should now haven't run again yet.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function should not run right away on switch to trailing either.");
+ Assert.AreEqual(value, triggeredValue, "Function should still be the initial value");
+
+ // Pragmatic pause again
+ await Task.Delay(TimeSpan.FromMilliseconds(30));
+
+ var value3 = "Hello";
+ debounceTimer.Debounce(
+ () =>
+ {
+ triggeredCount++;
+ triggeredValue = value3;
+ },
+ TimeSpan.FromMilliseconds(100), false); // We want to ensure we catch the latest data now
+
+ Assert.AreEqual(true, debounceTimer.IsRunning, "Expected timer to still to be running.");
+ Assert.AreEqual(1, triggeredCount, "Function should still now haven't run again yet.");
+ Assert.AreEqual(0, customTriggeredCount, "Custom Function should not run yet, as not enough time passed.");
+ Assert.AreEqual(value, triggeredValue, "Function should still be the initial value x2");
+
+ // Wait to where the timer should have fired and is done
+ await Task.Delay(TimeSpan.FromMilliseconds(120));
+
+ Assert.AreEqual(false, debounceTimer.IsRunning, "Expected timer to stopped at trailing edge to execute latest result.");
+ Assert.AreEqual(value3, triggeredValue, "Expected value to now be the last value provided.");
+ Assert.AreEqual(2, triggeredCount, "Expected to interrupt execution of 2nd request.");
+ Assert.AreEqual(1, customTriggeredCount, "Custom Function should have run once at end of trailing debounce.");
+ }
+
+ [TestCategory("DispatcherQueueTimerExtensions")]
+ [UIThreadTestMethod]
+ public async Task DispatcherQueueTimer_Debounce_Trailing_Stop_Lifetime()
+ {
+ // Our test indicator
+ WeakReference? reference = null;
+
+ // Still need to capture this on our UI thread
+ DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
+
+ await Task.Run(() =>
+ {
+ // This test checks the lifetime of the timer and if we hold a reference to it.
+ var debounceTimer = _queue.CreateTimer();
+
+ // Track the DispatcherQueueTimer object
+ reference = new WeakReference(debounceTimer, true);
+
+ var triggeredCount = 0;
+ string? triggeredValue = null;
+
+ var value = "He";
+ debounceTimer.Debounce(
+ () =>
+ {
+ triggeredCount++;
+ triggeredValue = value;
+ },
+ TimeSpan.FromMilliseconds(60));
+
+ // Stop the timer before it would fire, with our proper method to clean-up
+ debounceTimer.Stop();
+
+ Assert.AreEqual(false, debounceTimer.IsRunning, "Expected to stop the timer.");
+
+ debounceTimer = null;
+ });
+
+ // Now out of scope and see if GC cleans up
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+
+ // Clean-up any UI thread work
+ await CompositionTargetHelper.ExecuteAfterCompositionRenderingAsync(() => { });
+
+ Assert.IsNotNull(reference, "Didn't capture weak reference.");
+ Assert.IsNull(reference.Target, "Strong reference to DispatcherQueueTimer still exists.");
}
}