From d8f0e5d845881d3ded60210cb295d278104780a3 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Sat, 4 Apr 2026 12:20:21 -0500 Subject: [PATCH] feat: animated macro rings, bars, and staggered meal card entrance MacroRing: - Animates from 0 to target on appear (spring, 1.0s response) - Center value fades in + scales up with 0.4s delay - Updates animate smoothly on data change - .contentTransition(.numericText()) on calorie count MacroBar: - Bar width animates from 0 to target on appear (spring, 0.3s delay) - Updates animate smoothly on data change - .contentTransition(.numericText()) on values TodayView: - Meal sections stagger in: fade up with 0.08s delay per card - Re-animates on tab switch (onAppear resets animated flag) - Re-animates on date change - Spring physics for natural feel Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Features/Fitness/Views/TodayView.swift | 31 +++++++++++++---- .../Platform/Shared/Components/MacroBar.swift | 24 +++++++++++-- .../Shared/Components/MacroRing.swift | 34 +++++++++++++++++-- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift b/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift index dc9c4a9..ca2db04 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/TodayView.swift @@ -2,17 +2,14 @@ import SwiftUI struct TodayView: View { @State private var vm = TodayViewModel() + @State private var animated = false var body: some View { ScrollView { VStack(spacing: 16) { - // Date selector dateSelector - - // Macro summary macroSummary - // Meal sections if vm.entries.isEmpty && !vm.isLoading { EmptyStateView( icon: "fork.knife", @@ -20,7 +17,8 @@ struct TodayView: View { subtitle: "Tap + to log your first meal" ) } else { - ForEach(vm.mealGroups, id: \.0) { mealType, entries in + ForEach(Array(vm.mealGroups.enumerated()), id: \.1.0) { index, group in + let (mealType, entries) = group MealSectionView( mealType: mealType, entries: entries, @@ -28,6 +26,9 @@ struct TodayView: View { Task { await vm.deleteEntry(entry) } } ) + .opacity(animated ? 1 : 0) + .offset(y: animated ? 0 : 20) + .animation(.spring(response: 0.5, dampingFraction: 0.8).delay(Double(index) * 0.08), value: animated) } } @@ -41,9 +42,27 @@ struct TodayView: View { .background(Color.canvas) .task { await vm.load() + withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) { + animated = true + } } .onChange(of: vm.selectedDate) { - Task { await vm.load() } + animated = false + Task { + await vm.load() + withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) { + animated = true + } + } + } + .onAppear { + // Re-animate when switching back to this tab + animated = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) { + animated = true + } + } } } diff --git a/ios/Platform/Platform/Shared/Components/MacroBar.swift b/ios/Platform/Platform/Shared/Components/MacroBar.swift index 7c4a9e2..c9f7a62 100644 --- a/ios/Platform/Platform/Shared/Components/MacroBar.swift +++ b/ios/Platform/Platform/Shared/Components/MacroBar.swift @@ -7,7 +7,9 @@ struct MacroBar: View { var color: Color = .emerald var unit: String = "g" - private var progress: Double { + @State private var animatedProgress: Double = 0 + + private var targetProgress: Double { guard goal > 0 else { return 0 } return min(max(consumed / goal, 0), 1.0) } @@ -22,6 +24,7 @@ struct MacroBar: View { Text("\(Int(consumed))/\(Int(goal))\(unit)") .font(.caption.weight(.semibold)) .foregroundStyle(Color.textPrimary) + .contentTransition(.numericText()) } GeometryReader { geo in @@ -32,11 +35,26 @@ struct MacroBar: View { RoundedRectangle(cornerRadius: 4) .fill(color) - .frame(width: geo.size.width * progress, height: 8) - .animation(.easeInOut(duration: 0.5), value: progress) + .frame(width: geo.size.width * animatedProgress, height: 8) } } .frame(height: 8) } + .onAppear { + animatedProgress = 0 + withAnimation(.spring(response: 0.8, dampingFraction: 0.7).delay(0.3)) { + animatedProgress = targetProgress + } + } + .onChange(of: consumed) { _, _ in + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + animatedProgress = targetProgress + } + } + .onChange(of: goal) { _, _ in + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + animatedProgress = targetProgress + } + } } } diff --git a/ios/Platform/Platform/Shared/Components/MacroRing.swift b/ios/Platform/Platform/Shared/Components/MacroRing.swift index dbdfbd2..c8bb1d2 100644 --- a/ios/Platform/Platform/Shared/Components/MacroRing.swift +++ b/ios/Platform/Platform/Shared/Components/MacroRing.swift @@ -7,7 +7,9 @@ struct MacroRing: View { var color: Color = .emerald var size: CGFloat = 100 - private var progress: Double { + @State private var animatedProgress: Double = 0 + + private var targetProgress: Double { guard goal > 0 else { return 0 } return min(max(consumed / goal, 0), 1.0) } @@ -18,15 +20,30 @@ struct MacroRing: View { .stroke(color.opacity(0.15), lineWidth: lineWidth) Circle() - .trim(from: 0, to: progress) + .trim(from: 0, to: animatedProgress) .stroke( color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) ) .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.6), value: progress) } .frame(width: size, height: size) + .onAppear { + animatedProgress = 0 + withAnimation(.spring(response: 1.0, dampingFraction: 0.7).delay(0.2)) { + animatedProgress = targetProgress + } + } + .onChange(of: consumed) { _, _ in + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + animatedProgress = targetProgress + } + } + .onChange(of: goal) { _, _ in + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + animatedProgress = targetProgress + } + } } } @@ -38,6 +55,8 @@ struct MacroRingWithLabel: View { var size: CGFloat = 100 var lineWidth: CGFloat = 10 + @State private var showValue = false + var body: some View { ZStack { MacroRing( @@ -52,10 +71,19 @@ struct MacroRingWithLabel: View { Text("\(Int(consumed))") .font(.system(size: size * 0.22, weight: .bold, design: .rounded)) .foregroundStyle(Color.textPrimary) + .contentTransition(.numericText()) Text(label) .font(.system(size: size * 0.1, weight: .medium)) .foregroundStyle(Color.textSecondary) } + .opacity(showValue ? 1 : 0) + .scaleEffect(showValue ? 1 : 0.5) + } + .onAppear { + showValue = false + withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(0.4)) { + showValue = true + } } } }