Skip to content

Commit 3054402

Browse files
committed
refactor: .immediate에서 .delay로 수정 및 디바운스 작업도 같이 끝나도록 개선
1 parent 50d3907 commit 3054402

2 files changed

Lines changed: 84 additions & 52 deletions

File tree

DevLog/Presentation/ViewModel/SearchViewModel.swift

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,22 @@ final class SearchViewModel: Store {
4343
case fetch(String)
4444
}
4545

46+
private enum SearchTaskKind: Hashable {
47+
case debounce
48+
case request
49+
}
50+
4651
private(set) var state: State = .init()
4752
private let fetchWebPagesUseCase: FetchWebPagesUseCase
4853
private let fetchTodosUseCase: FetchTodosUseCase
4954
private let fetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase
5055
private let updateRecentSearchQueriesUseCase: UpdateRecentSearchQueriesUseCase
56+
private let loadingState = LoadingState()
5157
let contentsLimit: Int = 5
5258

5359
private let maxRecentQueries = 20
5460
private let searchDebounceDelay: Double = 0.4
55-
private var searchDebounceTask: Task<Void, Never>?
61+
private var searchTasks: [SearchTaskKind: Task<Void, Never>] = [:]
5662

5763
init(
5864
fetchWebPagesUseCase: FetchWebPagesUseCase,
@@ -104,22 +110,16 @@ final class SearchViewModel: Store {
104110
state.showAllWebPages = false
105111
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
106112
if trimmed.isEmpty {
107-
cancelDebounce()
113+
cancelSearch()
108114
state.webPages = []
109115
state.todos = []
110-
state.isLoading = false
111116
} else {
112-
state.isLoading = true
113-
scheduleDebouncedQuery(query)
117+
cancelSearch()
118+
beginLoading(.immediate)
119+
scheduleDebouncedFetch(trimmed)
114120
}
115121
case .applySearchQuery(let query):
116-
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
117-
if trimmed.isEmpty {
118-
state.webPages = []
119-
state.todos = []
120-
} else {
121-
effects = [.fetch(trimmed)]
122-
}
122+
effects = [.fetch(query)]
123123
case .setShowAllTodos(let shouldShowAll):
124124
state.showAllTodos = shouldShowAll
125125
case .setShowAllWebPages(let shouldShowAll):
@@ -133,10 +133,14 @@ final class SearchViewModel: Store {
133133
func run(_ effect: SideEffect) {
134134
switch effect {
135135
case .fetch(let query):
136-
Task {
136+
searchTasks[.request]?.cancel()
137+
let requestTask = Task { [weak self] in
138+
guard let self else { return }
137139
do {
138-
send(.setLoading(true))
139-
defer { send(.setLoading(false)) }
140+
defer {
141+
self.searchTasks[.request] = nil
142+
self.endLoading(.immediate)
143+
}
140144
let searchesTodoOnly = searchesTodoOnly(query)
141145
async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil)
142146
async let webPageItems = fetchWebPageItems(
@@ -145,12 +149,15 @@ final class SearchViewModel: Store {
145149
)
146150
let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) }
147151
let resolvedWebPageItems = try await webPageItems
152+
if Task.isCancelled { return }
148153
send(.fetchTodos(todoItems))
149154
send(.fetchWebPage(resolvedWebPageItems))
150155
} catch {
156+
if error is CancellationError { return }
151157
send(.setAlert(true))
152158
}
153159
}
160+
searchTasks[.request] = requestTask
154161
}
155162
}
156163
}
@@ -165,21 +172,36 @@ private extension SearchViewModel {
165172
state.showAlert = isPresented
166173
}
167174

168-
func scheduleDebouncedQuery(_ query: String) {
169-
searchDebounceTask?.cancel()
170-
searchDebounceTask = Task { [weak self] in
175+
func scheduleDebouncedFetch(_ query: String) {
176+
searchTasks[.debounce]?.cancel()
177+
let debounceTask = Task { [weak self] in
171178
guard let self else { return }
172179
try? await Task.sleep(for: .seconds(searchDebounceDelay))
173180
if Task.isCancelled { return }
174181
await MainActor.run {
182+
self.searchTasks[.debounce] = nil
175183
self.send(.applySearchQuery(query))
176184
}
177185
}
186+
searchTasks[.debounce] = debounceTask
187+
}
188+
189+
func cancelSearch() {
190+
searchTasks.values.forEach { $0.cancel() }
191+
searchTasks = [:]
192+
endLoading(.immediate)
178193
}
179194

180-
func cancelDebounce() {
181-
searchDebounceTask?.cancel()
182-
searchDebounceTask = nil
195+
func beginLoading(_ mode: LoadingState.Mode) {
196+
loadingState.begin(mode: mode) { [weak self] isLoading in
197+
self?.send(.setLoading(isLoading))
198+
}
199+
}
200+
201+
func endLoading(_ mode: LoadingState.Mode) {
202+
loadingState.end(mode: mode) { [weak self] isLoading in
203+
self?.send(.setLoading(isLoading))
204+
}
183205
}
184206

185207
func searchesTodoOnly(_ query: String) -> Bool {

DevLog/Presentation/ViewModel/TodoListViewModel.swift

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)