AnnouncementBanner.swift (5821B)
1 import SwiftUI 2 3 /// Banner-style surface for an `Announcement`. Renders a severity rail, icon, 4 /// and body text over a neutral material background. Manual announcements get 5 /// a close affordance; transient and sticky announcements do not. 6 struct AnnouncementBanner: View { 7 enum ContentSize { 8 case compact 9 case prominent 10 11 var childSpacing: CGFloat { 12 switch self { 13 case .compact: return 8 14 case .prominent: return 10 15 } 16 } 17 18 var iconFont: Font { 19 switch self { 20 case .compact: return .footnote.weight(.semibold) 21 case .prominent: return .subheadline.weight(.semibold) 22 } 23 } 24 25 var titleFont: Font { 26 switch self { 27 case .compact: return .subheadline.weight(.semibold) 28 case .prominent: return .callout.weight(.semibold) 29 } 30 } 31 32 var bodyFont: Font { 33 switch self { 34 case .compact: return .subheadline 35 case .prominent: return .callout 36 } 37 } 38 } 39 40 let announcement: Announcement 41 /// When true the banner stretches to the height its container proposes 42 /// (PuzzleView's fixed-height header slot); by default it hugs its text, 43 /// which is what inline placements like the game list need. 44 var fillsAvailableHeight = false 45 /// Whether to show the leading severity icon. The Settings tips archive 46 /// turns it off — the rail alone carries the tip styling there. 47 var showsIcon = true 48 /// Visual density for the banner content. Puzzle-header announcements use 49 /// the compact default; Game List announcements opt into larger text. 50 var contentSize: ContentSize = .compact 51 /// Fill behind the banner. Defaults to the inset-grouped section fill so 52 /// banners read as solid cards on the grouped Game List and in the Settings 53 /// tips archive; override per surface if a different fill is wanted. 54 var background: AnyShapeStyle = AnyShapeStyle(Color(.secondarySystemGroupedBackground)) 55 let onDismiss: (() -> Void)? 56 57 var body: some View { 58 let severityColor = tint(for: announcement.severity) 59 HStack(alignment: .center, spacing: contentSize.childSpacing) { 60 if showsIcon { 61 Image(systemName: iconName(for: announcement.severity)) 62 .font(contentSize.iconFont) 63 .foregroundStyle(severityColor) 64 .frame(width: 24) 65 .accessibilityHidden(true) 66 } 67 68 VStack(alignment: .leading, spacing: 3) { 69 if let title = announcement.title, !title.isEmpty { 70 Text(title) 71 .font(contentSize.titleFont) 72 .lineLimit(1) 73 } 74 Text(announcement.body) 75 .font(contentSize.bodyFont) 76 .foregroundStyle(.primary) 77 .multilineTextAlignment(.leading) 78 .lineLimit(3) 79 } 80 .frame(maxWidth: .infinity, alignment: .leading) 81 82 if case .manual = announcement.dismissal { 83 Button { 84 onDismiss?() 85 } label: { 86 Image(systemName: "xmark.circle.fill") 87 .font(.callout) 88 .symbolRenderingMode(.hierarchical) 89 } 90 .buttonStyle(.plain) 91 .foregroundStyle(.secondary) 92 .accessibilityLabel("Dismiss announcement") 93 } 94 } 95 .padding(.horizontal, 10) 96 .padding(.vertical, 10) 97 // Without the icon the text would otherwise crowd the severity rail; 98 // give it a little extra breathing room on the leading edge. 99 .padding(.leading, showsIcon ? 4 : 12) 100 .frame( 101 maxWidth: .infinity, 102 maxHeight: fillsAvailableHeight ? .infinity : nil, 103 alignment: .leading 104 ) 105 .background(background, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) 106 // The severity rail is drawn as an overlay rather than laid out as an 107 // HStack child: a width-constrained `Rectangle` in the HStack is 108 // greedy for height, which made the whole banner expand to fill 109 // whatever its container offered instead of hugging the text. 110 .overlay(alignment: .leading) { 111 Rectangle() 112 .fill(severityColor) 113 .frame(width: 4) 114 .accessibilityHidden(true) 115 } 116 .overlay { 117 RoundedRectangle(cornerRadius: 8, style: .continuous) 118 .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) 119 } 120 .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) 121 .contentShape(Rectangle()) 122 .onTapGesture { 123 // `.transient` self-dismisses; `.sticky` requires programmatic 124 // dismissal. Only `.manual` reacts to a tap. 125 guard case .manual = announcement.dismissal else { return } 126 onDismiss?() 127 } 128 .accessibilityElement(children: .combine) 129 } 130 131 /// SF Symbol that leads the banner text, one per severity. 132 private func iconName(for severity: Announcement.Severity) -> String { 133 switch severity { 134 case .tip: return "lightbulb" 135 case .info: return "bell" 136 case .warning: return "exclamationmark.triangle" 137 case .error: return "xmark.octagon" 138 } 139 } 140 141 private func tint(for severity: Announcement.Severity) -> Color { 142 switch severity { 143 case .tip: return .yellow 144 case .info: return .blue 145 case .warning: return .orange 146 case .error: return .red 147 } 148 } 149 }