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:
@@ -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 left→right 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
|
||||
}
|
||||
series = fetched
|
||||
// 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
|
||||
// left→right. 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.
|
||||
|
||||
Reference in New Issue
Block a user