Skip to content

Instantly share code, notes, and snippets.

@cjhodge
Last active March 14, 2025 03:31
Show Gist options
  • Save cjhodge/f3bb1c02c20efe59e5c14e159a6a9fe1 to your computer and use it in GitHub Desktop.
Save cjhodge/f3bb1c02c20efe59e5c14e159a6a9fe1 to your computer and use it in GitHub Desktop.
GACarouselView
//
// 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