Skip to content

Commit 5b1e9de

Browse files
Debounce SwiftUI Demo app user search to match UIKit demo app (#1428)
1 parent 7f7e965 commit 5b1e9de

2 files changed

Lines changed: 141 additions & 12 deletions

File tree

DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright © 2026 Stream.io Inc. All rights reserved.
33
//
44

5+
import Dispatch
56
import StreamChat
67
import StreamChatCommonUI
78
import StreamChatSwiftUI
@@ -12,7 +13,7 @@ import SwiftUI
1213

1314
@Published var searchText: String = "" {
1415
didSet {
15-
searchUsers(with: searchText)
16+
scheduleDebouncedUserSearch()
1617
}
1718
}
1819

@@ -49,11 +50,29 @@ import SwiftUI
4950
private lazy var searchController: ChatUserSearchController = chatClient.userSearchController()
5051
private let lastSeenDateFormatter = DateUtils.timeAgo
5152

53+
/// Matches UIKit demo `CreateChatViewController.throttleTime` / `CreateGroupViewController.throttleTime`.
54+
private let userSearchDebounceMilliseconds = 1000
55+
private var userSearchDebounceWorkItem: DispatchWorkItem?
56+
private var userSearchRequestGeneration: UInt64 = 0
57+
58+
private struct ActiveUserSearch: Equatable {
59+
var apiTerm: String?
60+
}
61+
62+
/// Last user-list query that finished successfully (`apiTerm == nil` is “all users”).
63+
private enum UserListFetchCursor: Equatable {
64+
case notYetFetched
65+
case fetched(apiTerm: String?)
66+
}
67+
68+
private var activeUserSearch: ActiveUserSearch?
69+
private var userListFetchCursor: UserListFetchCursor = .notYetFetched
70+
5271
init() {
5372
chatUsers = searchController.userArray
5473
searchController.delegate = self
55-
// Empty initial search to get all users
56-
searchUsers(with: nil)
74+
// Empty initial search to get all users (immediate — not debounced; same as UIKit `viewDidLoad`)
75+
performUserSearch(term: nil)
5776
}
5877

5978
func userTapped(_ user: ChatUser) {
@@ -117,17 +136,63 @@ import SwiftUI
117136

118137
// MARK: - private
119138

120-
private func searchUsers(with term: String?) {
139+
private func scheduleDebouncedUserSearch() {
140+
let nextTerm = normalizedUserSearchTerm(searchText)
141+
if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, nextTerm) {
142+
return
143+
}
144+
if case let .fetched(prev) = userListFetchCursor,
145+
matchesUserSearchTerm(prev, nextTerm),
146+
state != .error {
147+
return
148+
}
149+
150+
state = .loading
151+
userSearchDebounceWorkItem?.cancel()
152+
let query = searchText
153+
let work = DispatchWorkItem { [weak self] in
154+
self?.performUserSearch(term: query.isEmpty ? nil : query)
155+
}
156+
userSearchDebounceWorkItem = work
157+
DispatchQueue.main.asyncAfter(
158+
deadline: .now() + .milliseconds(userSearchDebounceMilliseconds),
159+
execute: work
160+
)
161+
}
162+
163+
private func performUserSearch(term: String?) {
164+
if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, term) {
165+
return
166+
}
167+
activeUserSearch = ActiveUserSearch(apiTerm: term)
168+
userSearchRequestGeneration += 1
169+
let generation = userSearchRequestGeneration
121170
state = .loading
122171
searchController.search(term: term) { [weak self] error in
172+
guard let self else { return }
173+
guard generation == self.userSearchRequestGeneration else { return }
174+
self.activeUserSearch = nil
123175
if error != nil {
124-
self?.state = .error
176+
self.state = .error
125177
} else {
126-
self?.state = .loaded
178+
self.userListFetchCursor = .fetched(apiTerm: term)
179+
self.state = self.chatUsers.isEmpty ? .noUsers : .loaded
127180
}
128181
}
129182
}
130183

184+
private func normalizedUserSearchTerm(_ text: String) -> String? {
185+
text.isEmpty ? nil : text
186+
}
187+
188+
private func matchesUserSearchTerm(_ lhs: String?, _ rhs: String?) -> Bool {
189+
switch (lhs, rhs) {
190+
case (nil, nil): true
191+
case let (l?, r?): l == r
192+
default: false
193+
}
194+
}
195+
131196
private func makeChannelController() throws {
132197
let selectedUserIds = Set(selectedUsers.map(\.id))
133198
channelController = try chatClient.channelController(

DemoAppSwiftUI/CreateGroupViewModel.swift

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright © 2026 Stream.io Inc. All rights reserved.
33
//
44

5+
import Dispatch
56
import StreamChat
67
import StreamChatCommonUI
78
import StreamChatSwiftUI
@@ -14,7 +15,7 @@ import SwiftUI
1415

1516
@Published var searchText = "" {
1617
didSet {
17-
searchUsers(with: searchText)
18+
scheduleDebouncedUserSearch()
1819
}
1920
}
2021

@@ -28,11 +29,28 @@ import SwiftUI
2829
private lazy var searchController: ChatUserSearchController = chatClient.userSearchController()
2930
private let lastSeenDateFormatter = DateUtils.timeAgo
3031

32+
/// Matches UIKit demo `CreateGroupViewController.throttleTime`.
33+
private let userSearchDebounceMilliseconds = 1000
34+
private var userSearchDebounceWorkItem: DispatchWorkItem?
35+
private var userSearchRequestGeneration: UInt64 = 0
36+
37+
private struct ActiveUserSearch: Equatable {
38+
var apiTerm: String?
39+
}
40+
41+
private enum UserListFetchCursor: Equatable {
42+
case notYetFetched
43+
case fetched(apiTerm: String?)
44+
}
45+
46+
private var activeUserSearch: ActiveUserSearch?
47+
private var userListFetchCursor: UserListFetchCursor = .notYetFetched
48+
3149
init() {
3250
chatUsers = searchController.userArray
3351
searchController.delegate = self
34-
// Empty initial search to get all users
35-
searchUsers(with: nil)
52+
// Empty initial search to get all users (immediate — not debounced)
53+
performUserSearch(term: nil)
3654
}
3755

3856
var canCreateGroup: Bool {
@@ -98,14 +116,60 @@ import SwiftUI
98116

99117
// MARK: - private
100118

101-
private func searchUsers(with term: String?) {
119+
private func scheduleDebouncedUserSearch() {
120+
let nextTerm = normalizedUserSearchTerm(searchText)
121+
if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, nextTerm) {
122+
return
123+
}
124+
if case let .fetched(prev) = userListFetchCursor,
125+
matchesUserSearchTerm(prev, nextTerm),
126+
state != .error {
127+
return
128+
}
129+
130+
state = .loading
131+
userSearchDebounceWorkItem?.cancel()
132+
let query = searchText
133+
let work = DispatchWorkItem { [weak self] in
134+
self?.performUserSearch(term: query.isEmpty ? nil : query)
135+
}
136+
userSearchDebounceWorkItem = work
137+
DispatchQueue.main.asyncAfter(
138+
deadline: .now() + .milliseconds(userSearchDebounceMilliseconds),
139+
execute: work
140+
)
141+
}
142+
143+
private func performUserSearch(term: String?) {
144+
if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, term) {
145+
return
146+
}
147+
activeUserSearch = ActiveUserSearch(apiTerm: term)
148+
userSearchRequestGeneration += 1
149+
let generation = userSearchRequestGeneration
102150
state = .loading
103151
searchController.search(term: term) { [weak self] error in
152+
guard let self else { return }
153+
guard generation == self.userSearchRequestGeneration else { return }
154+
self.activeUserSearch = nil
104155
if error != nil {
105-
self?.state = .error
156+
self.state = .error
106157
} else {
107-
self?.state = .loaded
158+
self.userListFetchCursor = .fetched(apiTerm: term)
159+
self.state = self.chatUsers.isEmpty ? .noUsers : .loaded
108160
}
109161
}
110162
}
163+
164+
private func normalizedUserSearchTerm(_ text: String) -> String? {
165+
text.isEmpty ? nil : text
166+
}
167+
168+
private func matchesUserSearchTerm(_ lhs: String?, _ rhs: String?) -> Bool {
169+
switch (lhs, rhs) {
170+
case (nil, nil): true
171+
case let (l?, r?): l == r
172+
default: false
173+
}
174+
}
111175
}

0 commit comments

Comments
 (0)