feat(recommendation): 추천 서브 배너와 연속 순환을 추가한다
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user