diff --git a/DevLog/Presentation/Common/LoadingState.swift b/DevLog/Presentation/Common/LoadingState.swift new file mode 100644 index 0000000..7a73065 --- /dev/null +++ b/DevLog/Presentation/Common/LoadingState.swift @@ -0,0 +1,173 @@ +// +// LoadingState.swift +// DevLog +// +// Created by opfic on 3/16/26. +// + +import Foundation + +@MainActor +final class LoadingState { + private enum DefaultTarget: Hashable { + case value + } + + enum Mode { + case immediate + case delayed + } + + private let delay: Duration + private var immediateCountByTarget: [AnyHashable: Int] = [:] + private var delayedCountByTarget: [AnyHashable: Int] = [:] + private var delayedTaskByTarget: [AnyHashable: Task] = [:] + private var visibleDelayedTargets = Set() + private var visibleTargets = Set() + + init(delay: Duration = .milliseconds(500)) { + self.delay = delay + } + + func begin( + mode: Mode, + update: @escaping @MainActor (Bool) -> Void + ) { + begin(target: DefaultTarget.value, mode: mode) { _, isLoading in + update(isLoading) + } + } + + func begin( + target: T, + mode: Mode, + update: @escaping @MainActor (T, Bool) -> Void + ) { + let hashableTarget = AnyHashable(target) + begin(target: hashableTarget, mode: mode) { isLoading in + update(target, isLoading) + } + } + + func end( + mode: Mode, + update: @escaping @MainActor (Bool) -> Void + ) { + end(target: DefaultTarget.value, mode: mode) { _, isLoading in + update(isLoading) + } + } + + func end( + target: T, + mode: Mode, + update: @escaping @MainActor (T, Bool) -> Void + ) { + let hashableTarget = AnyHashable(target) + end(target: hashableTarget, mode: mode) { isLoading in + update(target, isLoading) + } + } + + private func begin( + target: AnyHashable, + mode: Mode, + update: @escaping @MainActor (Bool) -> Void + ) { + switch mode { + case .immediate: + immediateCountByTarget[target, default: 0] += 1 + setVisibilityIfNeeded(for: target, isVisible: true, update: update) + case .delayed: + delayedCountByTarget[target, default: 0] += 1 + scheduleDelayedLoadingIfNeeded(for: target, update: update) + } + } + + private func end( + target: AnyHashable, + mode: Mode, + update: @escaping @MainActor (Bool) -> Void + ) { + switch mode { + case .immediate: + let count = immediateCountByTarget[target, default: 0] + immediateCountByTarget[target] = max(0, count - 1) + case .delayed: + let count = delayedCountByTarget[target, default: 0] + delayedCountByTarget[target] = max(0, count - 1) + } + updateLoadingVisibility(for: target, update: update) + } + + private func scheduleDelayedLoadingIfNeeded( + for target: AnyHashable, + update: @escaping @MainActor (Bool) -> Void + ) { + guard delayedTaskByTarget[target] == nil, + !visibleDelayedTargets.contains(target), + 0 < delayedCountByTarget[target, default: 0] else { return } + delayedTaskByTarget[target] = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(for: delay) + if Task.isCancelled { return } + await MainActor.run { + self.delayedTaskByTarget[target] = nil + guard 0 < self.delayedCountByTarget[target, default: 0] else { return } + self.visibleDelayedTargets.insert(target) + if self.immediateCountByTarget[target, default: 0] == 0 { + self.setVisibilityIfNeeded(for: target, isVisible: true, update: update) + } + } + } + } + + private func updateLoadingVisibility( + for target: AnyHashable, + update: @escaping @MainActor (Bool) -> Void + ) { + if 0 < immediateCountByTarget[target, default: 0] { + setVisibilityIfNeeded(for: target, isVisible: true, update: update) + return + } + if visibleDelayedTargets.contains(target) { + if delayedCountByTarget[target, default: 0] == 0 { + visibleDelayedTargets.remove(target) + setVisibilityIfNeeded(for: target, isVisible: false, update: update) + } else { + setVisibilityIfNeeded(for: target, isVisible: true, update: update) + } + return + } + if 0 < delayedCountByTarget[target, default: 0] { + if visibleTargets.contains(target) { + setVisibilityIfNeeded(for: target, isVisible: true, update: update) + } else { + setVisibilityIfNeeded(for: target, isVisible: false, update: update) + } + scheduleDelayedLoadingIfNeeded(for: target, update: update) + return + } + delayedTaskByTarget[target]?.cancel() + delayedTaskByTarget[target] = nil + setVisibilityIfNeeded(for: target, isVisible: false, update: update) + } + + private func setVisibilityIfNeeded( + for target: AnyHashable, + isVisible: Bool, + update: @escaping @MainActor (Bool) -> Void + ) { + let wasVisible = visibleTargets.contains(target) + + if isVisible { + visibleTargets.insert(target) + } else { + visibleTargets.remove(target) + } + + if wasVisible != isVisible { + update(isVisible) + } + } +} diff --git a/DevLog/Presentation/ViewModel/AccountViewModel.swift b/DevLog/Presentation/ViewModel/AccountViewModel.swift index 5f2efd9..7125316 100644 --- a/DevLog/Presentation/ViewModel/AccountViewModel.swift +++ b/DevLog/Presentation/ViewModel/AccountViewModel.swift @@ -55,6 +55,7 @@ final class AccountViewModel: Store { private let fetchProvidersUseCase: FetchAuthProvidersUseCase private let linkProviderUseCase: LinkAuthProviderUseCase private let unlinkProviderUseCase: UnlinkAuthProviderUseCase + private let loadingState = LoadingState() init( fetchProvidersUseCase: FetchAuthProvidersUseCase, @@ -106,11 +107,10 @@ final class AccountViewModel: Store { } } case .link(let provider): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) - + defer { endLoading(.delayed) } try await linkProviderUseCase.execute(provider) send(.setToast(isPresented: true, type: .linkSuccess)) @@ -122,11 +122,10 @@ final class AccountViewModel: Store { } } case .unlink(let provider): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) - + defer { endLoading(.delayed) } try await unlinkProviderUseCase.execute(provider) send(.setToast(isPresented: true, type: .unlinkSuccess)) @@ -192,4 +191,16 @@ private extension AccountViewModel { state.showToast = isPresented state.toastType = type } + + private func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + private func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } } diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index c525bed..7b42c43 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -81,7 +81,7 @@ final class HomeViewModel: Store { case searchView } - enum LoadingTarget { + enum LoadingTarget: Hashable { case recentTodos case webPage case overlay @@ -94,6 +94,7 @@ final class HomeViewModel: Store { private let undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase private let fetchTodosUseCase: FetchTodosUseCase private let fetchWebPagesUseCase: FetchWebPagesUseCase + private let loadingState = LoadingState() private var deletedWebPageURLString: String? init( @@ -133,10 +134,10 @@ final class HomeViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .addTodo(let todo): + beginLoading(for: .overlay, mode: .delayed) Task { do { - defer { send(.setLoading(.overlay, false)) } - send(.setLoading(.overlay, true)) + defer { endLoading(for: .overlay, mode: .delayed) } try await upsertTodoUseCase.execute(todo) let page = try await fetchRecentTodos() let items = page.items @@ -149,10 +150,10 @@ final class HomeViewModel: Store { } } case .fetchRecentTodos: + beginLoading(for: .recentTodos, mode: .immediate) Task { do { - defer { send(.setLoading(.recentTodos, false)) } - send(.setLoading(.recentTodos, true)) + defer { endLoading(for: .recentTodos, mode: .immediate) } let page = try await fetchRecentTodos() let items = page.items .filter { $0.createdAt != $0.updatedAt } @@ -164,10 +165,10 @@ final class HomeViewModel: Store { } } case .addWebPage(let urlString): + beginLoading(for: .overlay, mode: .delayed) Task { do { - defer { send(.setLoading(.overlay, false)) } - send(.setLoading(.overlay, true)) + defer { endLoading(for: .overlay, mode: .delayed) } try await addWebPageUseCase.execute(urlString) let pages = try await fetchWebPagesUseCase.execute("") send(.updateWebPages(pages.map { WebPageItem(from: $0) })) @@ -176,10 +177,10 @@ final class HomeViewModel: Store { } } case .deleteWebPage(let page, let index): + beginLoading(for: .webPage, mode: .delayed) Task { do { - defer { send(.setLoading(.webPage, false)) } - send(.setLoading(.webPage, true)) + defer { endLoading(for: .webPage, mode: .delayed) } try await deleteWebPageUseCase.execute(page.url.absoluteString) } catch { send(.restoreWebPage(page, index)) @@ -187,9 +188,9 @@ final class HomeViewModel: Store { } } case .undoDeleteWebPage(let urlString): + beginLoading(for: .webPage, mode: .delayed) Task { - defer { send(.setLoading(.webPage, false)) } - send(.setLoading(.webPage, true)) + defer { endLoading(for: .webPage, mode: .delayed) } var shouldPresentError = false @@ -211,10 +212,10 @@ final class HomeViewModel: Store { } } case .fetchWebPages: + beginLoading(for: .webPage, mode: .immediate) Task { do { - defer { send(.setLoading(.webPage, false)) } - send(.setLoading(.webPage, true)) + defer { endLoading(for: .webPage, mode: .immediate) } let pages = try await fetchWebPagesUseCase.execute("") send(.updateWebPages(pages.map { WebPageItem(from: $0) })) } catch { @@ -411,4 +412,22 @@ private extension HomeViewModel { cursor: nil ) } + + private func beginLoading( + for target: LoadingTarget, + mode: LoadingState.Mode + ) { + loadingState.begin(target: target, mode: mode) { [weak self] target, isLoading in + self?.send(.setLoading(target, isLoading)) + } + } + + private func endLoading( + for target: LoadingTarget, + mode: LoadingState.Mode + ) { + loadingState.end(target: target, mode: mode) { [weak self] target, isLoading in + self?.send(.setLoading(target, isLoading)) + } + } } diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index 251a405..db32d5d 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -60,6 +60,7 @@ final class PushNotificationListViewModel: Store { private let toggleReadUseCase: TogglePushNotificationReadUseCase private let fetchQueryUseCase: FetchPushNotificationQueryUseCase private let updateQueryUseCase: UpdatePushNotificationQueryUseCase + private let loadingState = LoadingState() private var undoDeleteNotificationId: String? private var cancellable: AnyCancellable? @@ -117,10 +118,10 @@ final class PushNotificationListViewModel: Store { if cursor == nil { stopObservingNotifications() } + beginLoading(.immediate) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.immediate) } let existingCount = cursor == nil ? 0 : self.state.notifications.count let page = try await fetchUseCase.execute(query, cursor: cursor) @@ -145,10 +146,10 @@ final class PushNotificationListViewModel: Store { } case .delete(let item, let index): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.delayed) } try await deleteUseCase.execute(item.id) } catch { send(.restoreNotification(item, index)) @@ -156,11 +157,11 @@ final class PushNotificationListViewModel: Store { } } case .undoDelete(let notificationId): + beginLoading(.delayed) Task { - // defer을 통해 setLoading을 false로 제어하지 않는 이유 - // send(.fetchNotifications)를 통해 false로 처리될 것이기 때문 - send(.setLoading(true)) - + // endLoading(.delayed)를 defer로 두지 않는 이유 + // send(.fetchNotifications)가 같은 턴에서 beginLoading(.immediate)를 먼저 올린 뒤 + // delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문 do { try await undoDeleteUseCase.execute(notificationId) } catch { @@ -168,12 +169,13 @@ final class PushNotificationListViewModel: Store { } send(.fetchNotifications) + endLoading(.delayed) } case .toggleRead(let todoId): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.delayed) } try await toggleReadUseCase.execute(todoId) } catch { send(.setAlert(isPresented: true)) @@ -338,6 +340,18 @@ private extension PushNotificationListViewModel { cancellable?.cancel() cancellable = nil } + + private func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + private func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } } extension PushNotificationQuery.SortOrder { diff --git a/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift index 870ea7f..25acef3 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift @@ -50,6 +50,7 @@ final class PushNotificationSettingsViewModel: Store { private let calendar = Calendar.current private let fetchPushSettingsUseCase: FetchPushSettingsUseCase private let updatePushSettingsUseCase: UpdatePushSettingsUseCase + private let loadingState = LoadingState() init( fetchPushSettingsUseCase: FetchPushSettingsUseCase, @@ -106,10 +107,10 @@ final class PushNotificationSettingsViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchPushNotificationSettings: + beginLoading(.immediate) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.immediate) } let settings = try await fetchPushSettingsUseCase.execute() self.send(.setPushNotificationEnable(settings.isEnabled)) if let hour = settings.scheduledTime.hour, @@ -122,10 +123,10 @@ final class PushNotificationSettingsViewModel: Store { } } case .updatePushNotificationSettings: + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.delayed) } let dateComponents = calendar.dateComponents( [.hour, .minute], from: state.sheetPushNotificationTime @@ -153,4 +154,16 @@ extension PushNotificationSettingsViewModel { state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." state.showAlert = isPresented } + + private func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + private func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } } diff --git a/DevLog/Presentation/ViewModel/SettingViewModel.swift b/DevLog/Presentation/ViewModel/SettingViewModel.swift index 3b4d629..d83997d 100644 --- a/DevLog/Presentation/ViewModel/SettingViewModel.swift +++ b/DevLog/Presentation/ViewModel/SettingViewModel.swift @@ -46,6 +46,7 @@ final class SettingViewModel: Store { private let sessionUseCase: AuthSessionUseCase private let observeSystemThemeUseCase: ObserveSystemThemeUseCase private let updateSystemThemeUseCase: UpdateSystemThemeUseCase + private let loadingState = LoadingState() private var cancellables = Set() let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String @@ -104,11 +105,11 @@ final class SettingViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .deleteAuth: + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } send(.setAlert(isPresented: false)) - send(.setLoading(true)) + defer { endLoading(.delayed) } try await deleteAuthuseCase.execute() sessionUseCase.execute(false) } catch { @@ -116,11 +117,11 @@ final class SettingViewModel: Store { } } case .signOut: + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } send(.setAlert(isPresented: false)) - send(.setLoading(true)) + defer { endLoading(.delayed) } try await signOutUseCase.execute() sessionUseCase.execute(false) } catch { @@ -205,6 +206,18 @@ private extension SettingViewModel { return total } + private func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + private func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + private func clearCacheDirectory() throws { let cachesDir = try FileManager.default.url( for: .cachesDirectory, diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift index 403391d..6ce7a62 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -70,6 +70,7 @@ final class TodayViewModel: Store { private let fetchTodoByIdUseCase: FetchTodoByIdUseCase private let upsertTodoUseCase: UpsertTodoUseCase private let updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase + private let loadingState = LoadingState() init( fetchTodosUseCase: FetchTodosUseCase, @@ -141,10 +142,10 @@ final class TodayViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchTodos: + beginLoading(.immediate) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.immediate) } async let todosWithDueDatePage = fetchTodosUseCase.execute( TodoQuery( completionFilter: .incomplete, @@ -175,10 +176,10 @@ final class TodayViewModel: Store { } } case .completeTodo(let item): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.delayed) } var todo = try await fetchTodoByIdUseCase.execute(item.id) let now = Date() todo.isCompleted = true @@ -191,10 +192,10 @@ final class TodayViewModel: Store { } } case .togglePinned(let item): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.delayed) } var todo = try await fetchTodoByIdUseCase.execute(item.id) todo.isPinned.toggle() todo.updatedAt = Date() @@ -340,6 +341,18 @@ private extension TodayViewModel { return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: Date()) } + private func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + private func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + func isDueSoon(_ item: TodayTodoItem) -> Bool { guard let dueDate = item.dueDate else { return false } let startOfToday = calendar.startOfDay(for: Date()) diff --git a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift index c8bd24f..853498e 100644 --- a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift @@ -39,6 +39,7 @@ final class TodoDetailViewModel: Store { private let fetchUseCase: FetchTodoByIdUseCase private let upsertUseCase: UpsertTodoUseCase private let todoId: String + private let loadingState = LoadingState() init( fetchUseCase: FetchTodoByIdUseCase, @@ -80,10 +81,10 @@ final class TodoDetailViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchTodo: + beginLoading(.immediate) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.immediate) } let todo = try await fetchUseCase.execute(todoId) send(.setTodo(todo)) } catch { @@ -91,10 +92,10 @@ final class TodoDetailViewModel: Store { } } case .upsertTodo(let todo): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.delayed) } try await upsertUseCase.execute(todo) send(.setTodo(todo)) } catch { @@ -114,4 +115,16 @@ private extension TodoDetailViewModel { state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." state.showAlert = isPresented } + + private func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + private func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } } diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index b5480c5..1106152 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -82,6 +82,7 @@ final class TodoListViewModel: Store { private let upsertTodoUseCase: UpsertTodoUseCase private let deleteTodoUseCase: DeleteTodoUseCase private let undoDeleteTodoUseCase: UndoDeleteTodoUseCase + private let loadingState = LoadingState() private var undoDeleteTodoId: String? private var nextCursor: TodoCursor? @@ -141,10 +142,10 @@ final class TodoListViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetch: + beginLoading(.immediate) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.immediate) } let page = try await fetchTodosUseCase.execute(state.query, cursor: nil) send(.resetPagination) send(.appendTodos(page.items.map { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) @@ -155,10 +156,10 @@ final class TodoListViewModel: Store { } } case .loadNextPage: + beginLoading(.immediate) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.immediate) } let page = try await fetchTodosUseCase.execute(state.query, cursor: nextCursor) send(.appendTodos(page.items.map { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) let hasMore = page.items.count == state.query.pageSize && page.nextCursor != nil @@ -168,10 +169,10 @@ final class TodoListViewModel: Store { } } case .search(let keyword): + beginLoading(.immediate) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.immediate) } let query = TodoQuery(kind: state.kind, keyword: keyword) let page = try await fetchTodosUseCase.execute(query, cursor: nil) send(.fetchSearchResults(page.items.map { TodoListItem(from: $0) })) @@ -180,10 +181,10 @@ final class TodoListViewModel: Store { } } case .upsert(let item): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.delayed) } try await upsertTodoUseCase.execute(item) send(.refresh) } catch { @@ -191,10 +192,10 @@ final class TodoListViewModel: Store { } } case .toggleCompleted(let item): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.delayed) } var todo = try await fetchTodoByIdUseCase.execute(item.id) let now = Date() todo.isCompleted.toggle() @@ -207,10 +208,10 @@ final class TodoListViewModel: Store { } } case .togglePinned(let item): + beginLoading(.delayed) Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { endLoading(.delayed) } var todo = try await fetchTodoByIdUseCase.execute(item.id) todo.isPinned.toggle() todo.updatedAt = Date() @@ -230,11 +231,11 @@ final class TodoListViewModel: Store { } } case .undoDelete(let todoId): + beginLoading(.delayed) Task { - // defer을 통해 setLoading을 false로 제어하지 않는 이유 - // send(.refresh)를 통해 false로 처리될 것이기 때문 - send(.setLoading(true)) - + // endLoading(.delayed)를 defer로 두지 않는 이유 + // send(.refresh)가 같은 턴에서 beginLoading(.immediate)를 먼저 올린 뒤 + // delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문 do { try await undoDeleteTodoUseCase.execute(todoId) } catch { @@ -242,6 +243,7 @@ final class TodoListViewModel: Store { } send(.refresh) + endLoading(.delayed) } } } @@ -424,6 +426,18 @@ private extension TodoListViewModel { searchDebounceTask?.cancel() searchDebounceTask = nil } + + private func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + private func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } } extension TodoQuery.SortTarget {