Demo animation improvements: grid visibility, smooth scroll, fluid fill

- Fix grid visibility: show gray background cells always, animate mood
  colors on top with scale 2x->1x effect
- Add smooth auto-scroll for MonthView: starts after first month is 50%
  filled, scrolls at constant speed to match fill rate
- Remove pause between months for fluid continuous animation
- Add currentAnimatingMonthIndex tracking to DemoAnimationManager

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-29 11:46:57 -06:00
parent 8e0f69c29a
commit 15ff52d043
3 changed files with 140 additions and 111 deletions

View File

@@ -29,8 +29,8 @@ final class DemoAnimationManager: ObservableObject {
/// Total animation duration for filling all cells in one month /// Total animation duration for filling all cells in one month
let monthAnimationDuration: TimeInterval = 1.5 let monthAnimationDuration: TimeInterval = 1.5
/// Delay between months /// Delay between months (0 for fluid continuous animation)
let monthDelay: TimeInterval = 0.3 let monthDelay: TimeInterval = 0.0
/// Delay between each cell in year view /// Delay between each cell in year view
let yearCellDelay: TimeInterval = 0.05 let yearCellDelay: TimeInterval = 0.05
@@ -116,6 +116,23 @@ final class DemoAnimationManager: ObservableObject {
return Double(monthIndex) * (monthAnimationDuration + monthDelay) return Double(monthIndex) * (monthAnimationDuration + monthDelay)
} }
/// Returns the current month index being animated (for auto-scrolling)
var currentAnimatingMonthIndex: Int {
guard animationStarted else { return 0 }
let monthDuration = monthAnimationDuration + monthDelay
return Int(animationProgress / monthDuration)
}
/// Returns the approximate row being animated within current month (0-5 typically)
func currentAnimatingRow(totalRows: Int, totalColumns: Int) -> Int {
guard animationStarted else { return 0 }
let monthDuration = monthAnimationDuration + monthDelay
let progressInMonth = animationProgress.truncatingRemainder(dividingBy: monthDuration)
let normalizedProgress = min(1.0, progressInMonth / monthAnimationDuration)
let cellIndex = Int(normalizedProgress * Double(totalRows * totalColumns))
return cellIndex / totalColumns
}
/// Check if a cell should be visible based on current animation progress /// Check if a cell should be visible based on current animation progress
/// For MonthView: animates cells within each month from top-left to bottom-right /// For MonthView: animates cells within each month from top-left to bottom-right
/// - Parameters: /// - Parameters:

View File

@@ -166,47 +166,67 @@ struct MonthView: View {
EmptyHomeView(showVote: false, viewModel: nil) EmptyHomeView(showVote: false, viewModel: nil)
.padding() .padding()
} else { } else {
ScrollView { ScrollViewReader { scrollProxy in
VStack(spacing: 16) { ScrollView {
let allMonths = displayData.flatMap { yearData in VStack(spacing: 16) {
yearData.months.map { (year: yearData.year, month: $0.month, entries: $0.entries) } let allMonths = displayData.flatMap { yearData in
yearData.months.map { (year: yearData.year, month: $0.month, entries: $0.entries) }
}
ForEach(Array(allMonths.enumerated()), id: \.element.month) { monthIndex, monthData in
MonthCard(
month: monthData.month,
year: monthData.year,
entries: monthData.entries,
moodTint: moodTint,
imagePack: imagePack,
theme: theme,
filteredDays: filteredDays.currentFilters,
monthIndex: monthIndex,
onTap: {
let detailView = MonthDetailView(
monthInt: monthData.month,
yearInt: monthData.year,
entries: monthData.entries,
parentViewModel: viewModel
)
selectedDetail.selectedItem = detailView
selectedDetail.showSheet = true
},
onShare: { image in
shareImage.selectedShareImage = image
shareImage.showSheet = true
}
)
.id("month-\(monthIndex)")
}
// Scroll anchor at the very bottom
Color.clear.frame(height: 1).id("scroll-end")
} }
ForEach(Array(allMonths.enumerated()), id: \.element.month) { monthIndex, monthData in .padding(.horizontal)
MonthCard( .padding(.bottom, 100)
month: monthData.month, .id(moodTint) // Force complete refresh when mood tint changes
year: monthData.year, .background(
entries: monthData.entries, GeometryReader { proxy in
moodTint: moodTint, let offset = proxy.frame(in: .named("scroll")).minY
imagePack: imagePack, Color.clear.preference(key: ViewOffsetKey.self, value: offset)
theme: theme, }
filteredDays: filteredDays.currentFilters, )
monthIndex: monthIndex, }
onTap: { .onChange(of: demoManager.animationProgress) { _, progress in
let detailView = MonthDetailView( guard demoManager.isDemoMode && demoManager.animationStarted else { return }
monthInt: monthData.month,
yearInt: monthData.year, // Start slow scroll once first month is 50% done
entries: monthData.entries, let halfwayPoint = demoManager.monthAnimationDuration * 0.5
parentViewModel: viewModel if progress >= halfwayPoint && progress < halfwayPoint + 0.1 {
) // Trigger once: scroll to bottom with long duration for smooth constant speed
selectedDetail.selectedItem = detailView let totalMonths = displayData.flatMap { $0.months }.count
selectedDetail.showSheet = true let totalDuration = Double(totalMonths) * demoManager.monthAnimationDuration
}, withAnimation(.linear(duration: totalDuration)) {
onShare: { image in scrollProxy.scrollTo("scroll-end", anchor: .bottom)
shareImage.selectedShareImage = image }
shareImage.showSheet = true
}
)
} }
} }
.padding(.horizontal)
.padding(.bottom, 100)
.id(moodTint) // Force complete refresh when mood tint changes
.background(
GeometryReader { proxy in
let offset = proxy.frame(in: .named("scroll")).minY
Color.clear.preference(key: ViewOffsetKey.self, value: offset)
}
)
} }
.scrollDisabled(iapManager.shouldShowPaywall && !demoManager.isDemoMode) .scrollDisabled(iapManager.shouldShowPaywall && !demoManager.isDemoMode)
.mask( .mask(
@@ -701,25 +721,43 @@ struct DemoHeatmapCell: View {
@State private var randomMood: Mood = .great @State private var randomMood: Mood = .great
var body: some View { var body: some View {
RoundedRectangle(cornerRadius: 4) ZStack {
.fill(cellColor) // Background: Gray grid cell always visible at 1x scale
.aspectRatio(1, contentMode: .fit) RoundedRectangle(cornerRadius: 4)
.scaleEffect(cellScale) .fill(Color.gray.opacity(0.3))
.opacity(cellOpacity) .aspectRatio(1, contentMode: .fit)
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: isVisible)
.onAppear { // Foreground: Animated mood color that scales 2x -> 1x
// Generate random mood once when cell appears if demoManager.isDemoMode {
randomMood = DemoAnimationManager.randomPositiveMood() // Skip placeholder cells (first row offset cells)
if entry.mood != .placeholder {
RoundedRectangle(cornerRadius: 4)
.fill(moodTint.color(forMood: randomMood))
.aspectRatio(1, contentMode: .fit)
.scaleEffect(isAnimated ? 1.0 : 2.0)
.opacity(isAnimated ? 1.0 : 0.0)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isAnimated)
}
} else {
// Normal mode - just show the actual color
RoundedRectangle(cornerRadius: 4)
.fill(normalCellColor)
.aspectRatio(1, contentMode: .fit)
} }
}
.onAppear {
// Generate random mood once when cell appears
randomMood = DemoAnimationManager.randomPositiveMood()
}
} }
/// Whether this cell should be visible in demo mode /// Whether this cell has been animated (filled with color)
private var isVisible: Bool { private var isAnimated: Bool {
if !demoManager.isDemoMode { if !demoManager.isDemoMode {
return true // Normal mode - always visible return true // Normal mode - always show
} }
if !demoManager.animationStarted { if !demoManager.animationStarted {
return false // Demo mode but animation hasn't started - hide all return false // Demo mode but animation hasn't started
} }
return demoManager.isCellVisible( return demoManager.isCellVisible(
row: row, row: row,
@@ -730,28 +768,8 @@ struct DemoHeatmapCell: View {
) )
} }
private var cellScale: CGFloat { /// Color for normal (non-demo) mode
isVisible ? 1.0 : 0.3 private var normalCellColor: Color {
}
private var cellOpacity: Double {
isVisible ? 1.0 : 0.0
}
private var cellColor: Color {
if demoManager.isDemoMode {
// In demo mode, show random positive mood colors
if !isVisible {
return Color.gray.opacity(0.1)
}
// Skip placeholder cells at the start of month
if entry.mood == .placeholder {
return Color.gray.opacity(0.1)
}
return moodTint.color(forMood: randomMood)
}
// Normal mode - use actual entry data
if entry.mood == .placeholder { if entry.mood == .placeholder {
return Color.gray.opacity(0.1) return Color.gray.opacity(0.1)
} else if entry.mood == .missing { } else if entry.mood == .missing {

View File

@@ -757,26 +757,40 @@ struct DemoYearHeatmapCell: View {
@State private var randomMood: Mood = .great @State private var randomMood: Mood = .great
var body: some View { var body: some View {
RoundedRectangle(cornerRadius: 2) ZStack {
.fill(cellColor) // Background: Gray grid cell always visible at 1x scale
.aspectRatio(1, contentMode: .fit) RoundedRectangle(cornerRadius: 2)
.scaleEffect(cellScale) .fill(Color.gray.opacity(0.3))
.opacity(cellOpacity) .aspectRatio(1, contentMode: .fit)
.animation(.spring(response: 0.35, dampingFraction: 0.5), value: isVisible)
.onAppear { // Foreground: Animated mood color that scales 2x -> 1x
// Generate random mood once when cell appears if demoManager.isDemoMode {
randomMood = DemoAnimationManager.randomPositiveMood() RoundedRectangle(cornerRadius: 2)
.fill(moodTint.color(forMood: randomMood))
.aspectRatio(1, contentMode: .fit)
.scaleEffect(isAnimated ? 1.0 : 2.0)
.opacity(isAnimated ? 1.0 : 0.0)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isAnimated)
} else {
// Normal mode - just show the actual color
RoundedRectangle(cornerRadius: 2)
.fill(normalCellColor)
.aspectRatio(1, contentMode: .fit)
} }
}
.onAppear {
// Generate random mood once when cell appears
randomMood = DemoAnimationManager.randomPositiveMood()
}
} }
/// Whether this cell should be visible in demo mode /// Whether this cell has been animated (filled with color)
/// For year view, we animate column by column (left to right), then row by row within each column private var isAnimated: Bool {
private var isVisible: Bool {
if !demoManager.isDemoMode { if !demoManager.isDemoMode {
return true // Normal mode - always visible return true // Normal mode - always show
} }
if !demoManager.animationStarted { if !demoManager.animationStarted {
return false // Demo mode but animation hasn't started - hide all return false // Demo mode but animation hasn't started
} }
return demoManager.isCellVisibleForYear( return demoManager.isCellVisibleForYear(
@@ -788,28 +802,8 @@ struct DemoYearHeatmapCell: View {
) )
} }
private var cellScale: CGFloat { /// Color for normal (non-demo) mode
isVisible ? 1.0 : 0.0 private var normalCellColor: Color {
}
private var cellOpacity: Double {
isVisible ? 1.0 : 0.0
}
private var cellColor: Color {
if demoManager.isDemoMode {
// In demo mode, show random positive mood colors
if !isVisible {
return Color.gray.opacity(0.1)
}
// Skip placeholder/empty cells
if color == Mood.placeholder.color || color == Mood.missing.color {
return Color.gray.opacity(0.1)
}
return moodTint.color(forMood: randomMood)
}
// Normal mode - use actual colors
if !isFiltered { if !isFiltered {
return Color.gray.opacity(0.1) return Color.gray.opacity(0.1)
} else if color == Mood.placeholder.color || color == Mood.missing.color { } else if color == Mood.placeholder.color || color == Mood.missing.color {