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