trends: animate the price-history lines (subtle, Reduce-Motion aware)

- Reloads (range/fuel switch, Price<->vs-cheapest) now glide the existing
  lines + y-axis to the new data via withAnimation/.animation(value: mode)
  instead of snapping.
- One-time left->right trace-in on first data appearance by revealing an
  increasing prefix of each series' points (area + line stay in sync); later
  switches morph rather than re-trace.
- Headline average rolls its digits with .contentTransition(.numericText).
- All animation honours .accessibilityReduceMotion (jump-cut to final state).
Kept two-pass area/line ordering, explicit series:, bounded fill bands, and
tight chartYScale unchanged.
This commit is contained in:
FuelBoard Contributor
2026-08-20 17:54:55 +01:00
parent 0b27d3d4f6
commit 69c3c17606
+35 -2
View File
@@ -25,6 +25,7 @@ struct TrendsView: View {
var onHistoryRecovered: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var fuel: FuelType = .e10
@State private var rangeDays: Int = 30
@@ -34,6 +35,11 @@ struct TrendsView: View {
@State private var loadFailed = false
@State private var firstSnapshot: String?
/// Lines trace-in leftright on first appearance by revealing an
/// increasing prefix of each series' points. Stays at the full count after
/// the first reveal so range/mode switches morph instead of re-tracing.
@State private var revealCount: Int = 0
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
/// opens where the user was same pattern as FavouritesView.
init(favourites: [FavouriteEntry],
@@ -157,7 +163,28 @@ struct TrendsView: View {
// pointer probe already done above.
loadFailed = firstSnapshot == nil
}
// Morph vs trace-in: a reload (range/fuel switch) glides the existing
// lines to the new data; the first real draw traces each line in
// leftright. Reduce Motion jumps straight to the final state.
let hadData = hasAnyData
if hadData {
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.35)) {
series = fetched
}
} else {
series = fetched
let maxPoints = fetched.reduce(0) { max($0, $1.points.count) }
if maxPoints > 0 {
if reduceMotion {
revealCount = maxPoints
} else {
revealCount = 0
withAnimation(.easeOut(duration: 0.5)) {
revealCount = maxPoints
}
}
}
}
// A failure with no data IS a connection problem raise the global
// banner so the user isn't stuck with a silent retry state. Success
// clears it (only if the banner is the connection banner).
@@ -330,6 +357,9 @@ struct TrendsView: View {
.font(.system(size: 30, weight: .bold, design: .default))
.monospacedDigit()
.foregroundStyle(.primary)
// Roll the digits to the new figure on range/mode change
// (fires inside the animated transaction above).
.contentTransition(.numericText(value: headAvg))
if let delta = headlineDelta, delta != 0 {
Label(
"\(deltaIsGood ? "" : "+")\(abs(delta), specifier: "%.1f")p",
@@ -389,13 +419,13 @@ struct TrendsView: View {
// type-checker's budget.)
Chart {
ForEach(displaySeries) { history in
ForEach(history.points) { point in
ForEach(history.points.prefix(revealCount)) { point in
areaMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
}
ForEach(displaySeries) { history in
ForEach(history.points) { point in
ForEach(history.points.prefix(revealCount)) { point in
lineMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
@@ -424,6 +454,9 @@ struct TrendsView: View {
}
}
.frame(height: 190)
// Price vs-cheapest is a pure view toggle (no network): animate the
// lines + axis gliding to the rebased series.
.animation(.easeInOut(duration: 0.35), value: mode)
}
/// One gradient-filled area band under a single point of a series.