216 lines
7.1 KiB
Swift
216 lines
7.1 KiB
Swift
import SwiftUI
|
|
|
|
struct BannerCarouselItem: Identifiable, Hashable {
|
|
let id: String
|
|
let imageUrl: String?
|
|
|
|
init(id: String, imageUrl: String?) {
|
|
self.id = id
|
|
self.imageUrl = imageUrl
|
|
}
|
|
}
|
|
|
|
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 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 = Array(items.prefix(BannerCarousel.maxItemCount))
|
|
self.action = action
|
|
}
|
|
|
|
var body: some View {
|
|
if !items.isEmpty {
|
|
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)
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BannerCarousel_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
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)
|
|
}
|
|
}
|