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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user