| 1 | import SwiftUI |
| 2 | |
| 3 | /// Compact call controls retain their Button labels for VoiceOver and tooltips. |
| 4 | /// |
| 5 | /// With `overlay` set, the button takes the look of controls over video |
| 6 | /// (see `CallOverlay`) in that state, and `color` and `emphasized` are |
| 7 | /// not used: the Mac's control row is one set of buttons in two places, |
| 8 | /// the classic layout's row and the full-window call's bar. |
| 9 | public struct CallActionStyle: ButtonStyle { |
| 10 | public var color: Color |
| 11 | public var emphasized: Bool |
| 12 | public var overlay: CallOverlayButtonStyle.Tone? |
| 13 | @Environment(\.isEnabled) private var isEnabled |
| 14 | |
| 15 | public init( |
| 16 | color: Color = CallsAppearance.accent, emphasized: Bool = false, |
| 17 | overlay: CallOverlayButtonStyle.Tone? = nil |
| 18 | ) { |
| 19 | self.color = color |
| 20 | self.emphasized = emphasized |
| 21 | self.overlay = overlay |
| 22 | } |
| 23 | |
| 24 | @ViewBuilder public func makeBody(configuration: Configuration) -> some View { |
| 25 | if let overlay { |
| 26 | OverlayButtonBody( |
| 27 | label: configuration.label, tone: overlay, size: 44, round: false, |
| 28 | pressed: configuration.isPressed, enabled: isEnabled) |
| 29 | } else { |
| 30 | configuration.label.labelStyle(.iconOnly).font(.title3) |
| 31 | .modifier(CallGlyphSizing(.action)) |
| 32 | .frame(width: 44, height: 44) |
| 33 | .foregroundStyle(emphasized ? .white : color) |
| 34 | .background(emphasized ? color : color.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) |
| 35 | .opacity(!isEnabled ? 0.4 : configuration.isPressed ? 0.65 : 1) |
| 36 | .contentShape(RoundedRectangle(cornerRadius: 14)) |
| 37 | } |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | // MARK: - Over the picture |
| 42 | |
| 43 | /// The look of everything the full-window call puts over its video — the |
| 44 | /// control bar and its buttons, the title pill, the name labels and the |
| 45 | /// notices — as one system (0.3.7): borderless glass panes, dark whatever |
| 46 | /// the system appearance, because what is under them is always video, and |
| 47 | /// video can be anything from a black room to a white wall. |
| 48 | /// |
| 49 | /// Each surface is Liquid Glass where the system has it (macOS 26, iOS |
| 50 | /// 26), and the ultra-thin material before that, with black at `scrim` |
| 51 | /// laid on it. The glass and the blur only soften; the black does the |
| 52 | /// work, so the contrast holds over anything and even where neither is |
| 53 | /// drawn: white under 62 % black is #616161, so white on a surface is |
| 54 | /// 6.2:1 at worst and 21:1 at best (over black). Rendered, the glass over |
| 55 | /// white measures #5B5B5B (6.8:1). No hairline edges anywhere: a surface |
| 56 | /// is told from the picture by its tone, and from black video by the |
| 57 | /// glass's own light. `CallOverlayContrastTests` holds the ratios. |
| 58 | public enum CallOverlay { |
| 59 | /// The black laid over the glass or material. |
| 60 | public static let scrim: Double = 0.62 |
| 61 | /// A resting button has no fill of its own: a white symbol on the pane. |
| 62 | /// Under the pointer it lightens by this, and pressed by `pressFill`; |
| 63 | /// white on the hovered pane is still 4.9:1 at worst. |
| 64 | public static let hoverFill = Color.white.opacity(0.1) |
| 65 | public static let pressFill = Color.white.opacity(0.18) |
| 66 | /// A control that is on — a panel open, the screen being shared: a |
| 67 | /// near-white capsule with a near-black symbol, 15:1 between them and |
| 68 | /// 6.8:1 from the pane at its lightest. |
| 69 | public static let onFill = Color(white: 0.94) |
| 70 | public static let onSymbol = Color(white: 0.1) |
| 71 | /// Microphone or camera off: an amber capsule, with the slashed symbol |
| 72 | /// in white (5.7:1). A warning that others cannot hear or see you, and |
| 73 | /// distinct from Leave's red and from the white of a control that is on. |
| 74 | public static let offFill = Color(red: 0.65, green: 0.30, blue: 0.02) |
| 75 | /// Leave and End call: a red capsule deep enough for white on it to read |
| 76 | /// (5.0:1; the system red would be 3.4:1). |
| 77 | public static let leaveFill = Color(red: 0.84, green: 0.16, blue: 0.14) |
| 78 | /// The unread count on Chat: white on violet, 6.0:1. |
| 79 | public static let badgeFill = Color(red: 0.49, green: 0.23, blue: 0.85) |
| 80 | /// Secondary text on a surface: the status line, a quiet notice. 5.0:1 |
| 81 | /// over the worst-case surface. |
| 82 | public static let secondaryText = Color.white.opacity(0.85) |
| 83 | /// A notice that must be seen (you are sharing your screen): a light |
| 84 | /// amber, 4.6:1 over the worst-case surface. |
| 85 | public static let alertText = Color(red: 1.0, green: 0.85, blue: 0.6) |
| 86 | /// A panel over the picture that is read rather than glanced at (the |
| 87 | /// Devices panel): opaque, so nothing of the video shows through the |
| 88 | /// text. The dark appearance's own label colours sit on it at full |
| 89 | /// contrast (primary 17:1, secondary 7.9:1). |
| 90 | public static let panelFill = Color(white: 0.12) |
| 91 | } |
| 92 | |
| 93 | private struct CallOverlayGlassKey: EnvironmentKey { |
| 94 | static let defaultValue = true |
| 95 | } |
| 96 | |
| 97 | extension EnvironmentValues { |
| 98 | /// Whether the overlay's surfaces may use Liquid Glass where the system |
| 99 | /// has it. True in the apps; the snapshots turn it off to show the |
| 100 | /// material every older system draws instead. |
| 101 | public var callOverlayGlass: Bool { |
| 102 | get { self[CallOverlayGlassKey.self] } |
| 103 | set { self[CallOverlayGlassKey.self] = newValue } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// A surface over the picture: borderless glass — Liquid Glass on macOS |
| 108 | /// and iOS 26, the ultra-thin material before — with black at |
| 109 | /// `CallOverlay.scrim` on it. Only the background is made dark, so a sheet |
| 110 | /// or popover the content opens keeps the person's own appearance. |
| 111 | /// |
| 112 | /// `rim: false` leaves Liquid Glass out even where the system has it: the |
| 113 | /// glass lights its own edge, a faint light hairline that a small pill |
| 114 | /// over a dark picture shows plainly (the sharing notice, 2026-09-28). |
| 115 | public struct CallOverlaySurface<S: InsettableShape>: ViewModifier { |
| 116 | let shape: S |
| 117 | let rim: Bool |
| 118 | @Environment(\.callOverlayGlass) private var glass |
| 119 | public init(_ shape: S, rim: Bool = true) { |
| 120 | self.shape = shape |
| 121 | self.rim = rim |
| 122 | } |
| 123 | public func body(content: Content) -> some View { |
| 124 | if glass, rim, #available(macOS 26, iOS 26, *) { |
| 125 | content |
| 126 | .background(shape.fill(Color.black.opacity(CallOverlay.scrim))) |
| 127 | .glassEffect(.regular, in: shape) |
| 128 | } else { |
| 129 | content |
| 130 | .background { |
| 131 | ZStack { |
| 132 | shape.fill(.ultraThinMaterial) |
| 133 | shape.fill(Color.black.opacity(CallOverlay.scrim)) |
| 134 | } |
| 135 | .environment(\.colorScheme, .dark) |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | extension View { |
| 142 | /// See `CallOverlaySurface`. |
| 143 | public func callOverlaySurface<S: InsettableShape>(_ shape: S, rim: Bool = true) -> some View { |
| 144 | modifier(CallOverlaySurface(shape, rim: rim)) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | /// A button over the picture, in one of four states. At rest it is only a |
| 149 | /// white symbol on the pane, lightening under the pointer; a filled |
| 150 | /// capsule appears behind it only in a state that needs one, and the state |
| 151 | /// is always the symbol as well as the fill: a muted microphone is slashed |
| 152 | /// and amber, a control that is on is white with a dark symbol. |
| 153 | public struct CallOverlayButtonStyle: ButtonStyle { |
| 154 | public enum Tone: Sendable { |
| 155 | /// Resting: a white symbol on the pane. |
| 156 | case normal |
| 157 | /// On: a panel open, the screen being shared. |
| 158 | case on |
| 159 | /// Microphone or camera off. |
| 160 | case off |
| 161 | /// Leave, End call. |
| 162 | case leave |
| 163 | } |
| 164 | |
| 165 | public var tone: Tone |
| 166 | public var size: CGFloat |
| 167 | public var round: Bool |
| 168 | @Environment(\.isEnabled) private var isEnabled |
| 169 | |
| 170 | public init(_ tone: Tone = .normal, size: CGFloat = 44, round: Bool = false) { |
| 171 | self.tone = tone |
| 172 | self.size = size |
| 173 | self.round = round |
| 174 | } |
| 175 | |
| 176 | public func makeBody(configuration: Configuration) -> some View { |
| 177 | OverlayButtonBody( |
| 178 | label: configuration.label, tone: tone, size: size, round: round, |
| 179 | pressed: configuration.isPressed, enabled: isEnabled) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | private struct OverlayButtonBody<Label: View>: View { |
| 184 | let label: Label |
| 185 | let tone: CallOverlayButtonStyle.Tone |
| 186 | let size: CGFloat |
| 187 | let round: Bool |
| 188 | let pressed: Bool |
| 189 | let enabled: Bool |
| 190 | @State private var hovering = false |
| 191 | |
| 192 | var body: some View { |
| 193 | // Leave is wider than it is tall, so it reads as a capsule and not as |
| 194 | // one more round state behind a symbol. |
| 195 | let width = tone == .leave && !round ? size * 1.4 : size |
| 196 | label.labelStyle(.iconOnly) |
| 197 | .font(round ? .title2.weight(.semibold) : .title3.weight(.medium)) |
| 198 | .modifier(CallGlyphSizing(round ? .round : .bar)) |
| 199 | .frame(width: width, height: size) |
| 200 | .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white) |
| 201 | .background(fill, in: Capsule()) |
| 202 | .opacity(!enabled ? 0.45 : pressed && tone != .normal ? 0.75 : 1) |
| 203 | .contentShape(Capsule()) |
| 204 | .onHover { hovering = $0 } |
| 205 | } |
| 206 | |
| 207 | private var fill: Color { |
| 208 | switch tone { |
| 209 | case .normal: |
| 210 | guard enabled else { return .clear } |
| 211 | return pressed ? CallOverlay.pressFill : hovering ? CallOverlay.hoverFill : .clear |
| 212 | case .on: return CallOverlay.onFill |
| 213 | case .off: return CallOverlay.offFill |
| 214 | case .leave: return CallOverlay.leaveFill |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | |