import SwiftUI /// Spec §15.19: the mark a video tile carries while its peer is muted. /// A slashed microphone and the word beside it, so the state is a shape /// and a word and never a colour. The tile folds it into its own /// accessibility label, so this hides itself from assistive technology /// rather than being read twice. public struct MutedBadge: View { public init() {} public var body: some View { Label("Muted", systemImage: "mic.slash.fill") .font(.caption.bold()).foregroundStyle(.white) .padding(.horizontal, 8).padding(.vertical, 4) .callOverlaySurface(Capsule()) .accessibilityHidden(true) } } /// What a tile's accessibility label says: the name, and "muted" when it /// is. Shared by the Mac and iPhone tiles so VoiceOver hears one phrase. public func tileAccessibilityLabel(_ label: String, muted: Bool) -> String { muted ? "\(label), muted" : label } /// The full-window call's name label: small, and carrying the muted mark /// inside it — shape and word, as §15.19 has it — rather than in a /// second badge, so a tile keeps one quiet /// label in its corner that can stay while the controls come and go. /// Hidden from assistive technology, as the tile's own label says both. /// /// A narrow tile (a filmstrip thumbnail, most of all on an iPhone held /// upright) has no room for "Ana García | Muted": there the word goes and /// the slashed microphone stays, and the name gives up its end to an /// ellipsis rather than running off the tile (0.4.1). The tile's own /// accessibility label still says "muted". public struct TileNameLabel: View { let label: String let muted: Bool public init(label: String, muted: Bool) { self.label = label self.muted = muted } public var body: some View { ViewThatFits(in: .horizontal) { row(word: true).fixedSize() row(word: false).fixedSize(horizontal: false, vertical: true) } .font(.caption.weight(.semibold)).foregroundStyle(.white) .padding(.horizontal, 7).padding(.vertical, 3) // The overlay's glass, as the controls have: white on it is 6.2:1 // even over a white wall. .callOverlaySurface(Capsule()) .accessibilityHidden(true) } private func row(word: Bool) -> some View { HStack(spacing: 5) { Text(label).lineLimit(1).truncationMode(.tail) if muted { Group { if word { Label("Muted", systemImage: "mic.slash.fill").labelStyle(.titleAndIcon) } else { Image(systemName: "mic.slash.fill") } } .padding(.leading, 5) .overlay(alignment: .leading) { Rectangle().fill(.white.opacity(0.5)).frame(width: 1, height: 10) } .fixedSize() .layoutPriority(1) } } } } /// What a tile shows where its picture is not: a symbol, and the words /// for it when there is room. Centred in the whole tile, as FaceTime has /// it, with the name label over the tile's bottom edge (2026-10-05, Paul: /// "Vertical centering here too"; until then, from 0.4.1, it was centred /// in the space above the label, which left it high). Only if the tile is /// so short that the centred placeholder would reach the label is it /// moved up, by no more than that (`PlaceholderPlacement`). On a tile too /// short for the words between the label and the top, the symbol stands /// alone, then smaller. The tile's accessibility value carries the words /// either way. public struct TilePlaceholder: View { let symbol: String let words: String? let iconFont: Font let wordsFont: Font let spacing: CGFloat let reserve: CGFloat public init( symbol: String, words: String?, iconFont: Font = .largeTitle, wordsFont: Font = .callout, spacing: CGFloat = 12, reserve: CGFloat ) { self.symbol = symbol self.words = words self.iconFont = iconFont self.wordsFont = wordsFont self.spacing = spacing self.reserve = reserve } public var body: some View { PlaceholderLayout(reserve: reserve) { ViewThatFits(in: .vertical) { if let words { VStack(spacing: spacing) { Image(systemName: symbol).font(iconFont) Text(words).font(wordsFont).multilineTextAlignment(.center).lineLimit(2) .padding(.horizontal, 12) } } Image(systemName: symbol).font(iconFont) Image(systemName: symbol).font(.body) } } .foregroundStyle(.white.opacity(0.8)) .accessibilityHidden(true) } } /// Where a tile's placeholder goes, apart from any view so tests hold it: /// centred in the tile, unless that would bring it within `gap` of the /// label block at the bottom, when it rises just clear of it, and never /// above the tile's top. public enum PlaceholderPlacement { /// Air kept between the placeholder and the top of the label block. public static let gap: CGFloat = 4 /// The height the placeholder may take and still clear the label: what /// chooses between the words, the symbol, and the smaller symbol. public static func room(tile: CGFloat, reserve: CGFloat) -> CGFloat { max(0, tile - clearance(reserve)) } /// The placeholder's top, from the tile's top, for content `height` tall. public static func top(tile: CGFloat, content height: CGFloat, reserve: CGFloat) -> CGFloat { let centred = (tile - height) / 2 let clear = tile - clearance(reserve) - height return max(0, min(centred, clear)) } static func clearance(_ reserve: CGFloat) -> CGFloat { reserve > 0 ? reserve + gap : 0 } } /// Fills the tile and puts its one subview where `PlaceholderPlacement` /// says, offered the height it may take so `ViewThatFits` chooses what /// fits there. struct PlaceholderLayout: Layout { let reserve: CGFloat func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { proposal.replacingUnspecifiedDimensions() } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { guard let content = subviews.first else { return } let room = PlaceholderPlacement.room(tile: bounds.height, reserve: reserve) let size = content.sizeThatFits(ProposedViewSize(width: bounds.width, height: room)) let top = PlaceholderPlacement.top(tile: bounds.height, content: size.height, reserve: reserve) content.place( at: CGPoint(x: bounds.midX, y: bounds.minY + top), anchor: .top, proposal: ProposedViewSize(width: bounds.width, height: size.height)) } } /// A tile's name label block, measured, so its placeholder can keep /// clear of it (see `TilePlaceholder`). extension View { /// Reports this view's height as the tile's label block. public func tileLabelBlock(_ height: Binding) -> some View { onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height.wrappedValue = $0 } } }