159 lines
6.3 KiB
Swift
159 lines
6.3 KiB
Swift
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() }
|
|
}
|
|
}
|