Files
sodalive-ios/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift

258 lines
8.2 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],
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 BannerCarouselSection<Item>: View {
let items: [Item]
let id: (Item, Int) -> String
let imageUrl: (Item) -> String?
let action: (Item) -> Void
init(
items: [Item],
id: @escaping (Item, Int) -> String,
imageUrl: @escaping (Item) -> String?,
action: @escaping (Item) -> Void
) {
self.items = items
self.id = id
self.imageUrl = imageUrl
self.action = action
}
var body: some View {
if !items.isEmpty {
BannerCarousel(items: carouselItems) { carouselItem in
guard let item = item(for: carouselItem) else { return }
action(item)
}
}
}
private var carouselItems: [BannerCarouselItem] {
items.enumerated().map { index, item in
BannerCarouselItem(
id: id(item, index),
imageUrl: imageUrl(item)
)
}
}
private func item(for carouselItem: BannerCarouselItem) -> Item? {
items.enumerated().first { index, item in
id(item, index) == carouselItem.id
}?.element
}
}
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)
}
}