Skip to content

Commit 4cad673

Browse files
committed
refactor: TodoDetail 로딩 상태를 LoadingFeature로 분리
1 parent 41fae5f commit 4cad673

4 files changed

Lines changed: 266 additions & 17 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//
2+
// LoadingFeature.swift
3+
// DevLogPresentation
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import ComposableArchitecture
9+
10+
@Reducer
11+
struct LoadingFeature {
12+
@ObservableState
13+
struct State: Equatable {
14+
var isLoading = false
15+
var immediateCountByTarget: [Target: Int] = [:]
16+
var delayedCountByTarget: [Target: Int] = [:]
17+
var scheduledDelayedTargets = Set<Target>()
18+
var visibleDelayedTargets = Set<Target>()
19+
var visibleTargets = Set<Target>()
20+
}
21+
22+
struct Target: Hashable, Sendable {
23+
static let `default` = Self("default")
24+
25+
let id: String
26+
27+
init(_ id: String) {
28+
self.id = id
29+
}
30+
}
31+
32+
enum Mode: Equatable, Sendable {
33+
case immediate
34+
case delayed
35+
}
36+
37+
enum Action: Equatable {
38+
case begin(target: Target, mode: Mode)
39+
case end(target: Target, mode: Mode)
40+
case delayedLoadingDidBecomeVisible(target: Target)
41+
}
42+
43+
private enum CancelID: Hashable {
44+
case delayedLoading(Target)
45+
}
46+
47+
@Dependency(\.continuousClock) var clock
48+
private let delay = Duration.seconds(0.3)
49+
50+
var body: some ReducerOf<Self> {
51+
Reduce { state, action in
52+
switch action {
53+
case .begin(let target, let mode):
54+
return begin(target: target, mode: mode, state: &state)
55+
case .end(let target, let mode):
56+
return end(target: target, mode: mode, state: &state)
57+
case .delayedLoadingDidBecomeVisible(let target):
58+
return delayedLoadingDidBecomeVisible(target: target, state: &state)
59+
}
60+
}
61+
}
62+
}
63+
64+
private extension LoadingFeature {
65+
func begin(
66+
target: Target,
67+
mode: Mode,
68+
state: inout State
69+
) -> Effect<Action> {
70+
switch mode {
71+
case .immediate:
72+
state.immediateCountByTarget[target, default: 0] += 1
73+
state.setVisibilityIfNeeded(for: target, isVisible: true)
74+
return .none
75+
case .delayed:
76+
state.delayedCountByTarget[target, default: 0] += 1
77+
return scheduleDelayedLoadingIfNeeded(for: target, state: &state)
78+
}
79+
}
80+
81+
func end(
82+
target: Target,
83+
mode: Mode,
84+
state: inout State
85+
) -> Effect<Action> {
86+
switch mode {
87+
case .immediate:
88+
let count = state.immediateCountByTarget[target, default: 0]
89+
state.immediateCountByTarget[target] = max(0, count - 1)
90+
case .delayed:
91+
let count = state.delayedCountByTarget[target, default: 0]
92+
state.delayedCountByTarget[target] = max(0, count - 1)
93+
}
94+
return updateLoadingVisibility(for: target, state: &state)
95+
}
96+
97+
func delayedLoadingDidBecomeVisible(
98+
target: Target,
99+
state: inout State
100+
) -> Effect<Action> {
101+
state.scheduledDelayedTargets.remove(target)
102+
guard 0 < state.delayedCountByTarget[target, default: 0] else { return .none }
103+
state.visibleDelayedTargets.insert(target)
104+
if state.immediateCountByTarget[target, default: 0] == 0 {
105+
state.setVisibilityIfNeeded(for: target, isVisible: true)
106+
}
107+
return .none
108+
}
109+
110+
func scheduleDelayedLoadingIfNeeded(
111+
for target: Target,
112+
state: inout State
113+
) -> Effect<Action> {
114+
guard !state.scheduledDelayedTargets.contains(target),
115+
!state.visibleDelayedTargets.contains(target),
116+
0 < state.delayedCountByTarget[target, default: 0] else { return .none }
117+
state.scheduledDelayedTargets.insert(target)
118+
return .run { [clock, delay] send in
119+
try await clock.sleep(for: delay)
120+
await send(.delayedLoadingDidBecomeVisible(target: target))
121+
}
122+
.cancellable(id: CancelID.delayedLoading(target), cancelInFlight: true)
123+
}
124+
125+
func updateLoadingVisibility(
126+
for target: Target,
127+
state: inout State
128+
) -> Effect<Action> {
129+
if 0 < state.immediateCountByTarget[target, default: 0] {
130+
state.setVisibilityIfNeeded(for: target, isVisible: true)
131+
return .none
132+
}
133+
if state.visibleDelayedTargets.contains(target) {
134+
if state.delayedCountByTarget[target, default: 0] == 0 {
135+
state.visibleDelayedTargets.remove(target)
136+
state.setVisibilityIfNeeded(for: target, isVisible: false)
137+
} else {
138+
state.setVisibilityIfNeeded(for: target, isVisible: true)
139+
}
140+
return .none
141+
}
142+
if 0 < state.delayedCountByTarget[target, default: 0] {
143+
state.setVisibilityIfNeeded(
144+
for: target,
145+
isVisible: state.visibleTargets.contains(target)
146+
)
147+
return scheduleDelayedLoadingIfNeeded(for: target, state: &state)
148+
}
149+
state.scheduledDelayedTargets.remove(target)
150+
state.setVisibilityIfNeeded(for: target, isVisible: false)
151+
return .cancel(id: CancelID.delayedLoading(target))
152+
}
153+
}
154+
155+
private extension LoadingFeature.State {
156+
mutating func setVisibilityIfNeeded(
157+
for target: LoadingFeature.Target,
158+
isVisible: Bool
159+
) {
160+
if isVisible {
161+
visibleTargets.insert(target)
162+
} else {
163+
visibleTargets.remove(target)
164+
}
165+
166+
isLoading = !visibleTargets.isEmpty
167+
}
168+
}

Application/DevLogPresentation/Sources/Home/Detail/TodoDetailFeature.swift

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ struct TodoDetailFeature {
2020
var showEditButton: Bool
2121
var todo: Todo?
2222
var referenceItems: [Int: TodoReferenceItem] = [:]
23-
var isLoading = false
23+
var loading = LoadingFeature.State()
24+
25+
var isLoading: Bool {
26+
loading.isLoading
27+
}
2428
}
2529

2630
@ObservableState
@@ -71,7 +75,7 @@ struct TodoDetailFeature {
7175
case setFullScreenCover(FullScreenCoverState?)
7276
case setTodo(Todo)
7377
case setReferenceItems([Int: TodoReferenceItem])
74-
case setLoading(Bool)
78+
case loading(LoadingFeature.Action)
7579

7680
@CasePathable
7781
enum Sheet {
@@ -82,9 +86,11 @@ struct TodoDetailFeature {
8286

8387
@Dependency(\.fetchTodoByIdUseCase) var fetchTodoUseCase
8488
@Dependency(\.fetchReferenceItemsUseCase) var fetchReferenceItemsUseCase
85-
private let loadingState = LoadingState()
8689

8790
var body: some ReducerOf<Self> {
91+
Scope(state: \.loading, action: \.loading) {
92+
LoadingFeature()
93+
}
8894
Reduce { state, action in
8995
switch action {
9096
case .sheet(.dismiss):
@@ -113,8 +119,8 @@ struct TodoDetailFeature {
113119
return resolveMarkdownEffect(content: todo.content)
114120
case .setReferenceItems(let items):
115121
state.referenceItems = items
116-
case .setLoading(let value):
117-
state.isLoading = value
122+
case .loading:
123+
break
118124
}
119125

120126
return .none
@@ -172,20 +178,14 @@ private enum FetchReferenceItemsUseCaseKey: DependencyKey {
172178

173179
private extension TodoDetailFeature {
174180
func fetchTodoEffect(todoId: String) -> Effect<Action> {
175-
.run { [fetchTodoUseCase, loadingState] send in
176-
await loadingState.begin(mode: .delayed) { isLoading in
177-
send(.setLoading(isLoading))
178-
}
181+
.run { [fetchTodoUseCase] send in
182+
await send(.loading(.begin(target: .default, mode: .delayed)))
179183
do {
180184
let todo = try await fetchTodoUseCase.execute(todoId)
181-
await loadingState.end(mode: .delayed) { isLoading in
182-
send(.setLoading(isLoading))
183-
}
185+
await send(.loading(.end(target: .default, mode: .delayed)))
184186
await send(.setTodo(todo))
185187
} catch {
186-
await loadingState.end(mode: .delayed) { isLoading in
187-
send(.setLoading(isLoading))
188-
}
188+
await send(.loading(.end(target: .default, mode: .delayed)))
189189
await send(.fetchFailed)
190190
}
191191
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//
2+
// LoadingFeatureTests.swift
3+
// DevLogPresentationTests
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import Testing
9+
import ComposableArchitecture
10+
@testable import DevLogPresentation
11+
12+
@MainActor
13+
struct LoadingFeatureTests {
14+
@Test("즉시 로딩은 시작하면 표시되고 종료하면 해제된다")
15+
func 즉시_로딩은_시작하면_표시되고_종료하면_해제된다() async {
16+
let target = LoadingFeature.Target.default
17+
let store = TestStore(initialState: LoadingFeature.State()) {
18+
LoadingFeature()
19+
}
20+
21+
await store.send(.begin(target: target, mode: .immediate)) {
22+
$0.immediateCountByTarget[target] = 1
23+
$0.visibleTargets = [target]
24+
$0.isLoading = true
25+
}
26+
27+
await store.send(.end(target: target, mode: .immediate)) {
28+
$0.immediateCountByTarget[target] = 0
29+
$0.visibleTargets = []
30+
$0.isLoading = false
31+
}
32+
}
33+
34+
@Test("지연 로딩은 delay가 지나기 전까지 표시되지 않는다")
35+
func 지연_로딩은_delay가_지나기_전까지_표시되지_않는다() async {
36+
let target = LoadingFeature.Target.default
37+
let clock = TestClock()
38+
let store = TestStore(initialState: LoadingFeature.State()) {
39+
LoadingFeature()
40+
} withDependencies: {
41+
$0.continuousClock = clock
42+
}
43+
44+
await store.send(.begin(target: target, mode: .delayed)) {
45+
$0.delayedCountByTarget[target] = 1
46+
$0.scheduledDelayedTargets = [target]
47+
}
48+
49+
await clock.advance(by: .milliseconds(299))
50+
51+
await clock.advance(by: .milliseconds(1))
52+
await store.receive(.delayedLoadingDidBecomeVisible(target: target)) {
53+
$0.scheduledDelayedTargets = []
54+
$0.visibleDelayedTargets = [target]
55+
$0.visibleTargets = [target]
56+
$0.isLoading = true
57+
}
58+
59+
await store.send(.end(target: target, mode: .delayed)) {
60+
$0.delayedCountByTarget[target] = 0
61+
$0.visibleDelayedTargets = []
62+
$0.visibleTargets = []
63+
$0.isLoading = false
64+
}
65+
}
66+
}

Application/DevLogPresentation/Tests/Home/TodoDetailFeatureTests.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,28 @@ struct TodoDetailFeatureTests {
9494

9595
@Test("Todo 조회가 지연되면 로딩 상태를 표시하고 완료되면 해제한다")
9696
func Todo_조회가_지연되면_로딩_상태를_표시하고_완료되면_해제한다() async {
97+
let clock = TestClock()
9798
let fetchSpy = FetchTodoByIdUseCaseSpy(todo: makeTodo())
9899
fetchSpy.shouldSuspend = true
99100
let adapter = TodoDetailStoreTestAdapter(
100101
fetchUseCase: fetchSpy,
101102
referenceUseCase: FetchReferenceItemsUseCaseSpy(),
102-
todoId: "todo-1"
103+
todoId: "todo-1",
104+
configureDependencies: {
105+
$0.continuousClock = clock
106+
}
103107
)
104108

105109
adapter.onAppear()
106110

111+
await waitUntil {
112+
fetchSpy.todoIds == ["todo-1"]
113+
}
114+
115+
#expect(!adapter.isLoading)
116+
117+
await clock.advance(by: .milliseconds(300))
118+
107119
await waitUntil {
108120
adapter.isLoading
109121
}
@@ -221,7 +233,8 @@ private struct TodoDetailStoreTestAdapter: TodoDetailTestAdapter {
221233
fetchUseCase: FetchTodoByIdUseCase,
222234
referenceUseCase: FetchReferenceItemsUseCase,
223235
todoId: String,
224-
showEditButton: Bool = true
236+
showEditButton: Bool = true,
237+
configureDependencies: ((inout DependencyValues) -> Void)? = nil
225238
) {
226239
store = Store(
227240
initialState: TodoDetailFeature.State(
@@ -233,6 +246,8 @@ private struct TodoDetailStoreTestAdapter: TodoDetailTestAdapter {
233246
} withDependencies: {
234247
$0.fetchTodoByIdUseCase = fetchUseCase
235248
$0.fetchReferenceItemsUseCase = referenceUseCase
249+
$0.continuousClock = ContinuousClock()
250+
configureDependencies?(&$0)
236251
}
237252
}
238253

0 commit comments

Comments
 (0)