Last active
March 14, 2025 03:31
-
-
Save cjhodge/f3bb1c02c20efe59e5c14e159a6a9fe1 to your computer and use it in GitHub Desktop.
GACarouselView
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// GACarousel.swift | |
// rpg | |
// | |
// Created by Chris Hodge on 3/4/25. | |
// | |
import SwiftUI | |
import MetalKit | |
struct GACarouselView: View { | |
@State private var angle: Double = 0 | |
@State private var currentIndex: Int = 0 | |
@State private var previousIndex: Int = 0 | |
let numberOfItems = 6 | |
let angleIncrement: Double = 60 // 360 degrees ÷ 6 items | |
let radius: CGFloat = 200 // Radius for positioning | |
var body: some View { | |
ZStack { | |
// Setup each item in the carousel | |
ForEach(0..<numberOfItems) { index in | |
let itemAngle = angle + Double(index) * angleIncrement | |
let normalizedDegrees = normalizeAngle(itemAngle) | |
GACharacterView(title: "Item \(index + 1)", index: index, blackAmount: blackAmount(at: normalizedDegrees)) | |
.offset(x: calculateXPosition(angle: itemAngle)) | |
.scaleEffect(scaleForItem(at: normalizedDegrees)) | |
.zIndex(zIndexForItem(at: normalizedDegrees)) | |
} | |
} | |
.overlay(alignment: .center) { | |
VStack(spacing: 0) { | |
// Direct navigation | |
Spacer() | |
HStack(spacing: 40) { | |
Button(action: rotateToNext) { | |
Image(systemName: "chevron.left") | |
.font(.title) | |
.foregroundStyle(.red) | |
.padding() | |
} | |
Button(action: rotateToPrevious) { | |
Image(systemName: "chevron.right") | |
.font(.title) | |
.foregroundStyle(.red) | |
.padding() | |
} | |
} | |
} | |
} | |
// Drag gesture | |
.gesture( | |
DragGesture() | |
.onChanged { value in | |
// Increased sensitivity by reducing divisor from 5 to 2.5 | |
angle = Double(currentIndex) * -angleIncrement - value.translation.width / 4.5 | |
} | |
.onEnded { value in | |
let velocity = value.predictedEndTranslation.width - value.translation.width | |
// Use velocity to determine final position | |
let predictedAngle = angle - (value.predictedEndTranslation.width / 4.5) | |
let anglePerItem = 360.0 / Double(numberOfItems) | |
// Calculate the nearest index with some velocity influence | |
let normalizedAngle = (predictedAngle.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) | |
var nearestIndex = Int(round(normalizedAngle / anglePerItem)) % numberOfItems | |
// If velocity is significant, move one more position in that direction | |
if abs(velocity) > 300 { | |
nearestIndex = velocity > 0 ? | |
(nearestIndex + 1) % numberOfItems : | |
(nearestIndex - 1 + numberOfItems) % numberOfItems | |
} | |
// Update current index | |
currentIndex = (numberOfItems - nearestIndex) % numberOfItems | |
// Less restrictive spring animation | |
withAnimation(.spring(response: 0.45, dampingFraction: 0.7, blendDuration: 0.3)) { | |
angle = Double(currentIndex) * -angleIncrement | |
} | |
} | |
) | |
// Detect when currentIndex changes | |
.onChange(of: currentIndex) { oldValue, newValue in | |
if previousIndex != newValue { | |
print("rotating to \(newValue)") | |
} | |
} | |
// Initialize previousIndex after view appears | |
.onAppear { | |
previousIndex = currentIndex | |
} | |
} | |
// Fade characters to black if not in the front middle | |
func blackAmount(at degrees: Double) -> Double { | |
// Calculate how "front-facing" the item is (1 = front, 0 = back) | |
let cosValue = cos((degrees - 180) * .pi / 180) | |
// Convert to a value from 0 (front) to 0.85 (back) | |
// The key is that front-facing items should have 0 blackAmount | |
return max(0, 0.85 - 0.85 * cosValue) | |
} | |
// Calculate X position based on angle but use elliptical path | |
func calculateXPosition(angle: Double) -> CGFloat { | |
let radians = angle * .pi / 180 | |
// Use sine for horizontal position | |
let xPosition = radius * CGFloat(sin(radians)) | |
return xPosition | |
} | |
func rotateToNext() { | |
// Store previous index before updating | |
previousIndex = currentIndex | |
withAnimation(.spring(response: 0.45, dampingFraction: 0.7, blendDuration: 0.3)) { | |
currentIndex = (currentIndex - 1 + numberOfItems) % numberOfItems | |
angle = Double(currentIndex) * -angleIncrement | |
} | |
} | |
func rotateToPrevious() { | |
withAnimation(.spring(response: 0.45, dampingFraction: 0.7, blendDuration: 0.3)) { | |
// Store previous index before updating | |
previousIndex = currentIndex | |
currentIndex = (currentIndex + 1) % numberOfItems | |
angle = Double(currentIndex) * -angleIncrement | |
} | |
} | |
// Helper functions | |
func itemColor(for index: Int) -> Color { | |
let colors: [Color] = [.blue, .red, .green, .orange, .purple, .pink] | |
return colors[index % colors.count] | |
} | |
func scaleForItem(at degrees: Double) -> CGFloat { | |
// Calculate scale based on position in carousel | |
// Items at 180 degrees (front) are largest | |
// Items at 0 or 360 degrees (back) are smallest | |
let cosValue = cos((degrees - 180) * .pi / 180) | |
// Scale from 0.6 to 1.0 based on position | |
return 0.6 + 0.4 * cosValue | |
} | |
func zIndexForItem(at degrees: Double) -> Double { | |
// Items closer to front (180 degrees) have higher z-index | |
// This ensures proper layering | |
let cosValue = cos((degrees - 180) * .pi / 180) | |
return Double(cosValue * 100) | |
} | |
func normalizeAngle(_ degrees: Double) -> Double { | |
// Convert to a value between 0 and 360 | |
return (degrees.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) | |
} | |
} | |
struct GACharacterView: View { | |
var title: String | |
var index: Int | |
var blackAmount: Double | |
var body: some View { | |
ZStack { | |
Image("CharacterSelectShadow") | |
.interpolation(.none) | |
.resizable() | |
.scaledToFit() | |
Image("GACarouselCharacter\(index)") | |
.interpolation(.none) | |
.resizable() | |
.scaledToFit() | |
.brightness(-blackAmount * 1) | |
} | |
} | |
} | |
#Preview { | |
GACarouselView() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment