feat(home): 추천 배너 섹션을 연결한다
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user