Last active
December 20, 2024 17:43
-
-
Save randomor/d45cda1b3dfec7d6b9de1f6c32f523ce to your computer and use it in GitHub Desktop.
3D card scroll view
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
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() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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:
I thought this is a fun experiment for everyone. Could use your help improve this. Thanks!