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
|
var onHistoryRecovered: (() -> Void)? = nil
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
@State private var fuel: FuelType = .e10
|
@State private var fuel: FuelType = .e10
|
||||||
@State private var rangeDays: Int = 30
|
@State private var rangeDays: Int = 30
|
||||||
@@ -34,6 +35,11 @@ struct TrendsView: View {
|
|||||||
@State private var loadFailed = false
|
@State private var loadFailed = false
|
||||||
@State private var firstSnapshot: String?
|
@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
|
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
|
||||||
/// opens where the user was — same pattern as FavouritesView.
|
/// opens where the user was — same pattern as FavouritesView.
|
||||||
init(favourites: [FavouriteEntry],
|
init(favourites: [FavouriteEntry],
|
||||||
@@ -157,7 +163,28 @@ struct TrendsView: View {
|
|||||||
// pointer probe already done above.
|
// pointer probe already done above.
|
||||||
loadFailed = firstSnapshot == nil
|
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
|
// 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
|
// banner so the user isn't stuck with a silent retry state. Success
|
||||||
// clears it (only if the banner is the connection banner).
|
// 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))
|
.font(.system(size: 30, weight: .bold, design: .default))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.primary)
|
.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 {
|
if let delta = headlineDelta, delta != 0 {
|
||||||
Label(
|
Label(
|
||||||
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
||||||
@@ -389,13 +419,13 @@ struct TrendsView: View {
|
|||||||
// type-checker's budget.)
|
// type-checker's budget.)
|
||||||
Chart {
|
Chart {
|
||||||
ForEach(displaySeries) { history in
|
ForEach(displaySeries) { history in
|
||||||
ForEach(history.points) { point in
|
ForEach(history.points.prefix(revealCount)) { point in
|
||||||
areaMark(point, series: history.name,
|
areaMark(point, series: history.name,
|
||||||
color: seriesColor(index(of: history.stationID)))
|
color: seriesColor(index(of: history.stationID)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ForEach(displaySeries) { history in
|
ForEach(displaySeries) { history in
|
||||||
ForEach(history.points) { point in
|
ForEach(history.points.prefix(revealCount)) { point in
|
||||||
lineMark(point, series: history.name,
|
lineMark(point, series: history.name,
|
||||||
color: seriesColor(index(of: history.stationID)))
|
color: seriesColor(index(of: history.stationID)))
|
||||||
}
|
}
|
||||||
@@ -424,6 +454,9 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 190)
|
.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.
|
/// One gradient-filled area band under a single point of a series.
|
||||||
|
|||||||
Reference in New Issue
Block a user