| 1 | import SwiftUI |
| 2 | |
| 3 | /// Spec §15.19: the mark a video tile carries while its peer is muted. |
| 4 | /// A slashed microphone and the word beside it, so the state is a shape |
| 5 | /// and a word and never a colour. The tile folds it into its own |
| 6 | /// accessibility label, so this hides itself from assistive technology |
| 7 | /// rather than being read twice. |
| 8 | public struct MutedBadge: View { |
| 9 | public init() {} |
| 10 | public var body: some View { |
| 11 | Label("Muted", systemImage: "mic.slash.fill") |
| 12 | .font(.caption.bold()).foregroundStyle(.white) |
| 13 | .padding(.horizontal, 8).padding(.vertical, 4) |
| 14 | .callOverlaySurface(Capsule()) |
| 15 | .accessibilityHidden(true) |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | /// What a tile's accessibility label says: the name, and "muted" when it |
| 20 | /// is. Shared by the Mac and iPhone tiles so VoiceOver hears one phrase. |
| 21 | public func tileAccessibilityLabel(_ label: String, muted: Bool) -> String { |
| 22 | muted ? "\(label), muted" : label |
| 23 | } |
| 24 | |
| 25 | /// The full-window call's name label: small, and carrying the muted mark |
| 26 | /// inside it — shape and word, as §15.19 has it — rather than in a |
| 27 | /// second badge, so a tile keeps one quiet |
| 28 | /// label in its corner that can stay while the controls come and go. |
| 29 | /// Hidden from assistive technology, as the tile's own label says both. |
| 30 | /// |
| 31 | /// A narrow tile (a filmstrip thumbnail, most of all on an iPhone held |
| 32 | /// upright) has no room for "Ana García | Muted": there the word goes and |
| 33 | /// the slashed microphone stays, and the name gives up its end to an |
| 34 | /// ellipsis rather than running off the tile (0.4.1). The tile's own |
| 35 | /// accessibility label still says "muted". |
| 36 | public struct TileNameLabel: View { |
| 37 | let label: String |
| 38 | let muted: Bool |
| 39 | public init(label: String, muted: Bool) { |
| 40 | self.label = label |
| 41 | self.muted = muted |
| 42 | } |
| 43 | public var body: some View { |
| 44 | ViewThatFits(in: .horizontal) { |
| 45 | row(word: true).fixedSize() |
| 46 | row(word: false).fixedSize(horizontal: false, vertical: true) |
| 47 | } |
| 48 | .font(.caption.weight(.semibold)).foregroundStyle(.white) |
| 49 | .padding(.horizontal, 7).padding(.vertical, 3) |
| 50 | // The overlay's glass, as the controls have: white on it is 6.2:1 |
| 51 | // even over a white wall. |
| 52 | .callOverlaySurface(Capsule()) |
| 53 | .accessibilityHidden(true) |
| 54 | } |
| 55 | |
| 56 | private func row(word: Bool) -> some View { |
| 57 | HStack(spacing: 5) { |
| 58 | Text(label).lineLimit(1).truncationMode(.tail) |
| 59 | if muted { |
| 60 | Group { |
| 61 | if word { |
| 62 | Label("Muted", systemImage: "mic.slash.fill").labelStyle(.titleAndIcon) |
| 63 | } else { |
| 64 | Image(systemName: "mic.slash.fill") |
| 65 | } |
| 66 | } |
| 67 | .padding(.leading, 5) |
| 68 | .overlay(alignment: .leading) { |
| 69 | Rectangle().fill(.white.opacity(0.5)).frame(width: 1, height: 10) |
| 70 | } |
| 71 | .fixedSize() |
| 72 | .layoutPriority(1) |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// What a tile shows where its picture is not: a symbol, and the words |
| 79 | /// for it when there is room. Centred in the whole tile, as FaceTime has |
| 80 | /// it, with the name label over the tile's bottom edge (2026-10-05, Paul: |
| 81 | /// "Vertical centering here too"; until then, from 0.4.1, it was centred |
| 82 | /// in the space above the label, which left it high). Only if the tile is |
| 83 | /// so short that the centred placeholder would reach the label is it |
| 84 | /// moved up, by no more than that (`PlaceholderPlacement`). On a tile too |
| 85 | /// short for the words between the label and the top, the symbol stands |
| 86 | /// alone, then smaller. The tile's accessibility value carries the words |
| 87 | /// either way. |
| 88 | public struct TilePlaceholder: View { |
| 89 | let symbol: String |
| 90 | let words: String? |
| 91 | let iconFont: Font |
| 92 | let wordsFont: Font |
| 93 | let spacing: CGFloat |
| 94 | let reserve: CGFloat |
| 95 | |
| 96 | public init( |
| 97 | symbol: String, words: String?, iconFont: Font = .largeTitle, wordsFont: Font = .callout, |
| 98 | spacing: CGFloat = 12, reserve: CGFloat |
| 99 | ) { |
| 100 | self.symbol = symbol |
| 101 | self.words = words |
| 102 | self.iconFont = iconFont |
| 103 | self.wordsFont = wordsFont |
| 104 | self.spacing = spacing |
| 105 | self.reserve = reserve |
| 106 | } |
| 107 | |
| 108 | public var body: some View { |
| 109 | PlaceholderLayout(reserve: reserve) { |
| 110 | ViewThatFits(in: .vertical) { |
| 111 | if let words { |
| 112 | VStack(spacing: spacing) { |
| 113 | Image(systemName: symbol).font(iconFont) |
| 114 | Text(words).font(wordsFont).multilineTextAlignment(.center).lineLimit(2) |
| 115 | .padding(.horizontal, 12) |
| 116 | } |
| 117 | } |
| 118 | Image(systemName: symbol).font(iconFont) |
| 119 | Image(systemName: symbol).font(.body) |
| 120 | } |
| 121 | } |
| 122 | .foregroundStyle(.white.opacity(0.8)) |
| 123 | .accessibilityHidden(true) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | /// Where a tile's placeholder goes, apart from any view so tests hold it: |
| 128 | /// centred in the tile, unless that would bring it within `gap` of the |
| 129 | /// label block at the bottom, when it rises just clear of it, and never |
| 130 | /// above the tile's top. |
| 131 | public enum PlaceholderPlacement { |
| 132 | /// Air kept between the placeholder and the top of the label block. |
| 133 | public static let gap: CGFloat = 4 |
| 134 | |
| 135 | /// The height the placeholder may take and still clear the label: what |
| 136 | /// chooses between the words, the symbol, and the smaller symbol. |
| 137 | public static func room(tile: CGFloat, reserve: CGFloat) -> CGFloat { |
| 138 | max(0, tile - clearance(reserve)) |
| 139 | } |
| 140 | |
| 141 | /// The placeholder's top, from the tile's top, for content `height` tall. |
| 142 | public static func top(tile: CGFloat, content height: CGFloat, reserve: CGFloat) -> CGFloat { |
| 143 | let centred = (tile - height) / 2 |
| 144 | let clear = tile - clearance(reserve) - height |
| 145 | return max(0, min(centred, clear)) |
| 146 | } |
| 147 | |
| 148 | static func clearance(_ reserve: CGFloat) -> CGFloat { reserve > 0 ? reserve + gap : 0 } |
| 149 | } |
| 150 | |
| 151 | /// Fills the tile and puts its one subview where `PlaceholderPlacement` |
| 152 | /// says, offered the height it may take so `ViewThatFits` chooses what |
| 153 | /// fits there. |
| 154 | struct PlaceholderLayout: Layout { |
| 155 | let reserve: CGFloat |
| 156 | func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { |
| 157 | proposal.replacingUnspecifiedDimensions() |
| 158 | } |
| 159 | func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { |
| 160 | guard let content = subviews.first else { return } |
| 161 | let room = PlaceholderPlacement.room(tile: bounds.height, reserve: reserve) |
| 162 | let size = content.sizeThatFits(ProposedViewSize(width: bounds.width, height: room)) |
| 163 | let top = PlaceholderPlacement.top(tile: bounds.height, content: size.height, reserve: reserve) |
| 164 | content.place( |
| 165 | at: CGPoint(x: bounds.midX, y: bounds.minY + top), anchor: .top, |
| 166 | proposal: ProposedViewSize(width: bounds.width, height: size.height)) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /// A tile's name label block, measured, so its placeholder can keep |
| 171 | /// clear of it (see `TilePlaceholder`). |
| 172 | extension View { |
| 173 | /// Reports this view's height as the tile's label block. |
| 174 | public func tileLabelBlock(_ height: Binding<CGFloat>) -> some View { |
| 175 | onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height.wrappedValue = $0 } |
| 176 | } |
| 177 | } |
| 178 | |