diff --git a/appleApp/FTW/UI/PassFigures.swift b/appleApp/FTW/UI/PassFigures.swift index 0bbde43..d4e27e5 100644 --- a/appleApp/FTW/UI/PassFigures.swift +++ b/appleApp/FTW/UI/PassFigures.swift @@ -6,7 +6,8 @@ import SwiftUI /// meets. Each a mono title, a figure and a line, with space between them /// and no borders. The first title takes the pass's colour; a plan that is /// old reads in ink3. It swaps with the pass, with no animation, so a tap -/// answers at once. +/// answers at once. Where the three do not fit side by side, as at the +/// largest text sizes, they stand one under another rather than end in "…". struct PassFigures: View, Equatable { let pass: PlanPass var grey = false @@ -15,30 +16,42 @@ struct PassFigures: View, Equatable { nonisolated static func == (a: Self, b: Self) -> Bool { a.pass == b.pass && a.grey == b.grey } var body: some View { - HStack(alignment: .top, spacing: 12) { - ForEach(Array(pass.figures.enumerated()), id: \.offset) { i, f in - VStack(alignment: i == 2 ? .trailing : .leading, spacing: 1) { - Text(f.title) - .font(Theme.mono()) - .tracking(0.5) - .foregroundStyle(grey ? Theme.ink3 : tint(f.tint)) - .dynamicTypeSize(...DynamicTypeSize.accessibility1) - Text(f.value) - .font(Theme.figure(figure)) - .foregroundStyle(grey ? Theme.ink3 : Theme.ink) - .minimumScaleFactor(0.7) - Text(f.sub) - .font(.caption) - .foregroundStyle(grey ? Theme.ink3 : Theme.ink2) + let figures = Array(pass.figures.enumerated()) + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 12) { + ForEach(figures, id: \.offset) { i, f in + column(f, trailing: i == 2) + .frame(maxWidth: .infinity, alignment: i == 2 ? .trailing : .leading) + } + } + VStack(alignment: .leading, spacing: 12) { + ForEach(figures, id: \.offset) { _, f in + column(f, trailing: false) } - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: i == 2 ? .trailing : .leading) } } .accessibilityElement(children: .ignore) .accessibilityLabel(pass.spoken) } + private func column(_ f: PlanPass.Figure, trailing: Bool) -> some View { + VStack(alignment: trailing ? .trailing : .leading, spacing: 1) { + Text(f.title) + .font(Theme.mono()) + .tracking(0.5) + .foregroundStyle(grey ? Theme.ink3 : tint(f.tint)) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + Text(f.value) + .font(Theme.figure(figure)) + .foregroundStyle(grey ? Theme.ink3 : Theme.ink) + .minimumScaleFactor(0.7) + Text(f.sub) + .font(.caption) + .foregroundStyle(grey ? Theme.ink3 : Theme.ink2) + } + .lineLimit(1) + } + private func tint(_ t: PlanPass.Figure.Tint) -> Color { switch t { case .gives: return Theme.gives diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index bf6999d..19b3043 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -157,8 +157,25 @@ struct PlanStripView: View, Equatable { } /// The kicker and its chevron, and room for the price, which sits over - /// the strip as a link of its own. + /// the strip as a link of its own. Beside the price where both fit; at + /// the largest sizes the price takes the first line and the kicker the + /// next, so the two never overlap. private var header: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + kickerLine + Spacer(minLength: 8) + priceRoom + } + VStack(alignment: .leading, spacing: 4) { + priceRoom + kickerLine + } + } + .animation(Theme.spring, value: strip.kicker) + } + + private var kickerLine: some View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(priceLabel.map { strip.kicker + " · " + $0 } ?? strip.kicker) .font(mono) @@ -172,13 +189,18 @@ struct PlanStripView: View, Equatable { .font(.caption2.weight(.semibold)) .foregroundStyle(Theme.ink3) } - Spacer(minLength: 8) - // The price's line, never drawn: it gives the header the price's - // height and baseline, so the kicker sits on the same line. - Text("\(Text(verbatim: "0").font(Theme.figure(figure))) \(Text(verbatim: unit).font(mono).tracking(0.5))") - .hidden() } - .animation(Theme.spring, value: strip.kicker) + } + + /// The price's line, never drawn: it gives the header the price's + /// width, height and baseline, so the kicker sits on the same line and + /// clear of it. + private var priceRoom: some View { + let figures = priceNow.map { AppWords.decimal($0) } ?? "0" + let words = priceNow == nil ? unit : "\(unit) \(PlanGlance.nowWord)" + return Text("\(Text(verbatim: figures).font(Theme.figure(figure))) \(Text(verbatim: words).font(mono).tracking(0.5))") + .lineLimit(1) + .hidden() } private func follow(_ x: CGFloat) { diff --git a/appleApp/FTWCharts/PlanStripDrawing.swift b/appleApp/FTWCharts/PlanStripDrawing.swift index 7f4715d..43c2575 100644 --- a/appleApp/FTWCharts/PlanStripDrawing.swift +++ b/appleApp/FTWCharts/PlanStripDrawing.swift @@ -28,9 +28,10 @@ struct PlanStripLayout: Equatable { var priceTop: CGFloat { Self.lanesBottom + levelHeight } var priceBottom: CGFloat { priceTop + priceHeight } - /// 100 % and 0 % in the level's row, clear of the lanes and the bars. + /// 100 % and 0 % in the level's row, clear of the lanes and the bars, + /// and 0 %'s figure clear of the price's top figure at any text size. var levelTop: CGFloat { Self.lanesBottom + 5 } - var levelBottom: CGFloat { priceTop - 14 } + var levelBottom: CGFloat { priceTop - max(14, (axis * 1.2).rounded(.up)) } func levelY(_ pct: Double) -> CGFloat { levelBottom - CGFloat(min(100, max(0, pct)) / 100) * (levelBottom - levelTop) } var hoursTop: CGFloat { priceBottom + 3 } /// Room on the right for the scale's figures, "600", in the axis's @@ -177,17 +178,22 @@ struct PlanStripDrawing: ChartDrawing { if layout.levelHeight > 0 { drawLevel(in: &g, layout: layout, width: width, px: px, ghost: ghost, chosen: chosen) } // The scale: a hairline at half the top and at the top, each with - // its figure in the gutter, and the zero line when a price falls - // under it. + // its figure in the gutter where there is room for it, and the zero + // line when a price falls under it. In a short row at a large text + // size the top keeps its figure and half the top keeps only its line. let priceScale = strip.scale var lines = Path() for v in priceScale.lines + (priceScale.bottom < 0 ? [0] : []) { lines.addRect(CGRect(x: 0, y: snap(layout.priceY(v, priceScale)), width: width, height: px)) } g.fill(lines, with: .color(Theme.ink.opacity(0.1))) - for v in priceScale.lines { + var lastY: CGFloat? + for v in priceScale.lines.sorted(by: >) { let text = g.resolve(ChartText.label(priceScale.label(v), size: layout.axis * 0.9)) - g.draw(text, at: CGPoint(x: width + 4, y: layout.priceY(v, priceScale)), anchor: .leading) + let y = layout.priceY(v, priceScale) + if let lastY, abs(y - lastY) < text.measure(in: size).height + 2 { continue } + g.draw(text, at: CGPoint(x: width + 4, y: y), anchor: .leading) + lastY = y } // The price: a bar per stretch of equal price and equal ink. Ink by