From 446858c7fbe3ffb7a743b30423f60bab9ec7be9c Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Wed, 3 Dec 2025 10:49:14 -0300 Subject: [PATCH] [PM-28144] Add debouncing to searches (#2176) --- .../ItemList/ItemList/ItemListView.swift | 2 +- .../Core/Platform/Utilities/Constants.swift | 3 + .../Application/Extensions/View+Task.swift | 59 +++++++++++++++++++ .../Send/Send/SendList/SendListView.swift | 2 +- .../AutofillList/VaultAutofillListView.swift | 2 +- .../Vault/VaultGroup/VaultGroupView.swift | 2 +- .../VaultItemSelectionView.swift | 2 +- .../Vault/Vault/VaultList/VaultListView.swift | 2 +- 8 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 BitwardenKit/UI/Platform/Application/Extensions/View+Task.swift diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListView.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListView.swift index fc63efc87..87c00ceae 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListView.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListView.swift @@ -371,7 +371,7 @@ struct ItemListView: View { placement: .navigationBarDrawer(displayMode: .always), prompt: Localizations.search, ) - .task(id: store.state.searchText) { + .searchDebouncedTask(id: store.state.searchText) { await store.perform(.search(store.state.searchText)) } } diff --git a/BitwardenKit/Core/Platform/Utilities/Constants.swift b/BitwardenKit/Core/Platform/Utilities/Constants.swift index ea8f96cc4..88d8a91dc 100644 --- a/BitwardenKit/Core/Platform/Utilities/Constants.swift +++ b/BitwardenKit/Core/Platform/Utilities/Constants.swift @@ -24,6 +24,9 @@ public enum Constants { /// The minimum number of minutes before attempting a server config sync again. public static let minimumConfigSyncInterval: TimeInterval = 60 * 60 // 60 minutes + /// The search debounce time in nanoseconds. + public static let searchDebounceTimeInNS: UInt64 = 200_000_000 // 200ms + /// The default file name when the file name cannot be determined. public static let unknownFileName = "unknown_file_name" } diff --git a/BitwardenKit/UI/Platform/Application/Extensions/View+Task.swift b/BitwardenKit/UI/Platform/Application/Extensions/View+Task.swift new file mode 100644 index 000000000..e1b4d41c0 --- /dev/null +++ b/BitwardenKit/UI/Platform/Application/Extensions/View+Task.swift @@ -0,0 +1,59 @@ +import SwiftUI + +public extension View { + /// Adds a debounced task to perform before this view appears or when a specified + /// value changes. + /// This means that the task will execute the action when the specified value doesn't change + /// in the debounce interval specified. + /// + /// - Parameters: + /// - id: The value to observe for changes. The value must conform + /// to the + /// protocol. + /// - debounceIntervalInNS: The interval to be set for debouncing the task. + /// - action: A closure that SwiftUI calls as an asynchronous task + /// before the view appears. SwiftUI can automatically cancel the task + /// after the view disappears before the action completes. If the + /// `id` value changes, SwiftUI cancels and restarts the task. + /// + /// - Returns: A view that runs the specified action asynchronously before + /// the view appears, or restarts the task when the `id` value changes. + @inlinable nonisolated func debouncedTask( + id value: T, + debounceIntervalInNS: UInt64, + _ action: @escaping @Sendable () async -> Void, + ) -> some View where T: Equatable { + task(id: value) { + try? await Task.sleep(nanoseconds: debounceIntervalInNS) + guard !Task.isCancelled else { + return + } + await action() + } + } + + /// Adds a task to perform before this view appears or when a specified + /// value changes to be used on debounced searches. + /// + /// - Parameters: + /// - id: The value to observe for changes. The value must conform + /// to the + /// protocol. + /// - action: A closure that SwiftUI calls as an asynchronous task + /// before the view appears. SwiftUI can automatically cancel the task + /// after the view disappears before the action completes. If the + /// `id` value changes, SwiftUI cancels and restarts the task. + /// + /// - Returns: A view that runs the specified action asynchronously before + /// the view appears, or restarts the task when the `id` value changes. + @inlinable nonisolated func searchDebouncedTask( + id value: T, + _ action: @escaping @Sendable () async -> Void, + ) -> some View where T: Equatable { + debouncedTask( + id: value, + debounceIntervalInNS: Constants.searchDebounceTimeInNS, + action + ) + } +} diff --git a/BitwardenShared/UI/Tools/Send/Send/SendList/SendListView.swift b/BitwardenShared/UI/Tools/Send/Send/SendList/SendListView.swift index 0a7125a9f..9d0f05081 100644 --- a/BitwardenShared/UI/Tools/Send/Send/SendList/SendListView.swift +++ b/BitwardenShared/UI/Tools/Send/Send/SendList/SendListView.swift @@ -266,7 +266,7 @@ struct SendListView: View { ) .task { await store.perform(.loadData) } .task { await store.perform(.streamSendList) } - .task(id: store.state.searchText) { + .searchDebouncedTask(id: store.state.searchText) { await store.perform(.search(store.state.searchText)) } .onChange(of: store.state.infoUrl) { newValue in diff --git a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListView.swift b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListView.swift index 4cc84535b..991931fd2 100644 --- a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListView.swift +++ b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListView.swift @@ -106,7 +106,7 @@ private struct VaultAutofillListSearchableView: View { .task { await store.perform(.streamShowWebIcons) } - .task(id: store.state.searchText) { + .searchDebouncedTask(id: store.state.searchText) { await store.perform(.search(store.state.searchText)) } .task(id: store.state.excludedCredentialIdFound) { diff --git a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupView.swift b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupView.swift index 29bfaf696..eefe8763d 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupView.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupView.swift @@ -62,7 +62,7 @@ struct VaultGroupView: View { openURL(url) store.send(.clearURL) } - .task(id: store.state.searchText) { + .searchDebouncedTask(id: store.state.searchText) { await store.perform(.search(store.state.searchText)) } .task(id: store.state.searchVaultFilterType) { diff --git a/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionView.swift b/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionView.swift index fbfcf3a29..582bf7180 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionView.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultItemSelection/VaultItemSelectionView.swift @@ -133,7 +133,7 @@ private struct VaultItemSelectionSearchableView: View { .task { await store.perform(.streamVaultItems) } - .task(id: store.state.searchText) { + .searchDebouncedTask(id: store.state.searchText) { await store.perform(.search(store.state.searchText)) } .toast( diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListView.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListView.swift index b576b9c7d..9a6f6877d 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListView.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListView.swift @@ -334,7 +334,7 @@ struct VaultListView: View { prompt: Localizations.search, ) .autocorrectionDisabled(true) - .task(id: store.state.searchText) { + .searchDebouncedTask(id: store.state.searchText) { await store.perform(.search(store.state.searchText)) } .task(id: store.state.searchVaultFilterType) {