import SwiftUI /// A call control's symbol, centred on its button by its ink (0.4.1). /// /// SF Symbols are laid out to sit on a line of text, not to be centred in /// a circle: each is placed by the font's baseline and cap height, and /// how its weight is spread inside that box is its own business. In a row /// of borderless buttons that shows. Paul measured the 0.4.0 bar: the /// share symbol's ink sat 1.25 pt below the others', because its weight /// is the tray at the bottom and the arrow above it is a thin line. /// /// So every symbol a call's controls draw goes through here: set at one /// point size and weight (`CallGlyphMetrics`, from the button's style), /// centred in one square the same for every symbol, and moved by its /// entry in `opticalOffsets`, so that its ink's centre of mass lies on /// the square's centre line. `CallGlyphTests` renders each symbol at /// every size the controls use and holds that to within half a point. public struct CallGlyph: View { public let symbol: String /// The ellipsis stood on end, for the iPhone's ⋯ button. The optical /// offset is along the symbol's own vertical, so a quarter turn makes /// it a sideways one; the ellipsis is symmetric, so there is none. public var rotation: Angle /// Off only to measure where SF Symbols alone would put the ink; /// `trial` measures a correction (in ems) before it goes in the table. var corrected = true var trial: CGFloat? = nil @Environment(\.callGlyphMetrics) private var metrics @Environment(\.displayScale) private var displayScale public init(_ symbol: String, rotation: Angle = .zero) { self.symbol = symbol self.rotation = rotation } public var body: some View { PixelSquare( side: metrics.box, lift: (trial ?? (corrected ? Self.opticalOffset(symbol) : 0)) * metrics.points, scale: displayScale ) { Image(systemName: symbol) .font(.system(size: metrics.points, weight: metrics.weight)) .imageScale(.medium) } .rotationEffect(rotation) } /// How far each symbol moves to put its ink's centroid on the centre /// line, in ems (a fraction of the point size, so it holds at every /// size); negative is up. Tuned by `CallGlyphTests.testTuneTheTable` /// (run with CALLS_GLYPH_TUNE=1), which prints a fresh table; a symbol /// added to the controls needs an entry, or it is drawn where SF /// Symbols puts it and nothing measures it. public static let opticalOffsets: [String: CGFloat] = [ "mic.fill": -0.016, "mic.slash.fill": -0.016, "video.fill": -0.008, "video.slash.fill": -0.008, "rectangle.on.rectangle": 0.016, "rectangle.on.rectangle.slash": 0.014, "slider.horizontal.3": 0.000, "rectangle.bottomthird.inset.filled": -0.070, "square.grid.2x2": -0.008, "square.and.arrow.up": -0.110, "bubble.left.and.bubble.right.fill": 0.074, "phone.down.fill": 0.014, "ellipsis": 0.000, "arrow.triangle.2.circlepath.camera": -0.042, "waveform": -0.008, "arrow.down.right.and.arrow.up.left": -0.008, ] public static func opticalOffset(_ symbol: String) -> CGFloat { opticalOffsets[symbol] ?? 0 } } /// The square a symbol is centred in, with the symbol placed on whole /// pixels by this layout rather than by the renderer. A symbol's box is /// a fraction of a point tall, so centring it leaves it half a pixel /// off the grid, and which way that half pixel then rounds is the /// renderer's choice: a window rounded it one way and an off-screen /// render the other, a pixel apart. Rounding here makes the two agree, /// and makes what the tests measure what the screen shows. struct PixelSquare: Layout { let side: CGFloat let lift: CGFloat let scale: CGFloat func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { CGSize(width: side, height: side) } func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) { let scale = max(scale, 1) func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale } for subview in subviews { let size = subview.sizeThatFits(.unspecified) subview.place( at: CGPoint( x: bounds.minX + snap((side - size.width) / 2), y: bounds.minY + snap((side - size.height) / 2 + lift)), proposal: ProposedViewSize(size)) } } } /// The size a button's style sets its symbol at: one point size and /// weight for every symbol in a row, and the square they are centred in. public struct CallGlyphMetrics: Equatable, Sendable { public var points: CGFloat public var weight: Font.Weight public var box: CGFloat public init(points: CGFloat, weight: Font.Weight) { self.points = points self.weight = weight // Wide enough for the widest (the two chat bubbles), and the same // for all of them; an even number of points, so that centred in a // button of 44 or 56 it starts on a whole point. self.box = (points * 0.95).rounded() * 2 } // The text styles the buttons used before 0.4.1, as point sizes, so no // symbol changed size: macOS's title3 and title2 are 15 and 17 pt, // iOS's 20 and 22 pt at the default text size. #if os(macOS) static let title3: CGFloat = 15 static let title2: CGFloat = 17 #else static let title3: CGFloat = 20 static let title2: CGFloat = 22 #endif /// The full-window bar's buttons. public static let bar = CallGlyphMetrics(points: title3, weight: .medium) /// A round button: the iPhone's ⋯, microphone and End call. public static let round = CallGlyphMetrics(points: title2, weight: .semibold) /// The classic row, the mini window, and the iPhone's controls sheet. public static let action = CallGlyphMetrics(points: title3, weight: .regular) /// `self`, scaled for the person's text size. func scaled(_ factor: CGFloat) -> CallGlyphMetrics { CallGlyphMetrics(points: (points * factor).rounded(), weight: weight) } } /// Sets the `CallGlyph`s inside at `metrics`, grown or shrunk with the /// person's text size as the text style the buttons used to set did. public struct CallGlyphSizing: ViewModifier { let metrics: CallGlyphMetrics @ScaledMetric(relativeTo: .title3) private var textScale: CGFloat = 1 public init(_ metrics: CallGlyphMetrics) { self.metrics = metrics } public func body(content: Content) -> some View { content.environment(\.callGlyphMetrics, textScale == 1 ? metrics : metrics.scaled(textScale)) } } private struct CallGlyphMetricsKey: EnvironmentKey { static let defaultValue = CallGlyphMetrics.action } extension EnvironmentValues { /// See `CallGlyphMetrics`. Set by the call buttons' styles. public var callGlyphMetrics: CallGlyphMetrics { get { self[CallGlyphMetricsKey.self] } set { self[CallGlyphMetricsKey.self] = newValue } } } extension Button where Label == SwiftUI.Label { /// A call control: its title for VoiceOver and the tooltip, and /// `glyph` drawn as a `CallGlyph`. public init( _ title: String, glyph: String, role: ButtonRole? = nil, action: @escaping () -> Void ) { self.init(role: role, action: action) { SwiftUI.Label { Text(title) } icon: { CallGlyph(glyph) } } } }