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