| 1 | import SwiftUI |
| 2 | |
| 3 | /// A call control's symbol, centred on its button by its ink (0.4.1). |
| 4 | /// |
| 5 | /// SF Symbols are laid out to sit on a line of text, not to be centred in |
| 6 | /// a circle: each is placed by the font's baseline and cap height, and |
| 7 | /// how its weight is spread inside that box is its own business. In a row |
| 8 | /// of borderless buttons that shows. Paul measured the 0.4.0 bar: the |
| 9 | /// share symbol's ink sat 1.25 pt below the others', because its weight |
| 10 | /// is the tray at the bottom and the arrow above it is a thin line. |
| 11 | /// |
| 12 | /// So every symbol a call's controls draw goes through here: set at one |
| 13 | /// point size and weight (`CallGlyphMetrics`, from the button's style), |
| 14 | /// centred in one square the same for every symbol, and moved by its |
| 15 | /// entry in `opticalOffsets`, so that its ink's centre of mass lies on |
| 16 | /// the square's centre line. `CallGlyphTests` renders each symbol at |
| 17 | /// every size the controls use and holds that to within half a point. |
| 18 | public struct CallGlyph: View { |
| 19 | public let symbol: String |
| 20 | /// The ellipsis stood on end, for the iPhone's ⋯ button. The optical |
| 21 | /// offset is along the symbol's own vertical, so a quarter turn makes |
| 22 | /// it a sideways one; the ellipsis is symmetric, so there is none. |
| 23 | public var rotation: Angle |
| 24 | /// Off only to measure where SF Symbols alone would put the ink; |
| 25 | /// `trial` measures a correction (in ems) before it goes in the table. |
| 26 | var corrected = true |
| 27 | var trial: CGFloat? = nil |
| 28 | @Environment(\.callGlyphMetrics) private var metrics |
| 29 | @Environment(\.displayScale) private var displayScale |
| 30 | |
| 31 | public init(_ symbol: String, rotation: Angle = .zero) { |
| 32 | self.symbol = symbol |
| 33 | self.rotation = rotation |
| 34 | } |
| 35 | |
| 36 | public var body: some View { |
| 37 | PixelSquare( |
| 38 | side: metrics.box, |
| 39 | lift: (trial ?? (corrected ? Self.opticalOffset(symbol) : 0)) * metrics.points, |
| 40 | scale: displayScale |
| 41 | ) { |
| 42 | Image(systemName: symbol) |
| 43 | .font(.system(size: metrics.points, weight: metrics.weight)) |
| 44 | .imageScale(.medium) |
| 45 | } |
| 46 | .rotationEffect(rotation) |
| 47 | } |
| 48 | |
| 49 | /// How far each symbol moves to put its ink's centroid on the centre |
| 50 | /// line, in ems (a fraction of the point size, so it holds at every |
| 51 | /// size); negative is up. Tuned by `CallGlyphTests.testTuneTheTable` |
| 52 | /// (run with CALLS_GLYPH_TUNE=1), which prints a fresh table; a symbol |
| 53 | /// added to the controls needs an entry, or it is drawn where SF |
| 54 | /// Symbols puts it and nothing measures it. |
| 55 | public static let opticalOffsets: [String: CGFloat] = [ |
| 56 | "mic.fill": -0.016, |
| 57 | "mic.slash.fill": -0.016, |
| 58 | "video.fill": -0.008, |
| 59 | "video.slash.fill": -0.008, |
| 60 | "rectangle.on.rectangle": 0.016, |
| 61 | "rectangle.on.rectangle.slash": 0.014, |
| 62 | "slider.horizontal.3": 0.000, |
| 63 | "rectangle.bottomthird.inset.filled": -0.070, |
| 64 | "square.grid.2x2": -0.008, |
| 65 | "square.and.arrow.up": -0.110, |
| 66 | "bubble.left.and.bubble.right.fill": 0.074, |
| 67 | "phone.down.fill": 0.014, |
| 68 | "ellipsis": 0.000, |
| 69 | "arrow.triangle.2.circlepath.camera": -0.042, |
| 70 | "waveform": -0.008, |
| 71 | "arrow.down.right.and.arrow.up.left": -0.008, |
| 72 | ] |
| 73 | |
| 74 | public static func opticalOffset(_ symbol: String) -> CGFloat { |
| 75 | opticalOffsets[symbol] ?? 0 |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// The square a symbol is centred in, with the symbol placed on whole |
| 80 | /// pixels by this layout rather than by the renderer. A symbol's box is |
| 81 | /// a fraction of a point tall, so centring it leaves it half a pixel |
| 82 | /// off the grid, and which way that half pixel then rounds is the |
| 83 | /// renderer's choice: a window rounded it one way and an off-screen |
| 84 | /// render the other, a pixel apart. Rounding here makes the two agree, |
| 85 | /// and makes what the tests measure what the screen shows. |
| 86 | struct PixelSquare: Layout { |
| 87 | let side: CGFloat |
| 88 | let lift: CGFloat |
| 89 | let scale: CGFloat |
| 90 | |
| 91 | func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { |
| 92 | CGSize(width: side, height: side) |
| 93 | } |
| 94 | |
| 95 | func placeSubviews( |
| 96 | in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () |
| 97 | ) { |
| 98 | let scale = max(scale, 1) |
| 99 | func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale } |
| 100 | for subview in subviews { |
| 101 | let size = subview.sizeThatFits(.unspecified) |
| 102 | subview.place( |
| 103 | at: CGPoint( |
| 104 | x: bounds.minX + snap((side - size.width) / 2), |
| 105 | y: bounds.minY + snap((side - size.height) / 2 + lift)), |
| 106 | proposal: ProposedViewSize(size)) |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// The size a button's style sets its symbol at: one point size and |
| 112 | /// weight for every symbol in a row, and the square they are centred in. |
| 113 | public struct CallGlyphMetrics: Equatable, Sendable { |
| 114 | public var points: CGFloat |
| 115 | public var weight: Font.Weight |
| 116 | public var box: CGFloat |
| 117 | |
| 118 | public init(points: CGFloat, weight: Font.Weight) { |
| 119 | self.points = points |
| 120 | self.weight = weight |
| 121 | // Wide enough for the widest (the two chat bubbles), and the same |
| 122 | // for all of them; an even number of points, so that centred in a |
| 123 | // button of 44 or 56 it starts on a whole point. |
| 124 | self.box = (points * 0.95).rounded() * 2 |
| 125 | } |
| 126 | |
| 127 | // The text styles the buttons used before 0.4.1, as point sizes, so no |
| 128 | // symbol changed size: macOS's title3 and title2 are 15 and 17 pt, |
| 129 | // iOS's 20 and 22 pt at the default text size. |
| 130 | #if os(macOS) |
| 131 | static let title3: CGFloat = 15 |
| 132 | static let title2: CGFloat = 17 |
| 133 | #else |
| 134 | static let title3: CGFloat = 20 |
| 135 | static let title2: CGFloat = 22 |
| 136 | #endif |
| 137 | |
| 138 | /// The full-window bar's buttons. |
| 139 | public static let bar = CallGlyphMetrics(points: title3, weight: .medium) |
| 140 | /// A round button: the iPhone's ⋯, microphone and End call. |
| 141 | public static let round = CallGlyphMetrics(points: title2, weight: .semibold) |
| 142 | /// The classic row, the mini window, and the iPhone's controls sheet. |
| 143 | public static let action = CallGlyphMetrics(points: title3, weight: .regular) |
| 144 | |
| 145 | /// `self`, scaled for the person's text size. |
| 146 | func scaled(_ factor: CGFloat) -> CallGlyphMetrics { |
| 147 | CallGlyphMetrics(points: (points * factor).rounded(), weight: weight) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | /// Sets the `CallGlyph`s inside at `metrics`, grown or shrunk with the |
| 152 | /// person's text size as the text style the buttons used to set did. |
| 153 | public struct CallGlyphSizing: ViewModifier { |
| 154 | let metrics: CallGlyphMetrics |
| 155 | @ScaledMetric(relativeTo: .title3) private var textScale: CGFloat = 1 |
| 156 | public init(_ metrics: CallGlyphMetrics) { self.metrics = metrics } |
| 157 | public func body(content: Content) -> some View { |
| 158 | content.environment(\.callGlyphMetrics, textScale == 1 ? metrics : metrics.scaled(textScale)) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | private struct CallGlyphMetricsKey: EnvironmentKey { |
| 163 | static let defaultValue = CallGlyphMetrics.action |
| 164 | } |
| 165 | |
| 166 | extension EnvironmentValues { |
| 167 | /// See `CallGlyphMetrics`. Set by the call buttons' styles. |
| 168 | public var callGlyphMetrics: CallGlyphMetrics { |
| 169 | get { self[CallGlyphMetricsKey.self] } |
| 170 | set { self[CallGlyphMetricsKey.self] = newValue } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | extension Button where Label == SwiftUI.Label<Text, CallGlyph> { |
| 175 | /// A call control: its title for VoiceOver and the tooltip, and |
| 176 | /// `glyph` drawn as a `CallGlyph`. |
| 177 | public init( |
| 178 | _ title: String, glyph: String, role: ButtonRole? = nil, action: @escaping () -> Void |
| 179 | ) { |
| 180 | self.init(role: role, action: action) { |
| 181 | SwiftUI.Label { Text(title) } icon: { CallGlyph(glyph) } |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | |