Skip to content

Commit c3d884b

Browse files
committed
refactor: LoadingFeature 적용
1 parent 956fc09 commit c3d884b

2 files changed

Lines changed: 75 additions & 29 deletions

File tree

Application/DevLogPresentation/Sources/Search/SearchFeature.swift

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct SearchFeature {
1616
@ObservableState
1717
struct State: Equatable {
1818
@Presents var alert: AlertState<Never>?
19-
var isLoading = false
19+
var loading = LoadingFeature.State()
2020
var isSearching = false
2121
var searchQuery = ""
2222
var webPages: [WebPageItem] = []
@@ -30,6 +30,10 @@ struct SearchFeature {
3030
self.recentQueries = OrderedSet(recentQueries)
3131
}
3232

33+
var isLoading: Bool {
34+
loading.isLoading
35+
}
36+
3337
var visibleTodos: [TodoListItem] {
3438
if showAllTodos {
3539
return todos
@@ -65,9 +69,9 @@ struct SearchFeature {
6569
case clearRecentQueries
6670
case applySearchQuery(String)
6771
case setAlert(Bool)
68-
case setLoading(Bool)
6972
case setShowAllTodos(Bool)
7073
case setShowAllWebPages(Bool)
74+
case loading(LoadingFeature.Action)
7175
}
7276

7377
private enum CancelID: Hashable {
@@ -84,14 +88,17 @@ struct SearchFeature {
8488
private let searchDebounceDelay = Duration.seconds(0.4)
8589

8690
var body: some ReducerOf<Self> {
91+
Scope(state: \.loading, action: \.loading) {
92+
LoadingFeature()
93+
}
8794
BindingReducer()
8895
Reduce { state, action in
8996
switch action {
9097
case .alert:
9198
break
9299
case .binding(\.isSearching):
93100
if !state.isSearching {
94-
return cancelSearchEffect()
101+
return cancelSearchEffect(isLoading: state.isLoading)
95102
}
96103
case .binding(\.searchQuery):
97104
state.showAllTodos = false
@@ -100,10 +107,10 @@ struct SearchFeature {
100107
if trimmed.isEmpty {
101108
state.webPages = []
102109
state.todos = []
103-
return cancelSearchEffect()
110+
return cancelSearchEffect(isLoading: state.isLoading)
104111
} else {
105112
return .concatenate(
106-
cancelSearchEffect(),
113+
cancelSearchEffect(isLoading: state.isLoading),
107114
debounceFetchEffect(trimmed)
108115
)
109116
}
@@ -133,18 +140,18 @@ struct SearchFeature {
133140
if trimmed.isEmpty {
134141
state.webPages = []
135142
state.todos = []
136-
return cancelSearchEffect()
143+
return cancelSearchEffect(isLoading: state.isLoading)
137144
} else {
138-
return fetchEffect(trimmed)
145+
return fetchEffect(trimmed, isLoading: state.isLoading)
139146
}
140147
case .setAlert(let isPresented):
141148
state.alert = isPresented ? alertState() : nil
142-
case .setLoading(let isLoading):
143-
state.isLoading = isLoading
144149
case .setShowAllTodos(let shouldShowAll):
145150
state.showAllTodos = shouldShowAll
146151
case .setShowAllWebPages(let shouldShowAll):
147152
state.showAllWebPages = shouldShowAll
153+
case .loading:
154+
break
148155
}
149156

150157
return .none
@@ -201,17 +208,17 @@ private enum SearchUpdateRecentQueriesUseCaseKey: DependencyKey {
201208
}
202209

203210
private extension SearchFeature {
204-
func cancelSearchEffect() -> Effect<Action> {
211+
func cancelSearchEffect(isLoading: Bool) -> Effect<Action> {
205212
.merge(
206213
.cancel(id: CancelID.debounce),
207214
.cancel(id: CancelID.request),
208-
.send(.setLoading(false))
215+
endLoadingEffect(isLoading: isLoading)
209216
)
210217
}
211218

212219
func debounceFetchEffect(_ query: String) -> Effect<Action> {
213220
.concatenate(
214-
.send(.setLoading(true)),
221+
.send(.loading(.begin(target: .default, mode: .immediate))),
215222
.run { [clock, searchDebounceDelay] send in
216223
try await clock.sleep(for: searchDebounceDelay)
217224
await send(.applySearchQuery(query))
@@ -220,7 +227,7 @@ private extension SearchFeature {
220227
)
221228
}
222229

223-
func fetchEffect(_ query: String) -> Effect<Action> {
230+
func fetchEffect(_ query: String, isLoading: Bool) -> Effect<Action> {
224231
let searchesTodoOnly = searchesTodoOnly(query)
225232

226233
return .run { [fetchTodosUseCase, fetchWebPagesUseCase] send in
@@ -235,17 +242,26 @@ private extension SearchFeature {
235242
let resolvedWebPageItems = try await webPageItems
236243
await send(.fetchTodos(todoItems))
237244
await send(.fetchWebPage(resolvedWebPageItems))
238-
await send(.setLoading(false))
245+
if isLoading {
246+
await send(.loading(.end(target: .default, mode: .immediate)))
247+
}
239248
} catch is CancellationError {
240249
return
241250
} catch {
242-
await send(.setLoading(false))
251+
if isLoading {
252+
await send(.loading(.end(target: .default, mode: .immediate)))
253+
}
243254
await send(.setAlert(true))
244255
}
245256
}
246257
.cancellable(id: CancelID.request, cancelInFlight: true)
247258
}
248259

260+
func endLoadingEffect(isLoading: Bool) -> Effect<Action> {
261+
guard isLoading else { return .none }
262+
return .send(.loading(.end(target: .default, mode: .immediate)))
263+
}
264+
249265
func saveRecentQueriesEffect(_ queries: OrderedSet<String>) -> Effect<Action> {
250266
let values = Array(queries)
251267
return .run { [updateRecentSearchQueriesUseCase] _ in

Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ struct SearchStoreTestAdapter {
4141
state.todos = initialTodos
4242
state.webPages = initialWebPages
4343
state.isSearching = isSearching
44-
state.isLoading = isLoading
44+
if isLoading {
45+
state.loading.setImmediateLoading()
46+
}
4547
store = TestStore(initialState: state) {
4648
SearchFeature()
4749
} withDependencies: {
@@ -93,6 +95,7 @@ struct SearchStoreTestAdapter {
9395

9496
func setSearchQuery(_ query: String) async {
9597
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
98+
let wasLoading = store.state.isLoading
9699
await store.send(.binding(.set(\.searchQuery, query))) {
97100
$0.searchQuery = query
98101
$0.showAllTodos = false
@@ -102,13 +105,11 @@ struct SearchStoreTestAdapter {
102105
$0.webPages = []
103106
}
104107
}
105-
await store.receive(.setLoading(false)) {
106-
$0.isLoading = false
108+
if wasLoading {
109+
await receiveEndLoading()
107110
}
108111
if !trimmed.isEmpty {
109-
await store.receive(.setLoading(true)) {
110-
$0.isLoading = true
111-
}
112+
await receiveBeginLoading()
112113
}
113114
}
114115

@@ -117,13 +118,12 @@ struct SearchStoreTestAdapter {
117118
}
118119

119120
func setSearching(_ value: Bool) async {
121+
let wasLoading = store.state.isLoading
120122
await store.send(.binding(.set(\.isSearching, value))) {
121123
$0.isSearching = value
122124
}
123-
if !value {
124-
await store.receive(.setLoading(false)) {
125-
$0.isLoading = false
126-
}
125+
if !value, wasLoading {
126+
await receiveEndLoading()
127127
}
128128
}
129129

@@ -135,25 +135,55 @@ struct SearchStoreTestAdapter {
135135
todos: [TodoListItem],
136136
webPages: [WebPageItem]
137137
) async {
138+
let wasLoading = store.state.isLoading
138139
await store.receive(.fetchTodos(todos)) {
139140
$0.todos = todos
140141
}
141142
await store.receive(.fetchWebPage(webPages)) {
142143
$0.webPages = webPages
143144
}
144-
await store.receive(.setLoading(false)) {
145-
$0.isLoading = false
145+
if wasLoading {
146+
await receiveEndLoading()
146147
}
147148
}
148149

149150
func receiveSearchFailure() async {
150-
await store.receive(.setLoading(false)) {
151-
$0.isLoading = false
151+
let wasLoading = store.state.isLoading
152+
if wasLoading {
153+
await receiveEndLoading()
152154
}
153155
await store.receive(.setAlert(true)) {
154156
$0.alert = expectedSearchErrorAlert()
155157
}
156158
}
159+
160+
private func receiveBeginLoading() async {
161+
await store.receive(.loading(.begin(target: .default, mode: .immediate))) {
162+
$0.loading.setImmediateLoading()
163+
}
164+
}
165+
166+
private func receiveEndLoading() async {
167+
await store.receive(.loading(.end(target: .default, mode: .immediate))) {
168+
$0.loading.setImmediateLoadingFinished()
169+
}
170+
}
171+
}
172+
173+
private extension LoadingFeature.State {
174+
mutating func setImmediateLoading() {
175+
let target = LoadingFeature.Target.default
176+
immediateCountByTarget[target] = 1
177+
visibleTargets.insert(target)
178+
isLoading = !visibleTargets.isEmpty
179+
}
180+
181+
mutating func setImmediateLoadingFinished() {
182+
let target = LoadingFeature.Target.default
183+
immediateCountByTarget[target] = 0
184+
visibleTargets.remove(target)
185+
isLoading = !visibleTargets.isEmpty
186+
}
157187
}
158188

159189
final class SearchFetchTodosUseCaseSpy: FetchTodosUseCase {

0 commit comments

Comments
 (0)