@@ -52,7 +52,7 @@ final class TodoListViewModel: Store {
5252 case upsertTodo( Todo )
5353
5454 // Run
55- case setSearchQuery ( String )
55+ case triggerSearch ( String )
5656 case fetchSearchResults( [ TodoListItem ] )
5757 case didToggleCompleted( TodoListItem )
5858 case didTogglePinned( TodoListItem )
@@ -74,9 +74,12 @@ final class TodoListViewModel: Store {
7474 case togglePinned( TodoListItem )
7575 }
7676
77+ private enum SearchTaskKind : Hashable {
78+ case debounce
79+ case request
80+ }
81+
7782 private( set) var state : State
78- private let searchDebounceDelay : Double = 0.4
79- private var searchDebounceTask : Task < Void , Never > ?
8083 private let fetchTodosUseCase : FetchTodosUseCase
8184 private let fetchTodoByIdUseCase : FetchTodoByIdUseCase
8285 private let upsertTodoUseCase : UpsertTodoUseCase
@@ -85,6 +88,8 @@ final class TodoListViewModel: Store {
8588 private let loadingState = LoadingState ( )
8689 private var undoDeleteTodoId : String ?
8790 private var nextCursor : TodoCursor ?
91+ private var searchTasks : [ SearchTaskKind : Task < Void , Never > ] = [ : ]
92+ private let searchDebounceDelay : Double = 0.4
8893
8994 init (
9095 fetchTodosUseCase: FetchTodosUseCase ,
@@ -129,7 +134,7 @@ final class TodoListViewModel: Store {
129134 case . onAppear, . loadNextPage, . setSearchText, . setToast, . upsertTodo:
130135 effects = reduceByView ( action, state: & state)
131136
132- case . setSearchQuery , . fetchSearchResults, . didToggleCompleted, . didTogglePinned,
137+ case . triggerSearch , . fetchSearchResults, . didToggleCompleted, . didTogglePinned,
133138 . restoreTodo, . setLoading, . appendTodos, . resetPagination, . setHasMore:
134139 effects = reduceByRun ( action, state: & state)
135140 }
@@ -142,10 +147,10 @@ final class TodoListViewModel: Store {
142147 func run( _ effect: SideEffect ) {
143148 switch effect {
144149 case . fetch:
145- beginLoading ( . immediate )
150+ beginLoading ( . delayed )
146151 Task {
147152 do {
148- defer { endLoading ( . immediate ) }
153+ defer { endLoading ( . delayed ) }
149154 let page = try await fetchTodosUseCase. execute ( state. query, cursor: nil )
150155 send ( . resetPagination)
151156 send ( . appendTodos( page. items. compactMap { TodoListItem ( from: $0) } , nextCursor: page. nextCursor) )
@@ -156,10 +161,10 @@ final class TodoListViewModel: Store {
156161 }
157162 }
158163 case . loadNextPage:
159- beginLoading ( . immediate )
164+ beginLoading ( . delayed )
160165 Task {
161166 do {
162- defer { endLoading ( . immediate ) }
167+ defer { endLoading ( . delayed ) }
163168 let page = try await fetchTodosUseCase. execute ( state. query, cursor: nextCursor)
164169 send ( . appendTodos( page. items. compactMap { TodoListItem ( from: $0) } , nextCursor: page. nextCursor) )
165170 let hasMore = page. items. count == state. query. pageSize && page. nextCursor != nil
@@ -169,17 +174,24 @@ final class TodoListViewModel: Store {
169174 }
170175 }
171176 case . search( let keyword) :
172- beginLoading ( . immediate)
173- Task {
177+ searchTasks [ . request] ? . cancel ( )
178+ let requestTask = Task { [ weak self] in
179+ guard let self else { return }
174180 do {
175- defer { endLoading ( . immediate) }
181+ defer {
182+ self . searchTasks [ . request] = nil
183+ self . endLoading ( . immediate)
184+ }
176185 let query = TodoQuery ( category: state. category, keyword: keyword)
177186 let page = try await fetchTodosUseCase. execute ( query, cursor: nil )
187+ if Task . isCancelled { return }
178188 send ( . fetchSearchResults( page. items. compactMap { TodoListItem ( from: $0) } ) )
179189 } catch {
190+ if error is CancellationError { return }
180191 send ( . setAlert( true ) )
181192 }
182193 }
194+ searchTasks [ . request] = requestTask
183195 case . upsert( let item) :
184196 beginLoading ( . delayed)
185197 Task {
@@ -242,7 +254,7 @@ final class TodoListViewModel: Store {
242254 beginLoading ( . delayed)
243255 Task {
244256 // endLoading(.delayed)를 defer로 두지 않는 이유
245- // send(.refresh)가 같은 턴에서 beginLoading(.immediate )를 먼저 올린 뒤
257+ // send(.refresh)가 같은 턴에서 beginLoading(.delayed )를 먼저 올린 뒤
246258 // delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문
247259 do {
248260 try await undoDeleteTodoUseCase. execute ( todoId)
@@ -298,7 +310,7 @@ private extension TodoListViewModel {
298310 case . setIsSearching( let value) :
299311 state. isSearching = value
300312 if !value {
301- cancelDebounce ( )
313+ cancelSearch ( )
302314 state. searchText = " "
303315 state. searchResults = [ ]
304316 state. showAllSearchResults = false
@@ -332,12 +344,12 @@ private extension TodoListViewModel {
332344 state. showAllSearchResults = false
333345 let trimmed = text. trimmingCharacters ( in: . whitespacesAndNewlines)
334346 if trimmed. isEmpty {
335- cancelDebounce ( )
347+ cancelSearch ( )
336348 state. searchResults = [ ]
337- state. isLoading = false
338349 } else {
339- state. isLoading = true
340- scheduleDebouncedQuery ( text)
350+ cancelSearch ( )
351+ beginLoading ( . immediate)
352+ scheduleDebouncedSearch ( trimmed)
341353 }
342354 case . setToast( let isPresented) :
343355 setToast ( & state, isPresented: isPresented)
@@ -352,13 +364,8 @@ private extension TodoListViewModel {
352364
353365 func reduceByRun( _ action: Action , state: inout State ) -> [ SideEffect ] {
354366 switch action {
355- case . setSearchQuery( let query) :
356- let trimmed = query. trimmingCharacters ( in: . whitespacesAndNewlines)
357- if trimmed. isEmpty {
358- state. searchResults = [ ]
359- } else {
360- return [ . search( trimmed) ]
361- }
367+ case . triggerSearch( let query) :
368+ return [ . search( query) ]
362369 case . fetchSearchResults( let items) :
363370 state. searchResults = items
364371 case . didToggleCompleted( let todo) :
@@ -418,21 +425,24 @@ private extension TodoListViewModel {
418425 state. showToast = isPresented
419426 }
420427
421- func scheduleDebouncedQuery ( _ query: String ) {
422- searchDebounceTask ? . cancel ( )
423- searchDebounceTask = Task { [ weak self] in
428+ func scheduleDebouncedSearch ( _ query: String ) {
429+ searchTasks [ . debounce ] ? . cancel ( )
430+ let debounceTask = Task { [ weak self] in
424431 guard let self else { return }
425432 try ? await Task . sleep ( for: . seconds( searchDebounceDelay) )
426433 if Task . isCancelled { return }
427434 await MainActor . run {
428- self . send ( . setSearchQuery( query) )
435+ self . searchTasks [ . debounce] = nil
436+ self . send ( . triggerSearch( query) )
429437 }
430438 }
439+ searchTasks [ . debounce] = debounceTask
431440 }
432441
433- func cancelDebounce( ) {
434- searchDebounceTask? . cancel ( )
435- searchDebounceTask = nil
442+ func cancelSearch( ) {
443+ searchTasks. values. forEach { $0. cancel ( ) }
444+ searchTasks = [ : ]
445+ endLoading ( . immediate)
436446 }
437447
438448 private func beginLoading( _ mode: LoadingState . Mode ) {
0 commit comments