From bdd885a0825fbf2bb941af4a85c5627b3b6c84a8 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 18 Aug 2026 13:30:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(content):=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=ED=83=AD=20=EB=8B=B9=EA=B2=A8=EC=84=9C=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Main/Content/All/MainContentAllView.swift | 36 ++++++++++++++++--- .../Content/All/MainContentAllViewModel.swift | 13 +++++-- .../Ranking/MainContentRankingView.swift | 36 ++++++++++++++++--- .../Ranking/MainContentRankingViewModel.swift | 24 ++++++++----- .../MainContentRecommendationView.swift | 36 ++++++++++++++++--- .../MainContentRecommendationViewModel.swift | 20 +++++++---- 6 files changed, 135 insertions(+), 30 deletions(-) diff --git a/SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift b/SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift index 3a55503d..2ee42776 100644 --- a/SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift +++ b/SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift @@ -70,11 +70,9 @@ struct MainContentAllView: View { sortBar if viewModel.isLoading && viewModel.hasLoaded == false { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .frame(maxWidth: .infinity, maxHeight: .infinity) + loadingContent } else if viewModel.shouldShowEmptyState { - MainContentAudioEmptyStateView(message: I18n.MainContentAll.emptyStateMessage) + emptyContent } else { ScrollView(showsIndicators: false) { gridContent @@ -88,6 +86,35 @@ struct MainContentAllView: View { .padding(.vertical, SodaSpacing.s20) } } + .refreshable { + await viewModel.refresh() + } + } + } + } + + private var loadingContent: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(maxWidth: .infinity) + .frame(minHeight: geometry.size.height) + } + .refreshable { + await viewModel.refresh() + } + } + } + + private var emptyContent: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + MainContentAudioEmptyStateView(message: I18n.MainContentAll.emptyStateMessage) + .frame(minHeight: geometry.size.height) + } + .refreshable { + await viewModel.refresh() } } } @@ -197,4 +224,3 @@ struct MainContentAllView_Previews: PreviewProvider { ) } } - diff --git a/SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift b/SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift index ba8bea6a..d4e8202f 100644 --- a/SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift +++ b/SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift @@ -32,7 +32,7 @@ final class MainContentAllViewModel: ObservableObject { } func fetchFirstPageIfNeeded() { - guard hasLoaded == false else { return } + guard hasLoaded == false, isLoading == false else { return } fetchFirstPage() } @@ -40,6 +40,16 @@ final class MainContentAllViewModel: ObservableObject { fetchContents(page: 0, isNextPage: false) } + func refresh() async { + if !isLoading { + fetchFirstPage() + } + + for await isLoading in $isLoading.values { + if !isLoading { return } + } + } + func apply(type: MainContentAllType, sort: ContentSort) { guard allowedSorts(for: type).contains(sort) else { return } @@ -247,4 +257,3 @@ extension SeriesPublishedDaysOfWeek { } } } - diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift b/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift index 2427ccf7..85a658f0 100644 --- a/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift +++ b/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift @@ -22,12 +22,9 @@ struct MainContentRankingView: View { ) if viewModel.isLoading && !viewModel.hasLoaded { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .frame(maxWidth: .infinity, maxHeight: .infinity) + loadingContent } else if viewModel.shouldShowEmptyState { - MainContentAudioRankingEmptyStateView(message: viewModel.emptyStateMessage) - .frame(maxWidth: .infinity, maxHeight: .infinity) + emptyContent } else { rankingContent } @@ -41,6 +38,20 @@ struct MainContentRankingView: View { .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) } + private var loadingContent: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(maxWidth: .infinity) + .frame(minHeight: geometry.size.height) + } + .refreshable { + await viewModel.refresh() + } + } + } + private var rankingContent: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: SodaSpacing.s8) { @@ -93,6 +104,21 @@ struct MainContentRankingView: View { .padding(.horizontal, SodaSpacing.s8) .padding(.vertical, SodaSpacing.s20) } + .refreshable { + await viewModel.refresh() + } + } + + private var emptyContent: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + MainContentAudioRankingEmptyStateView(message: viewModel.emptyStateMessage) + .frame(minHeight: geometry.size.height) + } + .refreshable { + await viewModel.refresh() + } + } } private var twoColumns: [GridItem] { diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift b/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift index 265c6f29..462ebe14 100644 --- a/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift +++ b/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift @@ -32,19 +32,17 @@ final class MainContentRankingViewModel: ObservableObject { repository.getAudioRankings(type: selectedType) .sink { [weak self] result in - guard let self else { return } - switch result { case .finished: DEBUG_LOG("finish") case .failure(let error): ERROR_LOG(error.localizedDescription) - self.items = [] - self.showRankChange = false - self.errorMessage = I18n.MainContentRanking.loadFailedMessage - self.isShowPopup = true - self.isLoading = false - self.hasLoaded = true + self?.items = [] + self?.showRankChange = false + self?.errorMessage = I18n.MainContentRanking.loadFailedMessage + self?.isShowPopup = true + self?.isLoading = false + self?.hasLoaded = true } } receiveValue: { [weak self] response in guard let self else { return } @@ -79,6 +77,16 @@ final class MainContentRankingViewModel: ObservableObject { .store(in: &subscription) } + func refresh() async { + if !isLoading { + fetchRankings() + } + + for await isLoading in $isLoading.values { + if !isLoading { return } + } + } + func selectType(_ type: AudioRankingType) { guard selectedType != type else { return } diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift index 235b3957..99e7f35a 100644 --- a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift @@ -17,11 +17,10 @@ struct MainContentRecommendationView: View { Color.black .ignoresSafeArea() - if viewModel.isLoading && viewModel.recommendations == nil { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) + if viewModel.isLoading && !viewModel.hasLoaded { + loadingContent } else if viewModel.shouldShowEmptyState { - MainContentAudioEmptyStateView(message: viewModel.emptyStateMessage) + emptyContent } else { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: SodaSpacing.s24) { @@ -81,6 +80,9 @@ struct MainContentRecommendationView: View { } .padding(.vertical, SodaSpacing.s20) } + .refreshable { + await viewModel.refresh() + } } } .onAppear { @@ -94,4 +96,30 @@ struct MainContentRecommendationView: View { autohideIn: 2 ) } + + private var loadingContent: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(maxWidth: .infinity) + .frame(minHeight: geometry.size.height) + } + .refreshable { + await viewModel.refresh() + } + } + } + + private var emptyContent: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + MainContentAudioEmptyStateView(message: viewModel.emptyStateMessage) + .frame(minHeight: geometry.size.height) + } + .refreshable { + await viewModel.refresh() + } + } + } } diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift index e89f894b..6cd926fb 100644 --- a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift @@ -30,17 +30,15 @@ final class MainContentRecommendationViewModel: ObservableObject { repository.getAudioRecommendations() .sink { [weak self] result in - guard let self else { return } - switch result { case .finished: DEBUG_LOG("finish") case .failure(let error): ERROR_LOG(error.localizedDescription) - self.errorMessage = I18n.MainContentRecommendation.loadFailedMessage - self.isShowPopup = true - self.isLoading = false - self.hasLoaded = true + self?.errorMessage = I18n.MainContentRecommendation.loadFailedMessage + self?.isShowPopup = true + self?.isLoading = false + self?.hasLoaded = true } } receiveValue: { [weak self] response in guard let self else { return } @@ -69,4 +67,14 @@ final class MainContentRecommendationViewModel: ObservableObject { } .store(in: &subscription) } + + func refresh() async { + if !isLoading { + fetchRecommendations() + } + + for await isLoading in $isLoading.values { + if !isLoading { return } + } + } }