// // ScenicPromotionSpotTabsView.swift // suixinkan // // Created by Codex on 2026/7/2. // import SwiftUI /// 宣传页打卡点标签区,5 个以内等分,超过 5 个时横向滚动并将选中项居中。 struct ScenicPromotionSpotTabsView: View { let spots: [ScenicPromotionSpot] let selectedSpotID: String? let onSelectSpot: (ScenicPromotionSpot) -> Void /// 选中背景滑块动画曲线。 private static let selectionAnimation = Animation.spring(response: 0.35, dampingFraction: 0.82) /// 选中标签滚动居中动画曲线。 private static let scrollAnimation = Animation.easeInOut(duration: 0.25) /// 当前选中标签在列表中的索引,未选中时回退到 0。 private var selectedIndex: Int { guard let selectedSpotID else { return 0 } return spots.firstIndex(where: { $0.id == selectedSpotID }) ?? 0 } /// 是否需要启用横向滚动(超过 5 个标签)。 private var shouldEnableScroll: Bool { spots.count > Int(ScenicPromotionLayout.visibleSpotTabCount) } var body: some View { if spots.isEmpty { EmptyView() } else { Color.clear .frame(maxWidth: .infinity) .frame(height: 42) .overlay { GeometryReader { proxy in let visibleCount = min( CGFloat(max(spots.count, 1)), ScenicPromotionLayout.visibleSpotTabCount ) let tabWidth = proxy.size.width / visibleCount ScrollViewReader { scrollProxy in ScrollView(.horizontal, showsIndicators: false) { ZStack(alignment: .leading) { selectionBackground(width: tabWidth) HStack(spacing: 0) { ForEach(spots) { spot in ScenicPromotionSpotTabButton( spot: spot, isSelected: selectedSpotID == spot.id, onTap: { onSelectSpot(spot) } ) .frame(width: tabWidth) .id(spot.id) } } } } .scrollDisabled(!shouldEnableScroll) .onAppear { scrollSelectedTabToCenter(using: scrollProxy, animated: false) } .onChange(of: selectedSpotID) { _ in scrollSelectedTabToCenter(using: scrollProxy, animated: true) } } } } .background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14)) .clipShape(RoundedRectangle(cornerRadius: 14)) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(.white.opacity(0.8), lineWidth: 1) ) .shadow(color: Color(hex: 0x0D47A1, alpha: 0.12), radius: 12, y: 5) } } /// 选中标签的渐变背景滑块,随选中项横向位移。 @ViewBuilder private func selectionBackground(width: CGFloat) -> some View { RoundedRectangle(cornerRadius: 14) .fill( LinearGradient( colors: [Color(hex: 0x1286FF), Color(hex: 0x006BFF)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: width, height: 42) .offset(x: CGFloat(selectedIndex) * width) .animation(Self.selectionAnimation, value: selectedIndex) } /// 超过可见数量时,将选中标签滚动到可视区域中间。 private func scrollSelectedTabToCenter(using scrollProxy: ScrollViewProxy, animated: Bool) { guard shouldEnableScroll, let selectedSpotID else { return } if animated { withAnimation(Self.scrollAnimation) { scrollProxy.scrollTo(selectedSpotID, anchor: .center) } } else { scrollProxy.scrollTo(selectedSpotID, anchor: .center) } } } /// 单个打卡点标签按钮。 struct ScenicPromotionSpotTabButton: View { let spot: ScenicPromotionSpot let isSelected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: 3) { Image(systemName: spot.iconName) .font(.system(size: 13, weight: .bold)) Text(spot.name) .font(.system(size: 12, weight: .bold)) .lineLimit(1) .minimumScaleFactor(0.66) } .foregroundStyle(isSelected ? .white : Color(hex: 0x14213D)) .frame(height: 42) .padding(.horizontal, 4) .contentShape(Rectangle()) } .buttonStyle(.plain) } } #if DEBUG #Preview("打卡点标签") { ScenicPromotionSpotTabsView( spots: ScenicPromotionPreviewSupport.sampleBoard.spots, selectedSpotID: "bridge", onSelectSpot: { _ in } ) .padding(.horizontal, 20) } #endif