feat(recommendation): 추천 서브 배너와 연속 순환을 추가한다

This commit is contained in:
Yu Sung
2026-10-30 12:19:00 +09:00
parent 88f923072c
commit 5dcd0f0104
18 changed files with 1045 additions and 2 deletions
@@ -0,0 +1,42 @@
import Foundation
struct SubBanner: Decodable, Hashable, Identifiable {
enum Placement: String, Decodable {
case homeRecommendation = "HOME_RECOMMENDATION"
case contentRecommendation = "CONTENT_RECOMMENDATION"
}
let id: Int
let placement: Placement
let imageUrl: String
let webUrl: String?
let androidDeepLink: String?
let iosDeepLink: String?
var imageURL: URL? { Self.webURL(imageUrl) }
var webURL: URL? { Self.webURL(webUrl) }
var iosURL: URL? {
guard let url = Self.absoluteURL(iosDeepLink),
let scheme = url.scheme?.lowercased(),
!["javascript", "data", "file", "content"].contains(scheme) else { return nil }
return url
}
var isEligible: Bool {
imageURL != nil && (iosURL != nil || webURL != nil)
}
private static func absoluteURL(_ raw: String?) -> URL? {
guard let raw, let url = URL(string: raw),
url.scheme != nil, url.absoluteString == raw else { return nil }
return url
}
private static func webURL(_ raw: String?) -> URL? {
guard let url = absoluteURL(raw),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
let host = url.host, !host.isEmpty else { return nil }
return url
}
}
@@ -0,0 +1,158 @@
import SwiftUI
import Kingfisher
struct SubBannerCarousel: View {
let items: [SubBanner]
let onTap: (SubBanner) -> Void
@Environment(\.scenePhase) private var scenePhase
@State private var displayIndex = 1
@State private var itemsRevision = 0
@State private var containerWidth: CGFloat = 0
@State private var imageRatios: [String: CGFloat] = [:]
@State private var lastKnownRatio: CGFloat?
@GestureState private var isSwiping = false
@GestureState private var dragOffset: CGFloat = 0
private static let autoScrollNanoseconds: UInt64 = 5_000_000_000
private static let transitionDuration: TimeInterval = 0.35
private var safeDisplayIndex: Int {
items.count > 1 ? min(max(displayIndex, 0), items.count + 1) : 0
}
private var safeIndex: Int {
items.count > 1 ? (safeDisplayIndex + items.count - 1) % items.count : 0
}
private var pages: [(id: String, item: SubBanner)] {
let originals = items.map { (id: "item-\($0.id)", item: $0) }
guard items.count > 1, let first = items.first, let last = items.last else { return originals }
return [(id: "leading-\(last.id)", item: last)] + originals + [(id: "trailing-\(first.id)", item: first)]
}
private var currentRatio: CGFloat? {
guard !items.isEmpty else { return nil }
return imageRatios[items[safeIndex].imageUrl]
}
private var height: CGFloat {
guard !items.isEmpty, let ratio = currentRatio ?? lastKnownRatio else { return 0 }
return containerWidth / ratio
}
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
ForEach(pages, id: \.id) { page in
Button {
onTap(page.item)
} label: {
KFImage(page.item.imageURL)
.placeholder { Color.gray800 }
.onSuccess { result in
let size = result.image.size
guard size.width > 0, size.height > 0 else { return }
imageRatios[page.item.imageUrl] = size.width / size.height
}
.startLoadingBeforeViewAppear()
.resizable()
.scaledToFit()
.frame(width: geometry.size.width, height: height)
.background(Color.gray800)
}
.buttonStyle(SubBannerTapStyle(drag: dragGesture(width: geometry.size.width)))
.accessibilityLabel(Text("\(safeIndex + 1) / \(items.count)"))
.accessibilityHidden(pages[safeDisplayIndex].id != page.id)
}
}
.offset(x: -CGFloat(safeDisplayIndex) * geometry.size.width + dragOffset)
.animation(.easeOut(duration: Self.transitionDuration), value: displayIndex)
.frame(width: geometry.size.width, height: height, alignment: .leading)
.contentShape(Rectangle())
.clipped()
.onAppear { containerWidth = geometry.size.width }
.onChange(of: geometry.size.width) { containerWidth = $0 }
}
.frame(height: height)
.onChange(of: currentRatio) { ratio in
if let ratio { lastKnownRatio = ratio }
}
.onChange(of: items) { _ in
itemsRevision += 1
normalize(to: items.count > 1 ? 1 : 0)
if items.isEmpty { lastKnownRatio = nil }
}
.onDisappear {
itemsRevision += 1
normalize(to: items.count > 1 ? safeIndex + 1 : 0)
}
.task(id: scenePhase == .active && items.count > 1 ? items : []) {
guard scenePhase == .active, items.count > 1 else { return }
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: Self.autoScrollNanoseconds)
} catch {
return
}
guard !Task.isCancelled else { return }
if !isSwiping { move(by: 1) }
}
}
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: move(by: 1)
case .decrement: move(by: -1)
@unknown default: break
}
}
}
private func dragGesture(width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: SodaSpacing.s12, coordinateSpace: .global)
.updating($dragOffset) { value, offset, _ in
if items.count > 1, abs(value.translation.width) > abs(value.translation.height) {
offset = value.translation.width
}
}
.updating($isSwiping) { _, swiping, _ in swiping = true }
.onEnded { value in
guard abs(value.translation.width) > abs(value.translation.height) else { return }
if value.translation.width < -width * 0.2 {
move(by: 1)
} else if value.translation.width > width * 0.2 {
move(by: -1)
}
}
}
private func move(by step: Int) {
guard items.count > 1, (1...items.count).contains(displayIndex) else { return }
let next = displayIndex + step
displayIndex = next
guard next == 0 || next == items.count + 1 else { return }
let revision = itemsRevision
DispatchQueue.main.asyncAfter(deadline: .now() + Self.transitionDuration) {
guard itemsRevision == revision, displayIndex == next else { return }
normalize(to: next == 0 ? items.count : 1)
}
}
private func normalize(to index: Int) {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { displayIndex = index }
}
}
private struct SubBannerTapStyle<Drag: Gesture>: PrimitiveButtonStyle {
let drag: Drag
func makeBody(configuration: Configuration) -> some View {
configuration.label
.contentShape(Rectangle())
.simultaneousGesture(drag.exclusively(before: TapGesture().onEnded { configuration.trigger() }))
.accessibilityAction { configuration.trigger() }
}
}
@@ -0,0 +1,28 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
enum SubBannerLinkOpener {
static func open(_ banner: SubBanner, using opener: @escaping (URL, @escaping (Bool) -> Void) -> Void) {
let webURL = banner.webURL
if let iosURL = banner.iosURL {
opener(iosURL) { opened in
if !opened, let webURL {
opener(webURL) { _ in }
}
}
} else if let webURL {
opener(webURL) { _ in }
}
}
#if canImport(UIKit)
static func open(_ banner: SubBanner) {
open(banner) { url, completion in
UIApplication.shared.open(url, options: [:], completionHandler: completion)
}
}
#endif
}