diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index e820de15f83a..5ba83f0ccb09 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -118,8 +118,13 @@ struct ChartCard: View { @ViewBuilder private var chartContentView: some View { if viewModel.isFirstLoad { - mainChartView(metric: selectedMetric, data: mockChartData) - .redacted(reason: .placeholder) + // Plain shapes instead of a `Chart` with mock data. Building Swift Charts + // (and compiling its Metal shaders) during the push transition is a + // measurable part of the first-open lag, and the placeholder is replaced + // as soon as data arrives anyway. + ChartLoadingPlaceholder(chartType: selectedChartType, metric: selectedMetric) + .frame(height: chartHeight) + .padding(.horizontal, -Constants.step1) .opacity(0.2) .pulsating() } else if let data = viewModel.chartData[selectedMetric] { @@ -291,6 +296,53 @@ struct ChartCard: View { } } +/// A cheap stand-in for the chart while the first data set loads, mirroring the +/// card's selected chart type. +private struct ChartLoadingPlaceholder: View { + let chartType: ChartType + let metric: SiteMetric + + private let barHeights: [CGFloat] = [0.45, 0.7, 0.55, 0.9, 0.6, 0.8, 0.5, 0.75, 0.65, 0.85, 0.4, 0.7, 0.6, 0.95] + private let lineHeights: [CGFloat] = [ + 0.32, 0.38, 0.35, 0.42, 0.46, 0.43, 0.50, 0.55, 0.52, 0.58, 0.63, 0.60, 0.67, 0.72, 0.69, 0.78, 0.85 + ] + + var body: some View { + switch chartType { + case .columns: + bars + case .line: + line + } + } + + private var bars: some View { + GeometryReader { proxy in + HStack(alignment: .bottom, spacing: Constants.step0_5) { + ForEach(barHeights.indices, id: \.self) { index in + RoundedRectangle(cornerRadius: 3) + .fill(metric.primaryColor) + .frame(height: proxy.size.height * barHeights[index]) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + } + + private var line: some View { + GeometryReader { proxy in + let size = proxy.size + let step = size.width / CGFloat(lineHeights.count - 1) + Path { path in + path.addLines(lineHeights.enumerated().map { index, height in + CGPoint(x: CGFloat(index) * step, y: size.height * (1 - height)) + }) + } + .stroke(metric.primaryColor, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) + } + } +} + private struct CardGradientBackground: View { let metric: SiteMetric @@ -408,3 +460,13 @@ private struct ChartCardPreview: View { } .background(Color(.systemGroupedBackground)) } + +#Preview("Loading placeholders") { + VStack(spacing: 24) { + ChartLoadingPlaceholder(chartType: .columns, metric: .views) + .frame(height: 140) + ChartLoadingPlaceholder(chartType: .line, metric: .views) + .frame(height: 140) + } + .padding() +} diff --git a/Modules/Sources/JetpackStats/Cards/TodayCard.swift b/Modules/Sources/JetpackStats/Cards/TodayCard.swift index b9c947db166d..febf7c1e0a49 100644 --- a/Modules/Sources/JetpackStats/Cards/TodayCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TodayCard.swift @@ -106,8 +106,14 @@ struct TodayCard: View { if let data = viewModel.data { makeSparklineView(data) } else { - let placeholder = makeSparklineView(placeholderData) - .redacted(reason: .placeholder) + // A plain shape instead of a `Chart` with placeholder data. See the + // matching note in `ChartCard`: building Swift Charts during the push + // transition is a measurable part of the first-open lag. + let placeholder = SparklinePlaceholder(metric: .views) + .frame(maxWidth: .infinity) + .padding(.trailing, 32) + .padding(.vertical, 2) + .offset(y: -3) if viewModel.isLoading { placeholder.pulsating().opacity(0.33) } else { @@ -201,6 +207,31 @@ struct TodayCard: View { } } +/// A cheap stand-in for `SparklineChart` while the first data set loads: +/// a gently rising polyline with light jitter, echoing the bell-curve flank +/// the real chart's placeholder data used to draw. No area wash: under the +/// loading state's dimming its contribution measures ~2/255, invisible. +private struct SparklinePlaceholder: View { + let metric: SiteMetric + + private let relativeHeights: [CGFloat] = [ + 0.08, 0.11, 0.10, 0.14, 0.16, 0.22, 0.25, 0.35, 0.41, 0.53, 0.61, 0.75, 0.83 + ] + + var body: some View { + GeometryReader { proxy in + let size = proxy.size + let step = size.width / CGFloat(relativeHeights.count - 1) + Path { path in + path.addLines(relativeHeights.enumerated().map { index, height in + CGPoint(x: CGFloat(index) * step, y: size.height * (1 - height)) + }) + } + .stroke(metric.primaryColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + } +} + private struct SparklineChart: View { let dataPoints: [(hour: Int, value: Int)] let previousDataPoints: [(hour: Int, value: Int)] @@ -349,3 +380,9 @@ private struct TodayCardPreview: View { .cardStyle() } } + +#Preview("Sparkline placeholder") { + SparklinePlaceholder(metric: .views) + .frame(width: 220, height: 52) + .padding() +} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 2532da8ba574..7faa992a7bd6 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,6 @@ 27.3 ----- -* [*] Stats: Improve performance when opening the Stats screen [#25938] +* [*] Stats: Improve performance when opening the Stats screen [#25938, #25931] 27.2 -----