feat(content): 랭킹 유형 탭을 추가한다

This commit is contained in:
Yu Sung
2026-07-07 00:38:48 +09:00
parent 9c4c304fc6
commit b33aed94c6
6 changed files with 138 additions and 13 deletions

View File

@@ -3054,6 +3054,30 @@ If you block this user, the following features will be restricted.
pick(ko: "랭킹", en: "Ranking", ja: "ランキング")
}
static var weeklyPopularTabTitle: String {
pick(ko: "주간 인기", en: "Weekly popular", ja: "週間人気")
}
static var risingTabTitle: String {
pick(ko: "지금 뜨는 중", en: "Rising now", ja: "今話題")
}
static var revenueTabTitle: String {
pick(ko: "매출", en: "Revenue", ja: "売上")
}
static var salesCountTabTitle: String {
pick(ko: "판매량", en: "Sales", ja: "販売数")
}
static var commentCountTabTitle: String {
pick(ko: "댓글수", en: "Comments", ja: "コメント数")
}
static var likeCountTabTitle: String {
pick(ko: "좋아요", en: "Likes", ja: "いいね")
}
static var emptyStateMessage: String {
pick(
ko: "랭킹 콘텐츠가 아직 없어요.",

View File

@@ -14,15 +14,25 @@ struct MainContentRankingView: View {
Color.black
.ignoresSafeArea()
VStack(spacing: 0) {
MainContentRankingTypeTabBar(
items: AudioRankingType.allCases,
selectedItem: selectedTypeBinding,
title: { $0.title }
)
if viewModel.isLoading && !viewModel.hasLoaded {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.shouldShowEmptyState {
MainContentAudioRankingEmptyStateView(message: viewModel.emptyStateMessage)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
rankingContent
}
}
}
.onAppear {
if !viewModel.hasLoaded {
viewModel.fetchRankings()
@@ -93,6 +103,13 @@ struct MainContentRankingView: View {
[GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
}
private var selectedTypeBinding: Binding<AudioRankingType> {
Binding(
get: { viewModel.selectedType },
set: { viewModel.selectType($0) }
)
}
private var topItem: AudioRankingItemResponse? {
viewModel.items.first { $0.rank == 1 }
}
@@ -109,3 +126,54 @@ struct MainContentRankingView: View {
viewModel.items.filter { 11...20 ~= $0.rank }
}
}
private struct MainContentRankingTypeTabBar: View {
let items: [AudioRankingType]
@Binding var selectedItem: AudioRankingType
let title: (AudioRankingType) -> String
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .center, spacing: SodaSpacing.s8) {
ForEach(items, id: \.self) { item in
MainContentRankingTypeTabBarItem(
title: title(item),
isSelected: selectedItem == item,
action: { selectedItem = item }
)
}
}
.padding(.horizontal, SodaSpacing.s14)
}
.frame(maxWidth: .infinity)
.frame(height: 52, alignment: .center)
.background(Color.black)
}
}
private struct MainContentRankingTypeTabBarItem: View {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button {
action()
} label: {
Text(title)
.appFont(size: 14, weight: .medium)
.foregroundColor(isSelected ? Color.black : Color.white)
.lineLimit(1)
.padding(.horizontal, SodaSpacing.s12)
.padding(.vertical, SodaSpacing.s8)
.frame(height: 34)
.background(isSelected ? Color.white : Color.black)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(isSelected ? Color.white : Color.gray700, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}

View File

@@ -11,6 +11,7 @@ final class MainContentRankingViewModel: ObservableObject {
@Published var items = [AudioRankingItemResponse]()
@Published var showRankChange = false
@Published var hasLoaded = false
@Published var selectedType: AudioRankingType = .weeklyPopular
var emptyStateMessage: String {
if !errorMessage.isEmpty {
@@ -29,7 +30,7 @@ final class MainContentRankingViewModel: ObservableObject {
isLoading = true
repository.getAudioRankings()
repository.getAudioRankings(type: selectedType)
.sink { [weak self] result in
guard let self else { return }
@@ -77,4 +78,16 @@ final class MainContentRankingViewModel: ObservableObject {
}
.store(in: &subscription)
}
func selectType(_ type: AudioRankingType) {
guard selectedType != type else { return }
subscription.forEach { $0.cancel() }
subscription.removeAll()
selectedType = type
items = []
isLoading = false
hasLoaded = false
fetchRankings()
}
}

View File

@@ -13,6 +13,23 @@ enum AudioRankingType: String, Decodable, CaseIterable, Hashable {
case salesCount = "SALES_COUNT"
case commentCount = "COMMENT_COUNT"
case likeCount = "LIKE_COUNT"
var title: String {
switch self {
case .weeklyPopular:
return I18n.MainContentRanking.weeklyPopularTabTitle
case .rising:
return I18n.MainContentRanking.risingTabTitle
case .revenue:
return I18n.MainContentRanking.revenueTabTitle
case .salesCount:
return I18n.MainContentRanking.salesCountTabTitle
case .commentCount:
return I18n.MainContentRanking.commentCountTabTitle
case .likeCount:
return I18n.MainContentRanking.likeCountTabTitle
}
}
}
struct AudioRankingItemResponse: Decodable, Identifiable {

View File

@@ -3,7 +3,7 @@ import Foundation
import Moya
enum MainContentRankingApi {
case getAudioRankings
case getAudioRankings(type: AudioRankingType)
}
extension MainContentRankingApi: TargetType {
@@ -27,8 +27,11 @@ extension MainContentRankingApi: TargetType {
var task: Moya.Task {
switch self {
case .getAudioRankings:
return .requestPlain
case .getAudioRankings(let type):
return .requestParameters(
parameters: ["type": type.rawValue],
encoding: URLEncoding.queryString
)
}
}

View File

@@ -7,7 +7,7 @@ import Moya
final class MainContentRankingRepository {
private let api = MoyaProvider<MainContentRankingApi>()
func getAudioRankings() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getAudioRankings)
func getAudioRankings(type: AudioRankingType) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getAudioRankings(type: type))
}
}