212 lines
9.1 KiB
Swift
212 lines
9.1 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: - Header Pill (Active Tab Title & Indicator)
|
|
HStack(spacing: 8) {
|
|
// Previous button
|
|
Button(action: {
|
|
selectPrevious()
|
|
}) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 14, weight: .bold))
|
|
.foregroundStyle(selectedIndex > 0 ? currentTab.color : Color.gray.opacity(0.4))
|
|
.frame(width: 28, height: 28)
|
|
.background(Color.white.opacity(0.15))
|
|
.clipShape(Circle())
|
|
}
|
|
.disabled(selectedIndex == 0)
|
|
|
|
Spacer()
|
|
|
|
// Active Title and Index Pill
|
|
HStack(spacing: 8) {
|
|
Image(systemName: currentTab.icon)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(currentTab.color)
|
|
|
|
Text(currentTab.title)
|
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.primary)
|
|
|
|
Text("\(selectedIndex + 1)/\(tabs.count)")
|
|
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(currentTab.color.opacity(0.2))
|
|
.foregroundStyle(currentTab.color)
|
|
.clipShape(Capsule())
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
Capsule()
|
|
.fill(.regularMaterial)
|
|
.shadow(color: currentTab.color.opacity(0.25), radius: 8, x: 0, y: 3)
|
|
)
|
|
|
|
Spacer()
|
|
|
|
// Next button
|
|
Button(action: {
|
|
selectNext()
|
|
}) {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14, weight: .bold))
|
|
.foregroundStyle(selectedIndex < tabs.count - 1 ? currentTab.color : Color.gray.opacity(0.4))
|
|
.frame(width: 28, height: 28)
|
|
.background(Color.white.opacity(0.15))
|
|
.clipShape(Circle())
|
|
}
|
|
.disabled(selectedIndex == tabs.count - 1)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
|
|
// 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: 32)
|
|
.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: 32 + 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
|
|
let velocity = value.predictedEndTranslation.width / itemSpacing
|
|
let calcIndex = Double(selectedIndex) - Double(value.translation.width / itemSpacing) - (velocity * 0.2)
|
|
let targetIndex = min(max(Int(round(calcIndex)), 0), tabs.count - 1)
|
|
|
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
|
selectedIndex = targetIndex
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.padding(.vertical, 12)
|
|
.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)
|
|
}
|
|
|
|
private func selectPrevious() {
|
|
if selectedIndex > 0 {
|
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
|
selectedIndex -= 1
|
|
feedback.impactOccurred()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func selectNext() {
|
|
if selectedIndex < tabs.count - 1 {
|
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
|
selectedIndex += 1
|
|
feedback.impactOccurred()
|
|
}
|
|
}
|
|
}
|
|
}
|