feat(home): 추천 배너 섹션을 연결한다
This commit is contained in:
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 참조 경로 갱신
|
||||
- 검증:
|
||||
|
||||
@@ -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` 또는 동등한 필드가 추가되는지, 아니면 서버에서 추천 라이브를 사전 필터링하는지 확인이 필요하다.
|
||||
|
||||
Reference in New Issue
Block a user