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) } }