From 15ff52d043433f618ea9345cc249ff05368e9ce9 Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 29 Jan 2026 11:46:57 -0600 Subject: [PATCH] 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 --- Shared/DemoAnimationManager.swift | 21 +++- Shared/Views/MonthView/MonthView.swift | 164 ++++++++++++++----------- Shared/Views/YearView/YearView.swift | 66 +++++----- 3 files changed, 140 insertions(+), 111 deletions(-) diff --git a/Shared/DemoAnimationManager.swift b/Shared/DemoAnimationManager.swift index dd12836..f373256 100644 --- a/Shared/DemoAnimationManager.swift +++ b/Shared/DemoAnimationManager.swift @@ -29,8 +29,8 @@ final class DemoAnimationManager: ObservableObject { /// Total animation duration for filling all cells in one month let monthAnimationDuration: TimeInterval = 1.5 - /// Delay between months - let monthDelay: TimeInterval = 0.3 + /// Delay between months (0 for fluid continuous animation) + let monthDelay: TimeInterval = 0.0 /// Delay between each cell in year view let yearCellDelay: TimeInterval = 0.05 @@ -116,6 +116,23 @@ final class DemoAnimationManager: ObservableObject { 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 /// For MonthView: animates cells within each month from top-left to bottom-right /// - Parameters: diff --git a/Shared/Views/MonthView/MonthView.swift b/Shared/Views/MonthView/MonthView.swift index 8790a8e..8450d9e 100644 --- a/Shared/Views/MonthView/MonthView.swift +++ b/Shared/Views/MonthView/MonthView.swift @@ -166,47 +166,67 @@ struct MonthView: View { EmptyHomeView(showVote: false, viewModel: nil) .padding() } else { - ScrollView { - VStack(spacing: 16) { - let allMonths = displayData.flatMap { yearData in - yearData.months.map { (year: yearData.year, month: $0.month, entries: $0.entries) } + ScrollViewReader { scrollProxy in + ScrollView { + VStack(spacing: 16) { + 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 - 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 - } - ) + .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) + } + ) + } + .onChange(of: demoManager.animationProgress) { _, progress in + guard demoManager.isDemoMode && demoManager.animationStarted else { return } + + // Start slow scroll once first month is 50% done + let halfwayPoint = demoManager.monthAnimationDuration * 0.5 + if progress >= halfwayPoint && progress < halfwayPoint + 0.1 { + // Trigger once: scroll to bottom with long duration for smooth constant speed + let totalMonths = displayData.flatMap { $0.months }.count + let totalDuration = Double(totalMonths) * demoManager.monthAnimationDuration + withAnimation(.linear(duration: totalDuration)) { + scrollProxy.scrollTo("scroll-end", anchor: .bottom) + } } } - .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) .mask( @@ -701,25 +721,43 @@ struct DemoHeatmapCell: View { @State private var randomMood: Mood = .great var body: some View { - RoundedRectangle(cornerRadius: 4) - .fill(cellColor) - .aspectRatio(1, contentMode: .fit) - .scaleEffect(cellScale) - .opacity(cellOpacity) - .animation(.spring(response: 0.4, dampingFraction: 0.6), value: isVisible) - .onAppear { - // Generate random mood once when cell appears - randomMood = DemoAnimationManager.randomPositiveMood() + ZStack { + // Background: Gray grid cell always visible at 1x scale + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.3)) + .aspectRatio(1, contentMode: .fit) + + // Foreground: Animated mood color that scales 2x -> 1x + if demoManager.isDemoMode { + // 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 - private var isVisible: Bool { + /// Whether this cell has been animated (filled with color) + private var isAnimated: Bool { if !demoManager.isDemoMode { - return true // Normal mode - always visible + return true // Normal mode - always show } 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( row: row, @@ -730,28 +768,8 @@ struct DemoHeatmapCell: View { ) } - private var cellScale: CGFloat { - isVisible ? 1.0 : 0.3 - } - - 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 + /// Color for normal (non-demo) mode + private var normalCellColor: Color { if entry.mood == .placeholder { return Color.gray.opacity(0.1) } else if entry.mood == .missing { diff --git a/Shared/Views/YearView/YearView.swift b/Shared/Views/YearView/YearView.swift index 71e0f91..a4a9b42 100644 --- a/Shared/Views/YearView/YearView.swift +++ b/Shared/Views/YearView/YearView.swift @@ -757,26 +757,40 @@ struct DemoYearHeatmapCell: View { @State private var randomMood: Mood = .great var body: some View { - RoundedRectangle(cornerRadius: 2) - .fill(cellColor) - .aspectRatio(1, contentMode: .fit) - .scaleEffect(cellScale) - .opacity(cellOpacity) - .animation(.spring(response: 0.35, dampingFraction: 0.5), value: isVisible) - .onAppear { - // Generate random mood once when cell appears - randomMood = DemoAnimationManager.randomPositiveMood() + ZStack { + // Background: Gray grid cell always visible at 1x scale + RoundedRectangle(cornerRadius: 2) + .fill(Color.gray.opacity(0.3)) + .aspectRatio(1, contentMode: .fit) + + // Foreground: Animated mood color that scales 2x -> 1x + if demoManager.isDemoMode { + 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 - /// For year view, we animate column by column (left to right), then row by row within each column - private var isVisible: Bool { + /// Whether this cell has been animated (filled with color) + private var isAnimated: Bool { if !demoManager.isDemoMode { - return true // Normal mode - always visible + return true // Normal mode - always show } 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( @@ -788,28 +802,8 @@ struct DemoYearHeatmapCell: View { ) } - private var cellScale: CGFloat { - isVisible ? 1.0 : 0.0 - } - - 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 + /// Color for normal (non-demo) mode + private var normalCellColor: Color { if !isFiltered { return Color.gray.opacity(0.1) } else if color == Mood.placeholder.color || color == Mood.missing.color {