Skip to content

Instantly share code, notes, and snippets.

@randomor
Last active December 20, 2024 17:43
Show Gist options
  • Save randomor/d45cda1b3dfec7d6b9de1f6c32f523ce to your computer and use it in GitHub Desktop.
Save randomor/d45cda1b3dfec7d6b9de1f6c32f523ce to your computer and use it in GitHub Desktop.
3D card scroll view
import SwiftUI
import AppKit
struct ScatteredCardsView: View {
@AppStorage("notesPerDay") private var notesPerDay: Double = 5
@AppStorage("visibleDepthDays") private var visibleDepthDays: Double = 7
@AppStorage("secondsPerPixel") private var secondsPerPixel: Double = 100
@State private var notes: [Note] = []
@State private var scrollOffset: CGFloat = 0
@State private var referenceTime: Date
@State private var loadedTimeRange: (start: Date, end: Date)
@State private var scrollHeight: CGFloat = 20000 // Initial scroll height
@State private var initialReferenceTime: Date
@State private var scrollPosition = ScrollPosition()
init() {
let now = Date()
_referenceTime = State(initialValue: now)
_loadedTimeRange = State(initialValue: (
start: Calendar.current.date(byAdding: .day, value: -14, to: now)!,
end: Calendar.current.date(byAdding: .day, value: 14, to: now)!
))
_initialReferenceTime = State(initialValue: now)
// Initialize notes with initial data
let initialNotes = Self.generateNotesForTimeRange(
start: Calendar.current.date(byAdding: .day, value: -14, to: now)!,
end: Calendar.current.date(byAdding: .day, value: 14, to: now)!,
notesPerDay: 5
)
_notes = State(initialValue: initialNotes)
}
static func generateNotesForTimeRange(start: Date, end: Date, notesPerDay: Double) -> [Note] {
let calendar = Calendar.current
let components = calendar.dateComponents([.day], from: start, to: end)
guard let days = components.day, days > 0 else { return [] }
let totalNotes = Int(Double(days) * notesPerDay)
var notes: [Note] = []
// Keep track of recent note positions to avoid overlap
var recentPositions: [(x: Double, y: Double)] = []
let maxRecentPositions = Int(notesPerDay * 3) // How many recent positions to consider
let minDistance = 0.2 // Minimum distance between notes (in relative coordinates)
for i in 0..<totalNotes {
let randomTimeOffset = Double.random(in: 0...Double(days * 24 * 60 * 60))
let noteDate = start.addingTimeInterval(randomTimeOffset)
// Generate position with flocking behavior
var bestPosition: (x: Double, y: Double) = (0, 0)
var maxMinDistance: Double = 0
// Try several positions and pick the one with maximum minimum distance to recent notes
for _ in 0..<10 {
let candidateX = Double.random(in: 0.15...0.90)
let candidateY = Double.random(in: 0.15...0.90)
let minDistanceToRecent = recentPositions.map { recent in
let dx = candidateX - recent.x
let dy = candidateY - recent.y
return sqrt(dx * dx + dy * dy)
}.min() ?? Double.infinity
if minDistanceToRecent > maxMinDistance {
maxMinDistance = minDistanceToRecent
bestPosition = (candidateX, candidateY)
}
}
// Update recent positions
recentPositions.append(bestPosition)
if recentPositions.count > maxRecentPositions {
recentPositions.removeFirst()
}
notes.append(Note(
id: Int(start.timeIntervalSince1970) + i,
text: "Note \(i + 1)",
relativeX: bestPosition.x,
relativeY: bestPosition.y,
rotation: Double.random(in: -20...20),
color: Note.colors.randomElement() ?? .yellow,
createdAt: noteDate,
height: Double.random(in: 120...216)
))
}
return notes.sorted { $0.createdAt < $1.createdAt }
}
func updateNotesIfNeeded() {
// Calculate visible range based on current reference time
let visibleStart = referenceTime.addingTimeInterval(-visibleDepthDays * 24 * 60 * 60)
let visibleEnd = referenceTime.addingTimeInterval(visibleDepthDays * 24 * 60 * 60)
// Increase buffer to 1.5x the visible depth for smoother transitions
let buffer = TimeInterval(visibleDepthDays * 24 * 60 * 60 * 1.5)
let needsUpdate = visibleStart.timeIntervalSince(loadedTimeRange.start) < buffer ||
loadedTimeRange.end.timeIntervalSince(visibleEnd) < buffer
// Always clean up notes that are too far from the reference time
let cleanupStart = referenceTime.addingTimeInterval(-visibleDepthDays * 24 * 60 * 60 * 4)
let cleanupEnd = referenceTime.addingTimeInterval(visibleDepthDays * 24 * 60 * 60 * 4)
notes = notes.filter { note in
note.createdAt >= cleanupStart && note.createdAt <= cleanupEnd
}
if needsUpdate {
// Load 3x the visible depth to ensure smooth scrolling
let newStart = referenceTime.addingTimeInterval(-visibleDepthDays * 24 * 60 * 60 * 3)
let newEnd = referenceTime.addingTimeInterval(visibleDepthDays * 24 * 60 * 60 * 3)
// Generate new notes only for the range we don't have
var timeRangesToGenerate: [(start: Date, end: Date)] = []
// Check if we need to generate notes before current range
if newStart < loadedTimeRange.start {
timeRangesToGenerate.append((start: newStart, end: loadedTimeRange.start))
}
// Check if we need to generate notes after current range
if newEnd > loadedTimeRange.end {
timeRangesToGenerate.append((start: loadedTimeRange.end, end: newEnd))
}
// Generate notes only for the missing ranges
let newNotes = timeRangesToGenerate.flatMap { range in
Self.generateNotesForTimeRange(
start: range.start,
end: range.end,
notesPerDay: notesPerDay
)
}
// Merge new notes with existing ones
var mergedNotes = notes
let existingIds = Set(notes.map { $0.id })
mergedNotes.append(contentsOf: newNotes.filter { !existingIds.contains($0.id) })
// Update notes and time range
notes = mergedNotes.sorted { $0.createdAt < $1.createdAt }
loadedTimeRange = (start: newStart, end: newEnd)
}
}
func updateScrollHeight() {
// Update scroll height when secondsPerPixel changes
scrollHeight = 20000 * (secondsPerPixel / 30)
}
// Calculate opacity and scale based on time difference
func cardProperties(for note: Note) -> (opacity: Double, scale: Double) {
let visibleDepthSeconds: Double = visibleDepthDays * 24 * 60 * 60
let fadeOutSeconds: Double = visibleDepthSeconds / 8 // Slightly longer fade for smoother transition
let secondsDifference = note.createdAt.timeIntervalSince(referenceTime)
// Add smooth easing function
func ease(_ x: Double) -> Double {
return x * x * (3 - 2 * x) // Smooth step function
}
// Future from reference time (fade out quickly)
if secondsDifference > 0 {
let futureRatio = 1.0 - (secondsDifference / fadeOutSeconds)
let scale = min(1.4, 1.0 + (1.0 - ease(futureRatio)) * 0.4)
return (
opacity: max(0, ease(futureRatio)),
scale: scale
)
}
// Past from reference time (fade in over visibleDepthSeconds)
let pastRatio = (secondsDifference + visibleDepthSeconds) / visibleDepthSeconds
let scale = max(0.1, ease(pastRatio))
// Make cards fully opaque when scale is between 0.8 and 1.0
let opacity = if scale >= 0.8 && scale <= 1.0 {
1.0
} else {
max(0.1, ease(pastRatio))
}
return (opacity: opacity, scale: scale)
}
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
// Main content area containing cards and scroll
ZStack {
// Background for visualization
// Color.blue.opacity(0.1)
ScrollViewReader { proxy in
ScrollView {
Color.clear
.frame(height: scrollHeight)
.background(
GeometryReader { proxy in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: proxy.frame(in: .named("scroll")).minY
)
}
)
.scrollTargetLayout()
.allowsHitTesting(false) // Allow events to pass through
}
.scrollPosition($scrollPosition)
.scrollDismissesKeyboard(.immediately)
.scrollIndicators(.hidden)
.coordinateSpace(name: "scroll")
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
scrollOffset = value
let secondsOffset = Double(value) * secondsPerPixel
let newReferenceTime = initialReferenceTime.addingTimeInterval(secondsOffset)
if abs(newReferenceTime.timeIntervalSince(referenceTime)) > 100 {
referenceTime = newReferenceTime
updateNotesIfNeeded()
}
}
// Cards layer that stays fixed
ZStack {
ForEach(notes.sorted(by: { $0.createdAt < $1.createdAt }).filter { note in
let secondsDifference = note.createdAt.timeIntervalSince(referenceTime)
let visibleDepthSeconds: Double = visibleDepthDays * 24 * 60 * 60
let fadeOutSeconds: Double = visibleDepthSeconds / 10
return secondsDifference >= -visibleDepthSeconds && secondsDifference <= fadeOutSeconds
}) { note in
let properties = cardProperties(for: note)
NoteCard(note: note, opacity: properties.opacity, scale: properties.scale)
.position(
x: geometry.size.width * note.relativeX,
y: (geometry.size.height - 180) * note.relativeY
)
.onTapGesture {
withAnimation(.smooth(duration: 0.5)) {
referenceTime = note.createdAt
let newScrollOffset = (note.createdAt.timeIntervalSince(initialReferenceTime) / secondsPerPixel)
scrollPosition = ScrollPosition(y: -newScrollOffset)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Controls in a fixed height container at the bottom
VStack {
HStack {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading) {
Text("Notes per day: \(Int(notesPerDay))")
Slider(value: $notesPerDay, in: 1...50) { _ in
updateNotesIfNeeded()
}
.frame(width: 200)
}
VStack(alignment: .leading) {
Text("Visible depth (days): \(Int(visibleDepthDays))")
Slider(value: $visibleDepthDays, in: 1...30) { _ in
updateNotesIfNeeded()
}
.frame(width: 200)
}
VStack(alignment: .leading) {
Text("Seconds per pixel: \(Int(secondsPerPixel))")
Slider(value: $secondsPerPixel, in: 100...1000) { _ in
// Update scroll height when secondsPerPixel changes
updateScrollHeight()
}
.frame(width: 200)
}
}
.padding()
Spacer()
VStack(alignment: .trailing) {
Button(action: {
// Reset reference time to current time
let now = Date()
referenceTime = now
initialReferenceTime = now
// Reset loaded time range around current time
loadedTimeRange = (
start: Calendar.current.date(byAdding: .day, value: -Int(visibleDepthDays * 3), to: referenceTime)!,
end: Calendar.current.date(byAdding: .day, value: Int(visibleDepthDays * 3), to: referenceTime)!
)
// Generate new notes for the current time range
notes = Self.generateNotesForTimeRange(
start: loadedTimeRange.start,
end: loadedTimeRange.end,
notesPerDay: notesPerDay
)
// Reset scroll position
scrollPosition.scrollTo(edge: .top)
}) {
Image(systemName: "arrow.clockwise.circle.fill")
.font(.title)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.help("Refresh notes")
Text("Reference Time:")
.font(.callout)
.foregroundColor(.secondary)
Text(referenceTime.formatted(date: .complete, time: .complete))
.font(.callout)
.foregroundColor(.secondary)
}
}
.padding()
}
.frame(height: 180)
}
}
.background(Color(NSColor.windowBackgroundColor))
.onAppear {
updateNotesIfNeeded()
}
}
}
struct Note: Identifiable {
let id: Int
let text: String
let relativeX: Double
let relativeY: Double
let rotation: Double
let color: Color
let createdAt: Date
let height: Double
static let colors: [Color] = [
.yellow,
.pink,
.mint,
.orange,
.cyan,
.green
]
}
struct NoteCard: View {
let note: Note
let opacity: Double
let scale: Double
@State private var isHovered = false
var body: some View {
VStack {
Text(note.text)
.padding()
.foregroundColor(.primary)
Text("Created: \(note.createdAt.formatted(.dateTime.day().month()))")
.foregroundColor(.primary)
Text("Opacity: \(String(format: "%.2f", opacity))")
.foregroundColor(.primary)
Text("Scale: \(String(format: "%.2f", scale))")
.foregroundColor(.primary)
}
.frame(width: 180, height: note.height * 1.2)
.background(note.color)
.cornerRadius(8)
.opacity(opacity)
.scaleEffect(scale * (isHovered ? 1.05 : 1.0))
.blur(radius: opacity < 1 ? 2 * (1 - opacity) : 0)
.animation(.smooth, value: opacity)
.animation(.smooth, value: scale)
.animation(.smooth, value: isHovered)
.onHover { hovering in
isHovered = hovering
}
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
extension Date {
static func random(in range: ClosedRange<Date>) -> Date {
let diff = range.upperBound.timeIntervalSince(range.lowerBound)
let randomValue = Double.random(in: 0...diff)
return range.lowerBound.addingTimeInterval(randomValue)
}
}
struct ScatteredCardsView_Previews: PreviewProvider {
static var previews: some View {
ScatteredCardsView()
}
}
struct ContentView: View {
var body: some View {
ScatteredCardsView()
}
}
@randomor
Copy link
Author

Inspired by a 3D scroll/time-travel view concept, I experimented with representing time as a third dimension. The cards, varying in height, populate a 2D canvas, while scrolling through the view updates the “current time” (reference time). Cards past this reference fade out, while older cards appear closer. A “vision depth” setting controls how many days are visible at once.

Although the concept seems promising, I’ve hit the limits of my Swift and design skills. With the help of Claude, I built a very rough prototype(6-second demo Giphy link), but there are issues:

  1. Low frame rate
  2. Jerky scrolling
  3. Imperfect depth illusion (cards getting closer but not convincingly)
  4. No shadows or parallax effect for depth realism
  5. other issues that I may not even notice....

I thought this is a fun experiment for everyone. Could use your help improve this. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment