Skip to content

Commit 3392576

Browse files
authored
[#368] LoadingView 띄우는 형태를 LoadingState()로 관리하여 즉시 뜨는게 아닌 일정 시간 딜레이 후 그 동안에도 비동기 로딩 중이면 뜨도록 개선한다 (#369)
* refactor: 0.3초로 딜레이 수정 * refactor: .immediate 대신 .delay로 교체 * refactor: LoadingState 적용 * refactor: .immediate에서 .delay로 수정 및 디바운스 작업도 같이 끝나도록 개선 * chore: PR 템플릿 추가 * fix: Task가 취소되지 않았을 경우 로딩을 끝내버릴 수 있는 이슈 해결
1 parent 4553d7a commit 3392576

11 files changed

Lines changed: 133 additions & 72 deletions

.github/pull_request_template.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
## 🔗 연관된 이슈
2+
> 이슈 번호를 입력해주세요. (예: #12)
3+
> 이슈가 완전히 해결되었다면 아래에 예약어를 남겨주세요.
4+
- closed #이슈번호
5+
6+
## 📝 작업 내용
7+
8+
### 📌 요약
9+
10+
### 🔍 상세
11+
12+
## 📸 영상 / 이미지 (Optional)

DevLog/Presentation/Common/LoadingState.swift

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

28-
init(delay: Duration = .milliseconds(500)) {
28+
init(delay: Duration = .seconds(0.3)) {
2929
self.delay = delay
3030
}
3131

DevLog/Presentation/ViewModel/HomeViewModel.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,10 @@ final class HomeViewModel: Store {
157157
func run(_ effect: SideEffect) {
158158
switch effect {
159159
case .fetchTodoCategoryPreferences:
160-
beginLoading(for: .preferences, mode: .immediate)
160+
beginLoading(for: .preferences, mode: .delayed)
161161
Task {
162162
do {
163-
defer { endLoading(for: .preferences, mode: .immediate) }
163+
defer { endLoading(for: .preferences, mode: .delayed) }
164164
let preferences = try await fetchPreferencesUseCase.execute()
165165
send(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:))))
166166
} catch {
@@ -192,10 +192,10 @@ final class HomeViewModel: Store {
192192
}
193193
}
194194
case .fetchRecentTodos:
195-
beginLoading(for: .recentTodos, mode: .immediate)
195+
beginLoading(for: .recentTodos, mode: .delayed)
196196
Task {
197197
do {
198-
defer { endLoading(for: .recentTodos, mode: .immediate) }
198+
defer { endLoading(for: .recentTodos, mode: .delayed) }
199199
let page = try await fetchRecentTodos()
200200
let items = page.items
201201
.filter { $0.createdAt != $0.updatedAt }
@@ -254,10 +254,10 @@ final class HomeViewModel: Store {
254254
}
255255
}
256256
case .fetchWebPages:
257-
beginLoading(for: .webPage, mode: .immediate)
257+
beginLoading(for: .webPage, mode: .delayed)
258258
Task {
259259
do {
260-
defer { endLoading(for: .webPage, mode: .immediate) }
260+
defer { endLoading(for: .webPage, mode: .delayed) }
261261
let pages = try await fetchWebPagesUseCase.execute("")
262262
send(.updateWebPages(pages.map { WebPageItem(from: $0) }))
263263
} catch {

DevLog/Presentation/ViewModel/LoginViewModel.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ final class LoginViewModel: Store {
2727
}
2828

2929
private let signInUseCase: SignInUseCase
30+
private let loadingState = LoadingState()
3031

3132
private(set) var state = State()
3233

@@ -54,12 +55,12 @@ final class LoginViewModel: Store {
5455
}
5556

5657
func run(_ effect: SideEffect) {
57-
send(.setLoading(true))
5858
switch effect {
5959
case .signIn(let authProvider):
60+
beginLoading(.immediate)
6061
Task {
6162
do {
62-
defer { send(.setLoading(false)) }
63+
defer { endLoading(.immediate) }
6364
try await self.signInUseCase.execute(authProvider)
6465
} catch {
6566
if error.isSocialLoginCancelled { return }
@@ -79,4 +80,16 @@ private extension LoginViewModel {
7980
state.alertMessage = String(localized: "common_error_message")
8081
state.showAlert = isPresented
8182
}
83+
84+
func beginLoading(_ mode: LoadingState.Mode) {
85+
loadingState.begin(mode: mode) { [weak self] isLoading in
86+
self?.send(.setLoading(isLoading))
87+
}
88+
}
89+
90+
func endLoading(_ mode: LoadingState.Mode) {
91+
loadingState.end(mode: mode) { [weak self] isLoading in
92+
self?.send(.setLoading(isLoading))
93+
}
94+
}
8295
}

DevLog/Presentation/ViewModel/ProfileViewModel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,10 @@ final class ProfileViewModel: Store {
201201
}
202202
}
203203
case .fetchActivityQuarter(let quarterStart):
204-
beginLoading(mode: .immediate)
204+
beginLoading(mode: .delayed)
205205
Task {
206206
do {
207-
defer { endLoading(mode: .immediate) }
207+
defer { endLoading(mode: .delayed) }
208208
let quarterActivityData = try await fetchQuarterActivityData(from: quarterStart)
209209
send(
210210
.setActivityQuarter(

DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,10 @@ final class PushNotificationListViewModel: Store {
118118
if cursor == nil {
119119
stopObservingNotifications()
120120
}
121-
beginLoading(.immediate)
121+
beginLoading(.delayed)
122122
Task {
123123
do {
124-
defer { endLoading(.immediate) }
124+
defer { endLoading(.delayed) }
125125
let existingCount = cursor == nil ? 0 : self.state.notifications.count
126126

127127
let page = try await fetchUseCase.execute(query, cursor: cursor)
@@ -160,7 +160,7 @@ final class PushNotificationListViewModel: Store {
160160
beginLoading(.delayed)
161161
Task {
162162
// endLoading(.delayed)를 defer로 두지 않는 이유
163-
// send(.fetchNotifications)가 같은 턴에서 beginLoading(.immediate)를 먼저 올린 뒤
163+
// send(.fetchNotifications)가 같은 턴에서 beginLoading(.delayed)를 먼저 올린 뒤
164164
// delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문
165165
do {
166166
try await undoDeleteUseCase.execute(notificationId)

DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@ final class PushNotificationSettingsViewModel: Store {
107107
func run(_ effect: SideEffect) {
108108
switch effect {
109109
case .fetchPushNotificationSettings:
110-
beginLoading(.immediate)
110+
beginLoading(.delayed)
111111
Task {
112112
do {
113-
defer { endLoading(.immediate) }
113+
defer { endLoading(.delayed) }
114114
let settings = try await fetchPushSettingsUseCase.execute()
115115
self.send(.setPushNotificationEnable(settings.isEnabled))
116116
if let hour = settings.scheduledTime.hour,

DevLog/Presentation/ViewModel/SearchViewModel.swift

Lines changed: 45 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,16 @@ 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+
if !Task.isCancelled {
143+
self.endLoading(.immediate)
144+
}
145+
}
140146
let searchesTodoOnly = searchesTodoOnly(query)
141147
async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil)
142148
async let webPageItems = fetchWebPageItems(
@@ -145,12 +151,15 @@ final class SearchViewModel: Store {
145151
)
146152
let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) }
147153
let resolvedWebPageItems = try await webPageItems
154+
if Task.isCancelled { return }
148155
send(.fetchTodos(todoItems))
149156
send(.fetchWebPage(resolvedWebPageItems))
150157
} catch {
158+
if error is CancellationError { return }
151159
send(.setAlert(true))
152160
}
153161
}
162+
searchTasks[.request] = requestTask
154163
}
155164
}
156165
}
@@ -165,21 +174,36 @@ private extension SearchViewModel {
165174
state.showAlert = isPresented
166175
}
167176

168-
func scheduleDebouncedQuery(_ query: String) {
169-
searchDebounceTask?.cancel()
170-
searchDebounceTask = Task { [weak self] in
177+
func scheduleDebouncedFetch(_ query: String) {
178+
searchTasks[.debounce]?.cancel()
179+
let debounceTask = Task { [weak self] in
171180
guard let self else { return }
172181
try? await Task.sleep(for: .seconds(searchDebounceDelay))
173182
if Task.isCancelled { return }
174183
await MainActor.run {
184+
self.searchTasks[.debounce] = nil
175185
self.send(.applySearchQuery(query))
176186
}
177187
}
188+
searchTasks[.debounce] = debounceTask
189+
}
190+
191+
func cancelSearch() {
192+
searchTasks.values.forEach { $0.cancel() }
193+
searchTasks = [:]
194+
endLoading(.immediate)
178195
}
179196

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

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

DevLog/Presentation/ViewModel/TodayViewModel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,10 @@ final class TodayViewModel: Store {
189189
func run(_ effect: SideEffect) {
190190
switch effect {
191191
case .fetchTodos:
192-
beginLoading(.immediate)
192+
beginLoading(.delayed)
193193
Task {
194194
do {
195-
defer { endLoading(.immediate) }
195+
defer { endLoading(.delayed) }
196196
async let todosWithDueDatePage = fetchTodosUseCase.execute(
197197
TodoQuery(
198198
completionFilter: .incomplete,

DevLog/Presentation/ViewModel/TodoDetailViewModel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,10 @@ final class TodoDetailViewModel: Store {
9595
func run(_ effect: SideEffect) {
9696
switch effect {
9797
case .fetchTodo:
98-
beginLoading(.immediate)
98+
beginLoading(.delayed)
9999
Task {
100100
do {
101-
defer { endLoading(.immediate) }
101+
defer { endLoading(.delayed) }
102102
let todo = try await fetchTodoUseCase.execute(todoId)
103103
send(.setTodo(todo))
104104
} catch {

0 commit comments

Comments
 (0)