feat(home): 추천 배너 섹션을 연결한다

This commit is contained in:
Yu Sung
2026-06-29 15:39:23 +09:00
parent c441fb0b02
commit 0e31c69be8
10 changed files with 454 additions and 41 deletions

View File

@@ -214,8 +214,11 @@
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; };
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; };
401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
401A3FC62FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; };
401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
401A3FC72FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; };
401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */; };
40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
40235DF02E37983D00B09A87 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEF2E37983D00B09A87 /* PersistenceController.swift */; };
@@ -1730,6 +1733,7 @@
401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingView.swift; sourceTree = "<group>"; };
401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingView.swift; sourceTree = "<group>"; };
401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLiveSection.swift; sourceTree = "<group>"; };
401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBannerSection.swift; sourceTree = "<group>"; };
40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = "<group>"; };
40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = "<group>"; };
@@ -2702,6 +2706,7 @@
isa = PBXGroup;
children = (
401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */,
401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */,
);
path = Components;
sourceTree = "<group>";
@@ -5328,6 +5333,7 @@
401A3FA82FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FA92FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
401A3FC62FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */,
401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */,
@@ -6075,6 +6081,7 @@
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
401A3FC72FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */,
401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */,
@@ -6849,6 +6856,7 @@
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
401A3FC82FEEB00200A5BBA3 /* MainHomeBannerSection.swift in Sources */,
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */,

View File

@@ -11,48 +11,205 @@ struct BannerCarouselItem: Identifiable, Hashable {
}
struct BannerCarousel: View {
private static let maxItemCount = 20
private static let autoScrollInterval: TimeInterval = 5
private static let transitionDuration: TimeInterval = 0.35
let items: [BannerCarouselItem]
let height: CGFloat
let action: (BannerCarouselItem) -> Void
@State private var displayIndex = 1
@GestureState private var dragOffset: CGFloat = 0
@State private var autoScrollTimer: Timer?
@State private var isSwiping = false
init(
items: [BannerCarouselItem],
height: CGFloat = 120,
action: @escaping (BannerCarouselItem) -> Void = { _ in }
) {
self.items = items
self.height = height
self.items = Array(items.prefix(BannerCarousel.maxItemCount))
self.action = action
}
var body: some View {
if !items.isEmpty {
TabView {
ForEach(items) { item in
Button {
action(item)
} label: {
DownsampledKFImage(
url: URL(string: item.imageUrl ?? ""),
size: CGSize(width: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2), height: height)
)
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
GeometryReader { proxy in
let itemWidth = proxy.size.width - (SodaSpacing.s20 * 2)
let itemHeight = itemWidth
let step = itemWidth + SodaSpacing.s8
let leadingInset = (proxy.size.width - itemWidth) / 2
let effectiveIndex = items.count > 1 ? displayIndex : 0
let offset = leadingInset - (CGFloat(effectiveIndex) * step) + dragOffset
HStack(spacing: SodaSpacing.s8) {
ForEach(Array(visibleItems.enumerated()), id: \.offset) { _, item in
Button {
guard !isSwiping else { return }
action(item)
} label: {
DownsampledKFImage(
url: URL(string: item.imageUrl ?? ""),
size: CGSize(width: itemWidth, height: itemHeight)
)
.background(Color.gray800)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
.buttonStyle(.plain)
.frame(width: itemWidth, height: itemHeight)
}
.buttonStyle(.plain)
.padding(.horizontal, SodaSpacing.s20)
}
.offset(x: offset)
.frame(width: proxy.size.width, height: itemHeight, alignment: .leading)
.animation(.easeOut(duration: BannerCarousel.transitionDuration), value: displayIndex)
.clipped()
.overlay(alignment: .topTrailing) {
pageIndicator
.padding(.top, SodaSpacing.s14)
.padding(.trailing, SodaSpacing.s20 + SodaSpacing.s14)
}
.contentShape(Rectangle())
.simultaneousGesture(dragGesture(step: step))
.onChange(of: items) { _ in
displayIndex = items.count > 1 ? 1 : 0
restartAutoScrollTimer()
}
}
.frame(height: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2))
.onAppear {
displayIndex = items.count > 1 ? 1 : 0
restartAutoScrollTimer()
}
.onDisappear {
stopAutoScrollTimer()
}
}
}
private var visibleItems: [BannerCarouselItem] {
guard items.count > 1, let first = items.first, let last = items.last else {
return items
}
return [last] + items + [first]
}
private var currentPage: Int {
guard items.count > 1 else { return items.isEmpty ? 0 : 1 }
if displayIndex == 0 {
return items.count
}
if displayIndex == items.count + 1 {
return 1
}
return displayIndex
}
private var pageIndicator: some View {
HStack(alignment: .center, spacing: 1) {
Text(String(format: "%02d", currentPage))
.foregroundColor(.white)
.frame(width: 20)
Text("/")
.foregroundColor(.gray400)
Text(String(format: "%02d", items.count))
.foregroundColor(.gray400)
.frame(width: 20)
}
.appFont(.body3)
.padding(.horizontal, SodaSpacing.s6)
.padding(.vertical, SodaSpacing.s4)
.background(Color.black.opacity(0.7))
.clipShape(Capsule())
}
private func dragGesture(step: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 10)
.updating($dragOffset) { value, state, _ in
state = value.translation.width
}
.onChanged { _ in
if !isSwiping {
isSwiping = true
}
}
.onEnded { value in
defer {
DispatchQueue.main.async {
isSwiping = false
}
}
guard items.count > 1 else { return }
let threshold = step * 0.2
if value.translation.width < -threshold {
displayIndex += 1
} else if value.translation.width > threshold {
displayIndex -= 1
}
wrapIfNeeded()
restartAutoScrollTimer()
}
}
private func restartAutoScrollTimer() {
stopAutoScrollTimer()
guard items.count > 1 else { return }
autoScrollTimer = Timer.scheduledTimer(
withTimeInterval: BannerCarousel.autoScrollInterval,
repeats: true
) { _ in
displayIndex += 1
wrapIfNeeded()
}
}
private func stopAutoScrollTimer() {
autoScrollTimer?.invalidate()
autoScrollTimer = nil
}
private func wrapIfNeeded() {
guard items.count > 1 else { return }
if displayIndex == 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
displayIndex = items.count
}
}
} else if displayIndex == items.count + 1 {
DispatchQueue.main.asyncAfter(deadline: .now() + BannerCarousel.transitionDuration) {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
displayIndex = 1
}
}
.frame(height: height)
.tabViewStyle(.page(indexDisplayMode: items.count > 1 ? .automatic : .never))
}
}
}
struct BannerCarousel_Previews: PreviewProvider {
static var previews: some View {
BannerCarousel(items: [BannerCarouselItem(id: "1", imageUrl: nil)])
.background(Color.black)
.previewLayout(.sizeThatFits)
BannerCarousel(
items: [
BannerCarouselItem(id: "1", imageUrl: "https://picsum.photos/500/500"),
BannerCarouselItem(id: "2", imageUrl: "https://picsum.photos/500/500"),
BannerCarouselItem(id: "3", imageUrl: "https://picsum.photos/500/500")
]
)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -9,6 +9,7 @@ struct MainHomeView: View {
let onTapContent: (Int) -> Void
let onTapCharacter: (Int) -> Void
let onTapCommunity: (Int) -> Void
let onTapBanner: (RecommendationBannerResponse) -> Void
@State private var selectedTab: MainHomeTab = .recommendation
@@ -48,7 +49,8 @@ struct MainHomeView: View {
onTapCreator: onTapCreator,
onTapContent: onTapContent,
onTapCharacter: onTapCharacter,
onTapCommunity: onTapCommunity
onTapCommunity: onTapCommunity,
onTapBanner: onTapBanner
)
case .ranking:
MainHomeRankingView()
@@ -85,7 +87,8 @@ struct MainHomeView_Previews: PreviewProvider {
onTapCreator: { _ in },
onTapContent: { _ in },
onTapCharacter: { _ in },
onTapCommunity: { _ in }
onTapCommunity: { _ in },
onTapBanner: { _ in }
)
}
}

View File

@@ -0,0 +1,83 @@
import SwiftUI
struct MainHomeBannerSection: View {
let items: [RecommendationBannerResponse]
let onTapBanner: (RecommendationBannerResponse) -> Void
init(
items: [RecommendationBannerResponse],
onTapBanner: @escaping (RecommendationBannerResponse) -> Void = { _ in }
) {
self.items = items
self.onTapBanner = onTapBanner
}
var body: some View {
if !items.isEmpty {
BannerCarousel(items: carouselItems) { item in
guard let banner = banner(for: item) else { return }
onTapBanner(banner)
}
}
}
private var carouselItems: [BannerCarouselItem] {
items.enumerated().map { index, item in
BannerCarouselItem(
id: fallbackId(for: item, index: index),
imageUrl: item.imageUrl
)
}
}
private func banner(for carouselItem: BannerCarouselItem) -> RecommendationBannerResponse? {
items.enumerated().first { index, item in
fallbackId(for: item, index: index) == carouselItem.id
}?.element
}
private func fallbackId(for item: RecommendationBannerResponse, index: Int) -> String {
if let eventItem = item.eventItem {
return "banner-\(index)-event-\(eventItem.id)"
}
if let creatorId = item.creatorId {
return "banner-\(index)-creator-\(creatorId)"
}
if let seriesId = item.seriesId {
return "banner-\(index)-series-\(seriesId)"
}
if let link = item.link, !link.isEmpty {
return "banner-\(index)-link-\(link)"
}
return "banner-\(index)-image-\(item.imageUrl)"
}
}
struct MainHomeBannerSection_Previews: PreviewProvider {
static var previews: some View {
MainHomeBannerSection(
items: [
RecommendationBannerResponse(
imageUrl: "https://picsum.photos/500/500",
eventItem: nil,
creatorId: nil,
seriesId: nil,
link: nil
),
RecommendationBannerResponse(
imageUrl: "https://picsum.photos/500/500",
eventItem: nil,
creatorId: 1,
seriesId: nil,
link: nil
)
]
)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -55,7 +55,7 @@ private struct MainHomeLiveItemView: View {
}
struct MainHomeLiveSection_Previews: PreviewProvider {
private static let previewImageUrl = "https://placehold.co/500"
private static let previewImageUrl = "https://picsum.photos/500/500"
static var previews: some View {
Group {

View File

@@ -6,6 +6,7 @@ struct MainHomeRecommendationView: View {
let onTapContent: (Int) -> Void
let onTapCharacter: (Int) -> Void
let onTapCommunity: (Int) -> Void
let onTapBanner: (RecommendationBannerResponse) -> Void
@StateObject private var viewModel = MainHomeRecommendationViewModel()
@@ -24,6 +25,11 @@ struct MainHomeRecommendationView: View {
items: viewModel.recommendations?.lives ?? [],
onTapLive: onTapLive
)
MainHomeBannerSection(
items: viewModel.recommendations?.banners ?? [],
onTapBanner: onTapBanner
)
}
.padding(.vertical, SodaSpacing.s20)
}
@@ -49,7 +55,8 @@ struct MainHomeRecommendationView_Previews: PreviewProvider {
onTapCreator: { _ in },
onTapContent: { _ in },
onTapCharacter: { _ in },
onTapCommunity: { _ in }
onTapCommunity: { _ in },
onTapBanner: { _ in }
)
}
}

View File

@@ -19,14 +19,11 @@ struct HomeLiveItem: Decodable, Hashable {
}
struct RecommendationBannerResponse: Decodable, Hashable {
let bannerId: Int?
let type: String?
let title: String?
let imageUrl: String?
let link: String?
let eventId: Int?
let imageUrl: String
let eventItem: EventItem?
let creatorId: Int?
let seriesId: Int?
let link: String?
}
struct HomeActiveCreatorItem: Decodable, Hashable {

View File

@@ -161,7 +161,8 @@ struct MainView: View {
onTapCreator: handleRecommendationCreatorTap,
onTapContent: handleRecommendationContentTap,
onTapCharacter: handleRecommendationCharacterTap,
onTapCommunity: handleRecommendationCommunityTap
onTapCommunity: handleRecommendationCommunityTap,
onTapBanner: handleRecommendationBannerTap
)
case .content:
MainPlaceholderTabView(title: MainTab.content.title)
@@ -478,6 +479,41 @@ struct MainView: View {
}
}
private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) {
if let eventItem = item.eventItem {
performRecommendationDetailAction {
appState.setAppStep(step: .eventDetail(event: eventItem))
}
return
}
if let creatorId = item.creatorId {
handleRecommendationCreatorTap(creatorId: creatorId)
return
}
if let seriesId = item.seriesId {
performRecommendationDetailAction {
appState.setAppStep(step: .seriesDetail(seriesId: seriesId))
}
return
}
openRecommendationBannerLink(item.link)
}
private func openRecommendationBannerLink(_ link: String?) {
guard let link = link?.trimmingCharacters(in: .whitespacesAndNewlines),
!link.isEmpty,
let url = URL(string: link),
UIApplication.shared.canOpenURL(url) else {
return
}
UIApplication.shared.open(url)
}
private func performRecommendationDetailAction(
requiresAdultContent: Bool = false,
action: @escaping () -> Void

View File

@@ -242,18 +242,70 @@
### Phase 9: 배너 섹션 UI
- [ ] **Task 9.1: 추천 배너 섹션 작성/보완**
- [x] **Task 9.1: BannerCarousel 현재 구현과 Figma 요구사항 고정**
- 대상 파일:
- 생성/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift`
- 확인/보완: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
- 확인/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift`
- 작업 내용:
- `banners: List<RecommendationBannerResponse>`를 표시할 Swift 모델을 확정한다.
- 기존 `BannerCarousel`로 충분하면 재사용하고, 홈 전용 wrapper만 작성한다.
- Figma 기준 노드는 `24:5525`로 고정한다.
- `BannerCarousel`이 현재 `TabView(.page)` 기반 단일 페이지 노출 구조인지 확인한다.
- `RecommendationBannerResponse``imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`가 표시와 이동에 매핑 가능한지 확인한다.
- 배너 이동 규칙은 미확정이면 탭 액션을 임의 구현하지 않는다.
- 검증 기준:
- 실행 명령: `rg "RecommendationBannerResponse|HomeBanner|BannerCarousel|MainHomeBannerSection" SodaLive/Sources/V2/Main/Home/Recommendation SodaLive/Sources/V2/Component/Banner`
- 기대 결과: 배너 응답 모델과 화면 조합이 확인된다.
- 실행 명령: `rg "struct BannerCarousel|BannerCarouselItem|RecommendationBannerResponse|eventItem|creatorId|seriesId|link|imageUrl" SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift`
- 기대 결과: 기존 캐러셀 구조와 배너 응답 모델 필드를 확인할 수 있다.
- [x] **Task 9.2: BannerCarousel을 Figma와 동일한 수평 캐러셀로 보완**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
- 작업 내용:
- 배너 width는 `device width - 40`으로 계산한다.
- 배너 이미지 영역은 1:1 비율을 유지한다.
- 배너 사이 간격은 8로 둔다.
- 현재 배너 좌우에 이전/다음 배너가 조금씩 보이도록 수평 inset과 item 배치를 조정한다.
- 배너가 2개 이상이면 무한 스크롤이 가능하도록 첫/마지막 경계에서 자연스럽게 이어지게 처리한다.
- 배너 corner radius는 Figma 기준 `14`를 유지한다.
- Figma처럼 우측 상단에 현재/전체 페이지 인디케이터를 표시한다.
- 인디케이터는 `01 / 20` 형식으로 두 자리 current/total을 표시하고, 배경은 반투명 검정 pill 형태로 둔다.
- 로컬 Figma asset URL(`localhost:3845`)은 앱 코드에 넣지 않는다.
- 검증 기준:
- 실행 명령: `rg "SodaSpacing.s8|SodaSpacing.s14|BannerCarousel|GeometryReader|ScrollView|TabView|01|current|total|localhost:3845" SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
- 기대 결과: 간격 8, radius 14, 페이지 인디케이터, 무한/수평 캐러셀 구현 근거가 확인되고 `localhost:3845`는 검색되지 않아야 한다.
- 수동 확인: iPhone 15 기준 현재 배너 폭이 화면 폭보다 40 작고, 좌우에 이전/다음 배너 일부가 보여야 한다.
- 수동 확인: 이미지 영역이 정사각형으로 표시되고 배너 간 간격이 8로 보여야 한다.
- [x] **Task 9.3: 추천 홈 배너 섹션 작성/보완**
- 대상 파일:
- 생성/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift`
- 수정: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift`
- 확인/보완: `SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift`
- 작업 내용:
- `HomeRecommendationResponse.banners``BannerCarouselItem` 배열로 변환한다.
- `eventItem`, `creatorId`, `seriesId`, `link`, `imageUrl` 순서로 안정적인 carousel id를 만든다.
- `imageUrl`이 비어 있는 배너는 이미지 placeholder 정책을 기존 `BannerCarousel` 동작과 맞춘다.
- 데이터가 비어 있으면 배너 섹션 자체를 표시하지 않는다.
- 기존 `MainHomeRecommendationView` 섹션 순서를 유지하면서 현재 라이브 섹션 다음 위치에 배너 섹션을 배치한다.
- 배너 이동 규칙은 미확정이면 탭 액션을 임의 구현하지 않는다.
- 검증 기준:
- 실행 명령: `rg "MainHomeBannerSection|BannerCarouselItem|BannerCarousel|banners|RecommendationBannerResponse" SodaLive/Sources/V2/Main/Home/Recommendation SodaLive/Sources/V2/Component/Banner`
- 기대 결과: 추천 응답의 `banners`가 홈 배너 섹션을 통해 `BannerCarousel`에 연결되어야 한다.
- [x] **Task 9.4: 배너 섹션 빌드 및 시각 회귀 검증**
- 대상 파일:
- 확인: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift`
- 확인: `SodaLive.xcodeproj/project.pbxproj`
- 작업 내용:
- 새로 만든 `MainHomeBannerSection.swift`가 빌드 대상에 포함되는지 확인한다.
- 파일 시스템 동기화 방식으로 자동 포함되면 프로젝트 파일을 수정하지 않는다.
- 프로젝트 파일 수정이 필요한 경우에만 최소 변경한다.
- 검증 기준:
- 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj`
- 기대 결과: `OK`
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: `BUILD SUCCEEDED`
- 수동 확인: 추천 홈에서 배너가 Figma 노드 `24:5525`처럼 좌우 배너 일부 노출, 간격 8, 1:1 이미지, 우측 상단 인디케이터 형태로 표시되어야 한다.
### Phase 10: 최근 활동 크리에이터 섹션 UI
@@ -444,6 +496,57 @@
## 검증 기록
### 2026-06-29 Phase 9 배너 스펙 변경 반영
- 목적: 변경된 `RecommendationBannerResponse` 백엔드 응답과 배너 1:1 이미지 비율 요구사항을 반영
- 수행 내용:
- `RecommendationBannerResponse`에서 과거 `bannerId`, `type`, `title`, `eventId` 필드를 제거하고 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link` 구조로 변경
- 배너 탭 시 `eventItem`, `creatorId`, `seriesId`, `link` 순서로 첫 non-optional 목적지를 찾아 이동하도록 연결
- `eventItem``eventDetail`, `creatorId``creatorDetail`, `seriesId``seriesDetail`, `link`는 외부 URL open 경로를 사용
- `BannerCarousel` 높이를 `itemWidth`와 동일하게 되돌려 이미지 영역 1:1 비율 유지
- `BannerCarousel_Previews`, `MainHomeBannerSection_Previews`, `MainHomeLiveSection_Previews`의 Preview 원격 이미지를 `placehold.co` 대신 JPEG를 반환하는 `picsum.photos/500/500`로 변경
- 검증:
- `rg -n "bannerId|eventId|RecommendationBannerResponse\(|\.type|\.title" SodaLive/Sources/V2/Main/Home SodaLive/Sources/V2/Main/MainView.swift` 실행, V2 추천 배너 코드에서 과거 응답 필드 참조 제거 확인
- `curl -L -o /dev/null -w "%{http_code} %{content_type}\n" "https://placehold.co/500"` 실행, `200 image/svg+xml` 확인
- `curl -L -o /dev/null -w "%{http_code} %{content_type}\n" "https://picsum.photos/500/500"` 실행, `200 image/jpeg` 확인
- `rg -n "placehold\.co/500" SodaLive/Sources/V2/Main/Home/Recommendation/Components SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` 실행, 검색 결과 없음 확인
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인
- `git diff --check` 실행, 출력 없이 성공 확인
### 2026-06-29 Phase 9 배너 섹션 구현 완료
- 목적: V2 Main Home 추천 탭에 Figma `24:5525` 기준 배너 캐러셀과 추천 배너 섹션을 연결
- 수행 내용:
- `BannerCarousel``device width - 40` 폭의 1:1 배너, 간격 8, radius 14, 좌우 인접 배너 일부 노출, 우측 상단 `01 / NN` pill 인디케이터 구조로 보완
- 배너가 2개 이상이면 첫/마지막에 복제 아이템을 두어 경계에서 이어지는 수평 캐러셀 동작을 구현
- `MainHomeBannerSection`을 추가해 `RecommendationBannerResponse.imageUrl``BannerCarouselItem`으로 매핑하고, 원본 배너를 상위 탭 콜백으로 전달하며, 데이터가 비어 있으면 섹션을 렌더링하지 않도록 구성
- `MainHomeRecommendationView`에서 현재 라이브 섹션 다음에 배너 섹션을 배치
- 배너 이동 규칙은 미확정 상태로 유지하고 탭 액션을 새로 구현하지 않음
- 리뷰어 지적에 따라 무한 캐러셀 경계 reset은 animation 비활성 transaction으로 처리하고, 인디케이터 trailing 위치를 현재 배너 내부 기준으로 보정
- 신규 `MainHomeBannerSection.swift``SodaLive.xcodeproj/project.pbxproj` 빌드 대상에 포함
- 검증:
- Figma `24:5525` 디자인 컨텍스트와 스크린샷 확인, 3개 수평 배너/간격 8/radius 14/우측 상단 `01 / 20` 인디케이터 확인
- `rg "SodaSpacing\.s8|SodaSpacing\.s14|BannerCarousel|GeometryReader|ScrollView|TabView|01|current|total|localhost:3845" SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` 실행, 간격/radius/수평 캐러셀/페이지 인디케이터 근거 확인 및 `localhost:3845` 미포함 확인
- `rg "MainHomeBannerSection|BannerCarouselItem|BannerCarousel|banners|RecommendationBannerResponse" SodaLive/Sources/V2/Main/Home/Recommendation SodaLive/Sources/V2/Component/Banner` 실행, 추천 응답 `banners`가 배너 섹션과 `BannerCarousel`에 연결됨 확인
- `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인
- `git diff --check` 실행, 출력 없이 성공 확인
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, `BUILD SUCCEEDED` 확인
- 전용 테스트 타깃이 검색되지 않아 새 테스트 인프라를 추가하지 않고 계획서의 정적/빌드/수동 시각 기준으로 검증
### 2026-06-29 Phase 9 배너 섹션 Task 세분화
- 목적: `BannerCarousel`을 Figma `24:5525` 기준으로 구현할 수 있도록 Phase 9 작업을 실행 가능한 단위로 세분화
- 수행 내용:
- 기존 단일 `Task 9.1``Task 9.1`~`Task 9.4`로 분리
- `BannerCarousel` 현재 구조 확인, Figma 레이아웃 보완, 추천 홈 배너 섹션 연결, 빌드/시각 검증 단계로 분리
- 무한 스크롤, 좌우 배너 일부 노출, 배너 간격 8, 이미지 1:1 비율, 배너 width `device width - 40`, 우측 상단 페이지 인디케이터 요구사항 반영
- Figma 컨텍스트에서 확인한 radius 14와 `01 / 20` 형태 인디케이터를 구현 요구사항에 반영
- 검증:
- Figma `24:5525` 디자인 컨텍스트와 스크린샷 확인, 수평 배너 3개 구성과 우측 상단 `01 / 20` 인디케이터 확인
- `rg "Task 9\\.[1-4]|device width - 40|1:1|간격은 8|무한 스크롤|24:5525|01 / 20" docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md` 실행, Phase 9 세부 Task와 핵심 배너 요구사항 반영 확인
- `rg "### Phase [0-9]+:|- \\[[ x]\\] \\*\\*Task [0-9]+\\.[0-9]+:" docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md` 실행, Phase/Task 형식 확인
- `git diff --check -- docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md` 실행, 출력 없이 성공 확인
### 2026-06-29 Phase 8.1 현재 라이브 섹션 구현 완료
- 목적: V2 Main Home 추천 탭에 현재 라이브 섹션을 API 응답 데이터 기반으로 표시
@@ -469,7 +572,7 @@
- 수행 내용:
- PRD 응답 스펙에서 `?`가 없는 필드를 Swift non-optional로 변경
- `targetId`, `coverImage`, `profileImage`, `originalWorkTitle`, `creatorProfileImage`, `imageUrl`, `audioUrl` 등 PRD nullable 필드는 optional 유지
- `RecommendationBannerResponse`상세 PRD 스펙이 별도로 확정되지 않아 기존 optional 선언 유지
- `RecommendationBannerResponse`백엔드 변경 스펙에 맞춰 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link` 기준으로 정렬
- `HomeLiveItem.creatorNickname`, `creatorProfileImage`가 non-optional이 되면서 라이브 섹션의 불필요한 nil fallback 제거
- 검증:
- `rg -n "let (roomId|activityType|activityAt|contentId|isPointAvailable|characterId|totalChatCount|genreName|creators|postId|createdAt|likeCount|commentCount|existOrdered): .*\\?" SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift` 실행, 검색 결과 없음 확인 예정
@@ -512,7 +615,7 @@
- 목적: Phase 2.2 리뷰에서 지적된 과거 추정 응답 필드 혼재를 제거하고, 추천 전용 request 모델 위치를 `Recommendation/Models` 구조로 일치
- 수행 내용:
- `MainHomeRecommendationResponse.swift`에서 PRD 응답 스펙에 없는 legacy/추정 필드(`liveRoomId`, `profileImageUrl`, `thumbnailUrl`, `coverImageUrl`, `releaseDate`, `originalTitle` 등) 제거
- 배너는 상세 PRD 스펙이 별도로 없으므로 기존 표시/이동 판단에 필요한 `RecommendationBannerResponse` 최소 필드 유지
- 배너는 변경된 백엔드 스펙에 맞춰 `eventItem`, `creatorId`, `seriesId`, `link` 중 첫 non-optional 값으로 이동 판단
- `FollowRecommendedCreatorsRequest.swift``SodaLive/Sources/V2/Main/Home/Recommendation/Models` 아래로 이동
- `SodaLive.xcodeproj/project.pbxproj`의 request/model/API/Repository 참조 경로 갱신
- 검증:

View File

@@ -120,6 +120,25 @@ data class HomeGenreCreatorGroupItem(
val creators: List<HomeCreatorItem>
)
#### RecommendationBannerResponse
```kotlin
data class RecommendationBannerResponse(
val imageUrl: String,
val eventItem: EventItem?,
val creatorId: Long?,
val seriesId: Long?,
val link: String?
)
```
배너 탭 이동은 `eventItem`, `creatorId`, `seriesId`, `link` 순서로 첫 non-null 값을 사용한다.
- `eventItem` -> 이벤트 상세
- `creatorId` -> 크리에이터 상세
- `seriesId` -> 시리즈 상세
- `link` -> 외부 URL 열기
data class HomePopularCommunityPostItem(
val postId: Long,
val creatorId: Long,
@@ -312,7 +331,7 @@ MainHomeView
- 빌드가 성공하고, 가능하면 ViewModel 디코딩/상태 전환 검증을 수행한다.
## 11. Open Questions
- `RecommendationBannerResponse`의 필드 목록과 배너 `type`별 이동 규칙 확인이 필요하다.
- `RecommendationBannerResponse`는 변경된 백엔드 스펙 기준으로 `eventItem`, `creatorId`, `seriesId`, `link` 순서의 이동 규칙을 사용한다.
- 각 섹션별 최대 표시 개수와 가로/세로 스크롤 정책이 Figma 기준 그대로인지, 서버 응답 전체를 모두 표시해야 하는지 확인이 필요하다.
- `HomeFirstAudioContentItem`에서 오리지널 작품 여부와 크리에이터의 첫 번째 콘텐츠 여부를 판단할 서버 필드가 추가되는지 확인이 필요하다.
- `HomeLiveItem`에 민감/성인 라이브 여부를 판단할 `isAdult` 또는 동등한 필드가 추가되는지, 아니면 서버에서 추천 라이브를 사전 필터링하는지 확인이 필요하다.