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 {
|
struct TodayView: View {
|
||||||
@State private var vm = TodayViewModel()
|
@State private var vm = TodayViewModel()
|
||||||
|
@State private var animated = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
// Date selector
|
|
||||||
dateSelector
|
dateSelector
|
||||||
|
|
||||||
// Macro summary
|
|
||||||
macroSummary
|
macroSummary
|
||||||
|
|
||||||
// Meal sections
|
|
||||||
if vm.entries.isEmpty && !vm.isLoading {
|
if vm.entries.isEmpty && !vm.isLoading {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "fork.knife",
|
icon: "fork.knife",
|
||||||
@@ -20,7 +17,8 @@ struct TodayView: View {
|
|||||||
subtitle: "Tap + to log your first meal"
|
subtitle: "Tap + to log your first meal"
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
MealSectionView(
|
||||||
mealType: mealType,
|
mealType: mealType,
|
||||||
entries: entries,
|
entries: entries,
|
||||||
@@ -28,6 +26,9 @@ struct TodayView: View {
|
|||||||
Task { await vm.deleteEntry(entry) }
|
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)
|
.background(Color.canvas)
|
||||||
.task {
|
.task {
|
||||||
await vm.load()
|
await vm.load()
|
||||||
|
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
|
||||||
|
animated = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: vm.selectedDate) {
|
.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 color: Color = .emerald
|
||||||
var unit: String = "g"
|
var unit: String = "g"
|
||||||
|
|
||||||
private var progress: Double {
|
@State private var animatedProgress: Double = 0
|
||||||
|
|
||||||
|
private var targetProgress: Double {
|
||||||
guard goal > 0 else { return 0 }
|
guard goal > 0 else { return 0 }
|
||||||
return min(max(consumed / goal, 0), 1.0)
|
return min(max(consumed / goal, 0), 1.0)
|
||||||
}
|
}
|
||||||
@@ -22,6 +24,7 @@ struct MacroBar: View {
|
|||||||
Text("\(Int(consumed))/\(Int(goal))\(unit)")
|
Text("\(Int(consumed))/\(Int(goal))\(unit)")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
.contentTransition(.numericText())
|
||||||
}
|
}
|
||||||
|
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
@@ -32,11 +35,26 @@ struct MacroBar: View {
|
|||||||
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(color)
|
.fill(color)
|
||||||
.frame(width: geo.size.width * progress, height: 8)
|
.frame(width: geo.size.width * animatedProgress, height: 8)
|
||||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(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 color: Color = .emerald
|
||||||
var size: CGFloat = 100
|
var size: CGFloat = 100
|
||||||
|
|
||||||
private var progress: Double {
|
@State private var animatedProgress: Double = 0
|
||||||
|
|
||||||
|
private var targetProgress: Double {
|
||||||
guard goal > 0 else { return 0 }
|
guard goal > 0 else { return 0 }
|
||||||
return min(max(consumed / goal, 0), 1.0)
|
return min(max(consumed / goal, 0), 1.0)
|
||||||
}
|
}
|
||||||
@@ -18,15 +20,30 @@ struct MacroRing: View {
|
|||||||
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: progress)
|
.trim(from: 0, to: animatedProgress)
|
||||||
.stroke(
|
.stroke(
|
||||||
color,
|
color,
|
||||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.animation(.easeInOut(duration: 0.6), value: progress)
|
|
||||||
}
|
}
|
||||||
.frame(width: size, height: size)
|
.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 size: CGFloat = 100
|
||||||
var lineWidth: CGFloat = 10
|
var lineWidth: CGFloat = 10
|
||||||
|
|
||||||
|
@State private var showValue = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
MacroRing(
|
MacroRing(
|
||||||
@@ -52,10 +71,19 @@ struct MacroRingWithLabel: View {
|
|||||||
Text("\(Int(consumed))")
|
Text("\(Int(consumed))")
|
||||||
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(Color.textPrimary)
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
.contentTransition(.numericText())
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: size * 0.1, weight: .medium))
|
.font(.system(size: size * 0.1, weight: .medium))
|
||||||
.foregroundStyle(Color.textSecondary)
|
.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