iOS_app_test/tg_test/WheelNavBar.swift
2026-10-25 20:41:21 +08:00

137 lines
6.3 KiB
Swift

//
// WheelNavBar.swift
// tg_test
//
import SwiftUI
struct WheelNavBar: View {
@Binding var selectedIndex: Int
let tabs: [TabItem]
@GestureState private var dragTranslation: CGFloat = 0
@State private var activeDragIndex: Int = 0
// Haptic feedback generator
private let feedback = UIImpactFeedbackGenerator(style: .medium)
private let itemSpacing: CGFloat = 62
var body: some View {
let currentTab = tabs[min(max(selectedIndex, 0), tabs.count - 1)]
VStack(spacing: 8) {
// MARK: - Wheel Dial Track
GeometryReader { geometry in
let centerPoint = geometry.size.width / 2
let effectiveIndex = Double(selectedIndex) - Double(dragTranslation / itemSpacing)
ZStack {
// Central Selector Ring Glow
Circle()
.fill(currentTab.color.opacity(0.15))
.frame(width: 56, height: 56)
.overlay(
Circle()
.stroke(currentTab.color, lineWidth: 2.5)
)
.position(x: centerPoint, y: 36)
.shadow(color: currentTab.color.opacity(0.4), radius: 10)
// Rotary Wheel Items
ForEach(tabs) { tab in
let distance = Double(tab.id) - effectiveIndex
let xOffset = distance * itemSpacing
// Arc wheel curvature (quadratic Y offset for wheel effect)
let yOffset = pow(distance, 2) * 2.2
// Scaling factor (center item is largest)
let scale = max(0.55, 1.15 - min(abs(distance) * 0.18, 0.6))
// Opacity falloff
let opacity = max(0.25, 1.0 - min(abs(distance) * 0.22, 0.75))
// 3D Y Rotation Angle for wheel perspective
let rotationY = min(max(distance * -18.0, -60.0), 60.0)
let isSelected = tab.id == selectedIndex
VStack(spacing: 2) {
ZStack {
Circle()
.fill(isSelected ? tab.color : Color(.tertiarySystemFill))
.frame(width: 44, height: 44)
.shadow(color: isSelected ? tab.color.opacity(0.5) : .clear, radius: 6)
Image(systemName: tab.icon)
.font(.system(size: isSelected ? 20 : 16, weight: isSelected ? .bold : .medium))
.foregroundStyle(isSelected ? .white : .primary)
if let badge = tab.badge {
Text(badge)
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.padding(4)
.background(Color.red)
.clipShape(Circle())
.offset(x: 14, y: -14)
}
}
}
.scaleEffect(scale)
.opacity(opacity)
.rotation3DEffect(
.degrees(rotationY),
axis: (x: 0.0, y: 1.0, z: 0.0),
perspective: 0.5
)
.position(x: centerPoint + xOffset, y: 36 + yOffset)
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
selectedIndex = tab.id
feedback.impactOccurred()
}
}
}
}
}
.frame(height: 72)
.contentShape(Rectangle())
.gesture(
DragGesture()
.updating($dragTranslation) { value, state, _ in
state = value.translation.width
}
.onChanged { value in
let calcIndex = Double(selectedIndex) - Double(value.translation.width / itemSpacing)
let rounded = min(max(Int(round(calcIndex)), 0), tabs.count - 1)
if rounded != activeDragIndex {
activeDragIndex = rounded
feedback.impactOccurred()
}
}
.onEnded { value in
// Momentum = how far the gesture would coast past the finger's release point.
// Cap it to one tab so a hard flick can't skip several tabs.
let momentum = value.predictedEndTranslation.width - value.translation.width
let cappedMomentum = min(max(momentum, -itemSpacing), itemSpacing)
let calcIndex = Double(selectedIndex) - Double((value.translation.width + cappedMomentum * 0.5) / itemSpacing)
let targetIndex = min(max(Int(round(calcIndex)), 0), tabs.count - 1)
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
selectedIndex = targetIndex
}
}
)
}
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(.ultraThinMaterial)
.shadow(color: Color.black.opacity(0.12), radius: 16, x: 0, y: 8)
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
}