HuggingChat-Mac/Views/CardStack.swift (126 lines of code) (raw):

// // RolodexView.swift // HuggingChat-Mac // // Created by Cyril Zakka on 9/30/24. // import SwiftUI public struct CardStack<Content:View>: View { private let views: [Content] @State private var dragProgress = 0.0 @State private var containerSize = CGSize.zero @Binding var selectedIndex: Int @State private var isAnimating = false @State private var animationProgress: CGFloat = 0 @AppStorage("isLocalGeneration") private var isLocalGeneration: Bool = false public init(_ views: [Content], selectedIndex: Binding<Int>) { self.views = views self._selectedIndex = selectedIndex } public var body: some View { ZStack { ForEach(0..<views.count, id: \.self) { index in views[index] .zIndex(zIndex(for: index)) .offset(y: yOffset(for: index)) .scaleEffect(scale(for: index), anchor: .center) .opacity(opacity(for: index)) } } .measure($containerSize) .onChange(of: isLocalGeneration) { oldValue, newValue in simulateDrag() } } var dragGesture: some Gesture { DragGesture(minimumDistance: 5) .onChanged { value in self.dragProgress = -(value.translation.height / containerSize.height) } .onEnded { value in snapToNearestIndex() } } func simulateDrag() { guard !isAnimating else { return } isAnimating = true let isLastCard = selectedIndex == views.count - 1 withAnimation(.easeInOut(duration: 0.3)) { self.dragProgress = isLastCard ? -0.4: 0.4 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { snapToNearestIndex() isAnimating = false } } func snapToNearestIndex() { let threshold = 0.3 if abs(dragProgress) < threshold { withAnimation(.bouncy) { self.dragProgress = 0.0 } } else { let direction = dragProgress < 0 ? -1 : 1 withAnimation(.smooth(duration: 0.25)) { go(to: selectedIndex + direction) } } } func go(to index: Int) { let maxIndex = views.count - 1 if index > maxIndex { self.selectedIndex = maxIndex } else if index < 0 { self.selectedIndex = 0 } else { self.selectedIndex = index } self.dragProgress = 0 } var progressIndex: Double { dragProgress + Double(selectedIndex) } func currentPosition(for index: Int) -> Double { progressIndex - Double(index) } func zIndex(for index: Int) -> Double { let position = currentPosition(for: index) return -abs(position) } func yOffset(for index: Int) -> Double { let padding = containerSize.height / 10 let y = (Double(index) - progressIndex) * padding let maxIndex = views.count - 1 if index == selectedIndex && progressIndex < Double(maxIndex) && progressIndex > 0 { return y * swingOutMultiplier } return y } var swingOutMultiplier: Double { return abs(sin(Double.pi * progressIndex) * 25) } func scale(for index: Int) -> CGFloat { return 1.0 - (0.3 * abs(currentPosition(for: index))) } func opacity(for index: Int) -> CGFloat { return 1.0 } func rotation(for index: Int) -> Double { return -currentPosition(for: index) * 2 } } extension View { /// Measures the geometry of the attached view. func measure(_ size: Binding<CGSize>) -> some View { self.background { GeometryReader { reader in Color.clear.preference( key: ViewSizePreferenceKey.self, value: reader.size ) } } .onPreferenceChange(ViewSizePreferenceKey.self) { size.wrappedValue = $0 ?? .zero } } } struct ViewSizePreferenceKey: PreferenceKey { static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) { value = nextValue() ?? value } static var defaultValue: CGSize? = nil }