Skip to content

Commit f4521ee

Browse files
authored
[#570] TodoDetailView에 TCA를 적용한다 (#588)
* feat: TodoDetailFeature 리듀서 추가 * refactor: TodoDetailViewModel을 TodoDetailFeature로 대체 * refactor: TodoDetail Store 조립 방식 정리 * refactor: TodoDetail 시트 Store 스코프 정리 * refactor: TodoDetail 시트 상태 enum 정리 * refactor: Bindable 적용 * chore: 폴더링 수정 * refactor: TodoDetail 로딩 상태를 LoadingFeature로 분리
1 parent d49b7e9 commit f4521ee

18 files changed

Lines changed: 1017 additions & 264 deletions
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/Common/LoadingState.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public final class LoadingState {
2626
private var visibleDelayedTargets = Set<AnyHashable>()
2727
private var visibleTargets = Set<AnyHashable>()
2828

29-
init(delay: Duration = .seconds(0.3)) {
29+
nonisolated init(delay: Duration = .seconds(0.3)) {
3030
self.delay = delay
3131
}
3232

Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageFeature.swift renamed to Application/DevLogPresentation/Sources/Home/Category/CategoryManageFeature.swift

File renamed without changes.

Application/DevLogPresentation/Sources/Home/CategoryManage/CategoryManageView.swift renamed to Application/DevLogPresentation/Sources/Home/Category/CategoryManageView.swift

File renamed without changes.

0 commit comments

Comments
 (0)