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:
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user