From 57134f2454df30096387043bf36804fdc50e4173 Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Fri, 22 Nov 2024 13:01:41 -0800
Subject: [PATCH 01/11] Add baseline single scenario tests for
DispatcherQueueTimer Debounce
---
.../DispatcherQueueTimerExtensionTests.cs | 54 ++++++++++++++++++-
1 file changed, 53 insertions(+), 1 deletion(-)
diff --git a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
index 036513a3..9eea7881 100644
--- a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
+++ b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
@@ -24,7 +24,36 @@ 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();
+
+ 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.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.");
+ }
+
+ [TestCategory("DispatcherQueueTimerExtensions")]
+ [UIThreadTestMethod]
+ public async Task DispatcherQueueTimer_Debounce_Trailing_Interrupt()
{
var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
@@ -62,6 +91,29 @@ public async Task DispatcherQueueTimer_Debounce_Interrupt()
Assert.AreEqual(1, triggeredCount, "Expected to postpone execution.");
}
+ [TestCategory("DispatcherQueueTimerExtensions")]
+ [UIThreadTestMethod]
+ public async Task DispatcherQueueTimer_Debounce_Immediate()
+ {
+ var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+
+ 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(value, triggeredValue, "Should have expected immediate set of value");
+ }
+
[TestCategory("DispatcherQueueTimerExtensions")]
[UIThreadTestMethod]
public async Task DispatcherQueueTimer_Debounce_Immediate_Interrupt()
From 8632ff8a3c850d9cd0ee7f715bf5bde3ef8af803 Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Fri, 22 Nov 2024 18:49:22 -0800
Subject: [PATCH 02/11] 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.
---
.../Dispatcher/KeyboardDebounceSample.xaml | 14 +++
.../Dispatcher/KeyboardDebounceSample.xaml.cs | 45 ++++++++++
.../samples/DispatcherQueueTimerExtensions.md | 27 ++++++
.../DispatcherQueueTimerExtensions.cs | 6 +-
.../DispatcherQueueTimerExtensionTests.cs | 88 ++++++++++++++++++-
5 files changed, 174 insertions(+), 6 deletions(-)
create mode 100644 components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml
create mode 100644 components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml.cs
create mode 100644 components/Extensions/samples/DispatcherQueueTimerExtensions.md
diff --git a/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml
new file mode 100644
index 00000000..48898cfa
--- /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..fe466f02
--- /dev/null
+++ b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml.cs
@@ -0,0 +1,45 @@
+// 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)
+ {
+ var interval = this.GeneratedPropertyMetadata?.FirstOrDefault(vm => vm.Name == "Interval")?.Value as double?;
+
+ if (sender is TextBox textBox && interval != null)
+ {
+ _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.Value),
+ //// 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/DispatcherQueueTimerExtensions.md b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
new file mode 100644
index 00000000..01eda8c2
--- /dev/null
+++ b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
@@ -0,0 +1,27 @@
+---
+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 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.
+
+## Syntax
+
+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.
+
+It can be used in a number of ways, but most simply like so:
+
+> [!SAMPLE KeyboardDebounceSample]
+
+## 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..650c5a63 100644
--- a/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
+++ b/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
@@ -20,7 +20,7 @@ public static class DispatcherQueueTimerExtensions
private static ConcurrentDictionary _debounceInstances = new ConcurrentDictionary();
///
- /// 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 +28,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));
///
///
diff --git a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
index 9eea7881..a6205d9e 100644
--- a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
+++ b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
@@ -83,6 +83,8 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_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.IsNull(triggeredValue, "Function shouldn't have run yet.");
await Task.Delay(TimeSpan.FromMilliseconds(110));
@@ -112,8 +114,17 @@ public async Task DispatcherQueueTimer_Debounce_Immediate()
Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
Assert.AreEqual(1, triggeredCount, "Function should have run right away.");
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.");
}
+ ///
+ /// 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()
@@ -143,14 +154,85 @@ 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(value, triggeredValue, "Value shouldn't have changed from 2nd request within time bound.");
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.");
+ }
+
+ ///
+ /// Tests where we start with immediately processing a delay, but then want to 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.
+ /// But then later 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();
+
+ 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(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(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(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.");
}
}
From df9188351257b0ddeb1e59b5dca217d8c7636149 Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Mon, 25 Nov 2024 13:55:29 -0800
Subject: [PATCH 03/11] Add test and fix behavior for when switching Debounce
mode from Trailing to Leading for a DispatcherQueueTimer
---
.../DispatcherQueueTimerExtensions.cs | 13 +++++
.../DispatcherQueueTimerExtensionTests.cs | 53 +++++++++++++++++++
2 files changed, 66 insertions(+)
diff --git a/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs b/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
index 650c5a63..91766926 100644
--- a/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
+++ b/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
@@ -17,6 +17,7 @@ namespace CommunityToolkit.WinUI;
///
public static class DispatcherQueueTimerExtensions
{
+ // TODO: We should use a WeakReference here...
private static ConcurrentDictionary _debounceInstances = new ConcurrentDictionary();
///
@@ -52,8 +53,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.ContainsKey(timer))
+ {
+ timeout = false;
+
+ _debounceInstances.Remove(timer, out var _);
+ }
+
// If we're in immediate mode then we only execute if the timer wasn't running beforehand
if (!timeout)
{
diff --git a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
index a6205d9e..389acfcc 100644
--- a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
+++ b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
@@ -167,6 +167,59 @@ public async Task DispatcherQueueTimer_Debounce_Immediate_Interrupt()
Assert.AreEqual(1, triggeredCount, "Expected 2nd request to be ignored.");
}
+ ///
+ /// Tests the scenario where we flip from wanting trailing to leading edge invocaton.
+ /// 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();
+
+ 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.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(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 exectution.");
+ }
+
///
/// Tests where we start with immediately processing a delay, but then want to 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.
From 387806a998020104b178267a0b8c84c605665326 Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Mon, 25 Nov 2024 14:21:23 -0800
Subject: [PATCH 04/11] Add Debounce test for stopping the timer manually
---
.../DispatcherQueueTimerExtensionTests.cs | 39 +++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
index 389acfcc..8243fbfb 100644
--- a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
+++ b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
@@ -51,6 +51,45 @@ public async Task DispatcherQueueTimer_Debounce_Trailing()
Assert.AreEqual(1, triggeredCount, "Expected to run once.");
}
+ [TestCategory("DispatcherQueueTimerExtensions")]
+ [UIThreadTestMethod]
+ public async Task DispatcherQueueTimer_Debounce_Trailing_Stop()
+ {
+ var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+
+ 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.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.");
+
+ // 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.");
+ }
+
[TestCategory("DispatcherQueueTimerExtensions")]
[UIThreadTestMethod]
public async Task DispatcherQueueTimer_Debounce_Trailing_Interrupt()
From 86e6d96b24259597e068ad75e5edc9a910e30e24 Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Mon, 25 Nov 2024 14:40:43 -0800
Subject: [PATCH 05/11] Add mouse clicking debounce sample
---
.../Dispatcher/MouseDebounceSample.xaml | 14 ++++++
.../Dispatcher/MouseDebounceSample.xaml.cs | 44 +++++++++++++++++++
.../samples/DispatcherQueueTimerExtensions.md | 6 ++-
3 files changed, 63 insertions(+), 1 deletion(-)
create mode 100644 components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml
create mode 100644 components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml.cs
diff --git a/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml
new file mode 100644
index 00000000..bcf16452
--- /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..0fdd2db3
--- /dev/null
+++ b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml.cs
@@ -0,0 +1,44 @@
+// 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)
+ {
+ var interval = this.GeneratedPropertyMetadata?.FirstOrDefault(vm => vm.Name == "Interval")?.Value as double?;
+
+ if (interval != null)
+ {
+ _debounceTimer.Debounce(() =>
+ {
+ ResultText.Text = $"You hit the button {++_count} times!";
+ },
+ interval: TimeSpan.FromMilliseconds(interval.Value),
+ // 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
index 01eda8c2..96f904e0 100644
--- a/components/Extensions/samples/DispatcherQueueTimerExtensions.md
+++ b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
@@ -18,10 +18,14 @@ The `DispatcherQueueTimerExtensions` static class provides a collection of exten
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.
-It can be used in a number of ways, but most simply like so:
+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).
From c1be9bb2e269344664704be2657f02aadbdd0c96 Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Mon, 25 Nov 2024 15:00:54 -0800
Subject: [PATCH 06/11] Clean-up usage of the sample slider value in the
Debounce samples
---
.../Dispatcher/KeyboardDebounceSample.xaml.cs | 7 ++-----
.../Dispatcher/MouseDebounceSample.xaml.cs | 19 +++++++------------
2 files changed, 9 insertions(+), 17 deletions(-)
diff --git a/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml.cs b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml.cs
index fe466f02..e3ce6ee2 100644
--- a/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml.cs
+++ b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml.cs
@@ -26,20 +26,17 @@ public KeyboardDebounceSample()
private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
- var interval = this.GeneratedPropertyMetadata?.FirstOrDefault(vm => vm.Name == "Interval")?.Value as double?;
-
- if (sender is TextBox textBox && interval != null)
+ 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.Value),
+ 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.cs b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml.cs
index 0fdd2db3..c441f529 100644
--- a/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml.cs
+++ b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml.cs
@@ -28,17 +28,12 @@ public MouseDebounceSample()
private void Button_Click(object sender, RoutedEventArgs e)
{
- var interval = this.GeneratedPropertyMetadata?.FirstOrDefault(vm => vm.Name == "Interval")?.Value as double?;
-
- if (interval != null)
- {
- _debounceTimer.Debounce(() =>
- {
- ResultText.Text = $"You hit the button {++_count} times!";
- },
- interval: TimeSpan.FromMilliseconds(interval.Value),
- // By being on the leading edge, we ignore inputs past the first for the duration of the interval
- immediate: true);
- }
+ _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);
}
}
From 7985456939c59e0d5ff812dd8c52eff3d8158a06 Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Mon, 25 Nov 2024 16:55:42 -0800
Subject: [PATCH 07/11] Switch Debounce DispatcherQueueTimer extension to use
ConditionalWeakTable
This prevents capture holding onto the timer for garbage collection, validated with a unit test which fails with the ConcurrentDictionary, but succeeds with the ConditionalWeakTable
Because of the new Trailing/Leading behavior, we need the table in order to know if something was scheduled, otherwise we'd need reflection if we only stored the event handler, which wouldn't be AOT compatible.
---
.../DispatcherQueueTimerExtensions.cs | 15 +++---
.../DispatcherQueueTimerExtensionTests.cs | 50 ++++++++++++++++++-
2 files changed, 58 insertions(+), 7 deletions(-)
diff --git a/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs b/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
index 91766926..5204a70d 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,8 +19,8 @@ namespace CommunityToolkit.WinUI;
///
public static class DispatcherQueueTimerExtensions
{
- // TODO: We should use a WeakReference here...
- private static ConcurrentDictionary _debounceInstances = new ConcurrentDictionary();
+ //// https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.conditionalweaktable-2
+ 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. Useful for smoothing keyboard input, for instance.
@@ -60,11 +62,11 @@ public static void Debounce(this DispatcherQueueTimer timer, Action action, Time
{
// 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.ContainsKey(timer))
+ if (_debounceInstances.TryGetValue(timer, out var _))
{
timeout = false;
- _debounceInstances.Remove(timer, out var _);
+ _debounceInstances.Remove(timer);
}
// If we're in immediate mode then we only execute if the timer wasn't running beforehand
@@ -79,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.
@@ -94,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 8243fbfb..7b636572 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;
@@ -327,4 +326,53 @@ public async Task DispatcherQueueTimer_Debounce_Leading_Switch_Trailing_Interrup
Assert.AreEqual(value3, triggeredValue, "Expected value to now be the last value provided.");
Assert.AreEqual(2, triggeredCount, "Expected to interrupt execution of 2nd request.");
}
+
+ [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.");
+ }
}
From 18b340f6c79eb19c91dad59b036c52814511cf70 Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Mon, 25 Nov 2024 17:18:28 -0800
Subject: [PATCH 08/11] Apply XAML Styler
---
.../Dispatcher/KeyboardDebounceSample.xaml | 20 +++++++++----------
.../Dispatcher/MouseDebounceSample.xaml | 20 +++++++++----------
2 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml
index 48898cfa..3de79e5e 100644
--- a/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml
+++ b/components/Extensions/samples/Dispatcher/KeyboardDebounceSample.xaml
@@ -1,14 +1,14 @@
-
+
-
-
+
+
diff --git a/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml
index bcf16452..d44d17f7 100644
--- a/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml
+++ b/components/Extensions/samples/Dispatcher/MouseDebounceSample.xaml
@@ -1,14 +1,14 @@
-
+
-
-
+
+
From 5eaf1f4983f878de9fd12cb045ef9b54594c253b Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Mon, 25 Nov 2024 17:25:29 -0800
Subject: [PATCH 09/11] Add additional notes/details about how to use the
DispatcherQueueTimer.Debounce method to the docs
---
.../Extensions/samples/DispatcherQueueTimerExtensions.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/components/Extensions/samples/DispatcherQueueTimerExtensions.md b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
index 96f904e0..266b0053 100644
--- a/components/Extensions/samples/DispatcherQueueTimerExtensions.md
+++ b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
@@ -14,6 +14,14 @@ icon: Assets/Extensions.png
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.
+> [!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 sceario 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.
+
## Syntax
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.
From 26ac1f823f11d08e3c70a47cedfd8a5591d9c532 Mon Sep 17 00:00:00 2001
From: Michael Hawker <24302614+michael-hawker@users.noreply.github.com>
Date: Tue, 26 Nov 2024 11:47:22 -0800
Subject: [PATCH 10/11] Clarify behavior in docs and test results of
registering to the Tick event of the DispatcherQueueTimer when using Debounce
Behavior should be well defined now.
In the future, we could define a 'repeating' behavior, if we think it'd be useful (not sure of the specific scenario), but to do so, I would recommend we encorporate it at the end of the current signature and make false by default:
public static void Debounce(this DispatcherQueueTimer timer, Action action, TimeSpan interval, bool immediate = false, bool repeat = false)
I would imagine, this would do something like continually pulse the Action/Tick event but when additional requests are received that it would disrupt that periodic pattern somehow based on the debounce configuration (trailing/leading)?
---
.../samples/DispatcherQueueTimerExtensions.md | 9 ++-
.../DispatcherQueueTimerExtensionTests.cs | 74 ++++++++++++++++++-
2 files changed, 78 insertions(+), 5 deletions(-)
diff --git a/components/Extensions/samples/DispatcherQueueTimerExtensions.md b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
index 266b0053..18f473a3 100644
--- a/components/Extensions/samples/DispatcherQueueTimerExtensions.md
+++ b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
@@ -12,7 +12,9 @@ issue-id: 0
icon: Assets/Extensions.png
---
-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.
+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.
@@ -22,9 +24,10 @@ For each sceario that you want to Debounce, you'll want to create a separate `Di
> [!NOTE]
> Using the `Debounce` method will set `DispatcherQueueTimer.IsRepeating` to `false` to ensure proper operation. Do not change this value.
-## Syntax
+> [!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.
-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.
+## Syntax
It can be used in a number of ways, but most simply like so as a keyboard limiter:
diff --git a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
index 7b636572..bcaf505f 100644
--- a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
+++ b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
@@ -27,6 +27,13 @@ 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;
@@ -41,6 +48,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing()
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));
@@ -48,6 +56,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing()
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")]
@@ -56,6 +65,13 @@ 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;
@@ -70,6 +86,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Stop()
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(20));
@@ -80,6 +97,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_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));
@@ -87,6 +105,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Stop()
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")]
@@ -95,6 +114,13 @@ 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;
@@ -109,6 +135,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_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.");
var value2 = "Hello";
@@ -122,6 +149,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_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(110));
@@ -129,6 +157,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Interrupt()
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")]
@@ -137,6 +166,13 @@ 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;
@@ -151,11 +187,13 @@ public async Task DispatcherQueueTimer_Debounce_Immediate()
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.");
}
///
@@ -169,6 +207,13 @@ 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;
@@ -183,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";
@@ -196,17 +242,20 @@ public async Task DispatcherQueueTimer_Debounce_Immediate_Interrupt()
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(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 invocaton.
+ /// 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.
///
@@ -217,6 +266,13 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Switch_Leading_Interrup
{
var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+ // Test custom event handler too
+ var customTriggeredCount = 0;
+ debounceTimer.Tick += (s, o) =>
+ {
+ customTriggeredCount++;
+ };
+
var triggeredCount = 0;
string? triggeredValue = null;
@@ -234,6 +290,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Switch_Leading_Interrup
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
@@ -248,6 +305,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Switch_Leading_Interrup
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
@@ -255,7 +313,8 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Switch_Leading_Interrup
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 exectution.");
+ 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.");
}
///
@@ -270,6 +329,13 @@ public async Task DispatcherQueueTimer_Debounce_Leading_Switch_Trailing_Interrup
{
var debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
+ // Test custom event handler too
+ var customTriggeredCount = 0;
+ debounceTimer.Tick += (s, o) =>
+ {
+ customTriggeredCount++;
+ };
+
var triggeredCount = 0;
string? triggeredValue = null;
@@ -284,6 +350,7 @@ public async Task DispatcherQueueTimer_Debounce_Leading_Switch_Trailing_Interrup
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
@@ -301,6 +368,7 @@ public async Task DispatcherQueueTimer_Debounce_Leading_Switch_Trailing_Interrup
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
@@ -317,6 +385,7 @@ public async Task DispatcherQueueTimer_Debounce_Leading_Switch_Trailing_Interrup
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
@@ -325,6 +394,7 @@ public async Task DispatcherQueueTimer_Debounce_Leading_Switch_Trailing_Interrup
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")]
From 46e0596552278439cc0e560da30345efec0228bb Mon Sep 17 00:00:00 2001
From: "Michael Hawker MSFT (XAML Llama)"
<24302614+michael-hawker@users.noreply.github.com>
Date: Tue, 26 Nov 2024 12:21:09 -0800
Subject: [PATCH 11/11] Apply Arlo's suggestions from code review on Debounce
improvements - Thanks!
Co-authored-by: Arlo
---
.../samples/DispatcherQueueTimerExtensions.md | 2 +-
.../Dispatcher/DispatcherQueueTimerExtensions.cs | 2 +-
.../tests/DispatcherQueueTimerExtensionTests.cs | 13 ++++++-------
3 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/components/Extensions/samples/DispatcherQueueTimerExtensions.md b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
index 18f473a3..d740aad8 100644
--- a/components/Extensions/samples/DispatcherQueueTimerExtensions.md
+++ b/components/Extensions/samples/DispatcherQueueTimerExtensions.md
@@ -19,7 +19,7 @@ The `DispatcherQueueTimerExtensions` provides a single extension method, `Deboun
> [!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 sceario 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.
+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.
diff --git a/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs b/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
index 5204a70d..769139de 100644
--- a/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
+++ b/components/Extensions/src/Dispatcher/DispatcherQueueTimerExtensions.cs
@@ -19,7 +19,7 @@ namespace CommunityToolkit.WinUI;
///
public static class DispatcherQueueTimerExtensions
{
- //// https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.conditionalweaktable-2
+ ///
private static ConditionalWeakTable _debounceInstances = new();
///
diff --git a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
index bcaf505f..dddd9a4a 100644
--- a/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
+++ b/components/Extensions/tests/DispatcherQueueTimerExtensionTests.cs
@@ -84,7 +84,7 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Stop()
},
TimeSpan.FromMilliseconds(60));
- Assert.AreEqual(true, debounceTimer.IsRunning, "Expected time to be running.");
+ 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.");
@@ -198,9 +198,9 @@ public async Task DispatcherQueueTimer_Debounce_Immediate()
///
/// 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()
@@ -256,10 +256,10 @@ public async Task DispatcherQueueTimer_Debounce_Immediate_Interrupt()
///
/// 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()
@@ -318,11 +318,10 @@ public async Task DispatcherQueueTimer_Debounce_Trailing_Switch_Leading_Interrup
}
///
- /// Tests where we start with immediately processing a delay, but then want to 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.
- /// But then later want to delay and wait to execute until all the query string is available.
+ /// 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()