Skip to content

Commit 56f71da

Browse files
authored
[#166] TodoListView에서 searchable을 버튼화하고 검색 로직을 구현한다 (#170)
* ui: 돋보기 버튼을 통해 searchable이 구현된 뷰를 보여주도록 변경 * feat: 검색어 입력에 대해 0.4초 디바운스 추가 * refactor: 로컬 필터링 대신 서버에서 검색해서 데이터를 가져오도록 개선 * fix: iOS 17 이하에서 돋보기 버튼을 탭해도 포커싱이 되지 않는 현상 해결 * fix: 디바운스 중 검색어를 제거하면 LoadingView가 게속 뜨는 현상 해결 * chore: 제거된 문자열 수정
1 parent be6abeb commit 56f71da

4 files changed

Lines changed: 227 additions & 115 deletions

File tree

DevLog/Presentation/Enum/TodoScope.swift

Lines changed: 0 additions & 23 deletions
This file was deleted.

DevLog/Presentation/ViewModel/TodoListViewModel.swift

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ final class TodoListViewModel: Store {
1111
struct State {
1212
var todos: [TodoListItem] = []
1313
var searchText: String = ""
14+
var searchResults: [TodoListItem] = []
1415
let kind: TodoKind
1516
var showEditor: Bool = false
1617
var showAlert: Bool = false
1718
var alertTitle: String = ""
1819
var alertMessage: String = ""
19-
var scope: TodoScope = .title
20+
var isSearching: Bool = false
21+
var showAllSearchResults: Bool = false
2022
var query: TodoQuery
2123
var isLoading: Bool = false
2224
var showToast: Bool = false
@@ -37,6 +39,8 @@ final class TodoListViewModel: Store {
3739
case togglePinnedOnly
3840
case setCompletionFilter(TodoQuery.CompletionFilter)
3941
case resetFilters
42+
case setIsSearching(Bool)
43+
case setShowAllSearchResults(Bool)
4044
case tapToggleCompleted(TodoListItem)
4145
case tapTogglePinned(TodoListItem)
4246
case undoDelete
@@ -45,12 +49,13 @@ final class TodoListViewModel: Store {
4549
case confirmDelete
4650
case onAppear
4751
case loadNextPage
48-
case setScope(TodoScope)
4952
case setSearchText(String)
5053
case setToast(isPresented: Bool)
5154
case upsertTodo(Todo)
5255

5356
// Run
57+
case setSearchQuery(String)
58+
case fetchSearchResults([TodoListItem])
5459
case didToggleCompleted(TodoListItem)
5560
case didTogglePinned(TodoListItem)
5661
case setLoading(Bool)
@@ -62,13 +67,16 @@ final class TodoListViewModel: Store {
6267
enum SideEffect {
6368
case fetch
6469
case loadNextPage
70+
case search(String)
6571
case upsert(Todo)
6672
case delete(String)
6773
case toggleCompleted(TodoListItem)
6874
case togglePinned(TodoListItem)
6975
}
7076

7177
@Published private(set) var state: State
78+
private let searchDebounceDelay: Double = 0.4
79+
private var searchDebounceTask: Task<Void, Never>?
7280
private let fetchTodosUseCase: FetchTodosUseCase
7381
private let fetchTodoByIDUseCase: FetchTodoByIDUseCase
7482
private let upsertTodoUseCase: UpsertTodoUseCase
@@ -91,6 +99,8 @@ final class TodoListViewModel: Store {
9199
)
92100
}
93101

102+
let searchResultsLimit = 5
103+
94104
var appliedFilterCount: Int {
95105
var count = 0
96106
if state.query.sortTarget != .createdAt { count += 1 }
@@ -106,14 +116,15 @@ final class TodoListViewModel: Store {
106116

107117
switch action {
108118
case .refresh, .setAlert, .setShowEditor, .swipeTodo, .setSortTarget, .setSortOrder,
109-
.togglePinnedOnly, .setCompletionFilter, .resetFilters, .tapToggleCompleted,
110-
.tapTogglePinned, .undoDelete:
119+
.togglePinnedOnly, .setCompletionFilter, .resetFilters, .setIsSearching,
120+
.setShowAllSearchResults, .tapToggleCompleted, .tapTogglePinned, .undoDelete:
111121
effects = reduceByUser(action, state: &state)
112122

113-
case .confirmDelete, .onAppear, .loadNextPage, .setScope, .setSearchText, .setToast, .upsertTodo:
123+
case .confirmDelete, .onAppear, .loadNextPage, .setSearchText, .setToast, .upsertTodo:
114124
effects = reduceByView(action, state: &state)
115125

116-
case .didToggleCompleted, .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore:
126+
case .setSearchQuery, .fetchSearchResults,
127+
.didToggleCompleted, .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore:
117128
effects = reduceByRun(action, state: &state)
118129
}
119130

@@ -150,6 +161,18 @@ final class TodoListViewModel: Store {
150161
send(.setAlert(true))
151162
}
152163
}
164+
case .search(let keyword):
165+
Task {
166+
do {
167+
defer { send(.setLoading(false)) }
168+
send(.setLoading(true))
169+
let query = TodoQuery(kind: state.kind, keyword: keyword)
170+
let page = try await fetchTodosUseCase.execute(query, cursor: nil)
171+
send(.fetchSearchResults(page.items.map { TodoListItem(from: $0) }))
172+
} catch {
173+
send(.setAlert(true))
174+
}
175+
}
153176
case .upsert(let item):
154177
Task {
155178
do {
@@ -246,6 +269,16 @@ private extension TodoListViewModel {
246269
state.query = TodoQuery(kind: state.kind)
247270
state.nextCursor = nil
248271
return [.fetch]
272+
case .setIsSearching(let value):
273+
state.isSearching = value
274+
if !value {
275+
cancelDebounce()
276+
state.searchText = ""
277+
state.searchResults = []
278+
state.showAllSearchResults = false
279+
}
280+
case .setShowAllSearchResults(let value):
281+
state.showAllSearchResults = value
249282
case .tapToggleCompleted(let todo):
250283
return [.toggleCompleted(todo)]
251284
case .tapTogglePinned(let todo):
@@ -275,10 +308,18 @@ private extension TodoListViewModel {
275308
case .loadNextPage:
276309
guard state.hasMore, !state.isLoading, state.pendingTask == nil else { return [] }
277310
return [.loadNextPage]
278-
case .setScope(let scope):
279-
state.scope = scope
280311
case .setSearchText(let text):
281312
state.searchText = text
313+
state.showAllSearchResults = false
314+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
315+
if trimmed.isEmpty {
316+
cancelDebounce()
317+
state.searchResults = []
318+
state.isLoading = false
319+
} else {
320+
state.isLoading = true
321+
scheduleDebouncedQuery(text)
322+
}
282323
case .setToast(let isPresented):
283324
setToast(&state, isPresented: isPresented)
284325
case .upsertTodo(let todo):
@@ -291,6 +332,15 @@ private extension TodoListViewModel {
291332

292333
func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] {
293334
switch action {
335+
case .setSearchQuery(let query):
336+
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
337+
if trimmed.isEmpty {
338+
state.searchResults = []
339+
} else {
340+
return [.search(trimmed)]
341+
}
342+
case .fetchSearchResults(let items):
343+
state.searchResults = items
294344
case .didToggleCompleted(let todo):
295345
if let index = state.todos.firstIndex(where: { $0.id == todo.id }) {
296346
state.todos[index] = todo
@@ -341,6 +391,23 @@ private extension TodoListViewModel {
341391
state.toastMessage = "실행 취소"
342392
state.showToast = isPresented
343393
}
394+
395+
func scheduleDebouncedQuery(_ query: String) {
396+
searchDebounceTask?.cancel()
397+
searchDebounceTask = Task { [weak self] in
398+
guard let self else { return }
399+
try? await Task.sleep(for: .seconds(searchDebounceDelay))
400+
if Task.isCancelled { return }
401+
await MainActor.run {
402+
self.send(.setSearchQuery(query))
403+
}
404+
}
405+
}
406+
407+
func cancelDebounce() {
408+
searchDebounceTask?.cancel()
409+
searchDebounceTask = nil
410+
}
344411
}
345412

346413
extension TodoQuery.SortTarget {

DevLog/Resource/Localizable.xcstrings

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -233,26 +233,6 @@
233233
},
234234
"Todos" : {
235235

236-
},
237-
"TodoScope.content" : {
238-
"localizations" : {
239-
"ko" : {
240-
"stringUnit" : {
241-
"state" : "translated",
242-
"value" : "내용"
243-
}
244-
}
245-
}
246-
},
247-
"TodoScope.title" : {
248-
"localizations" : {
249-
"ko" : {
250-
"stringUnit" : {
251-
"state" : "translated",
252-
"value" : "제목"
253-
}
254-
}
255-
}
256236
},
257237
"Web Page" : {
258238

@@ -271,6 +251,9 @@
271251
},
272252
"검색어를 입력해 저장한 앱 컨텐츠를 찾아보세요." : {
273253

254+
},
255+
"검색어를 입력해주세요." : {
256+
274257
},
275258
"계정 삭제" : {
276259

0 commit comments

Comments
 (0)