AnnouncementBanner.swift (3991B)
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 let onDismiss: (() -> Void)? 13 14 var body: some View { 15 let severityColor = tint(for: announcement.severity) 16 HStack(alignment: .center, spacing: 8) { 17 Image(systemName: iconName(for: announcement.severity)) 18 .font(.footnote.weight(.semibold)) 19 .foregroundStyle(severityColor) 20 .frame(width: 24) 21 .accessibilityHidden(true) 22 23 VStack(alignment: .leading, spacing: 3) { 24 if let title = announcement.title, !title.isEmpty { 25 Text(title) 26 .font(.subheadline.weight(.semibold)) 27 .lineLimit(1) 28 } 29 Text(announcement.body) 30 .font(.subheadline) 31 .foregroundStyle(.primary) 32 .multilineTextAlignment(.leading) 33 .lineLimit(2) 34 } 35 .frame(maxWidth: .infinity, alignment: .leading) 36 37 if case .manual = announcement.dismissal { 38 Button { 39 onDismiss?() 40 } label: { 41 Image(systemName: "xmark.circle.fill") 42 .font(.callout) 43 .symbolRenderingMode(.hierarchical) 44 } 45 .buttonStyle(.plain) 46 .foregroundStyle(.secondary) 47 .accessibilityLabel("Dismiss announcement") 48 } 49 } 50 .padding(.horizontal, 10) 51 .padding(.vertical, 6) 52 .padding(.leading, 4) 53 .frame( 54 maxWidth: .infinity, 55 maxHeight: fillsAvailableHeight ? .infinity : nil, 56 alignment: .leading 57 ) 58 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) 59 // The severity rail is drawn as an overlay rather than laid out as an 60 // HStack child: a width-constrained `Rectangle` in the HStack is 61 // greedy for height, which made the whole banner expand to fill 62 // whatever its container offered instead of hugging the text. 63 .overlay(alignment: .leading) { 64 Rectangle() 65 .fill(severityColor) 66 .frame(width: 4) 67 .accessibilityHidden(true) 68 } 69 .overlay { 70 RoundedRectangle(cornerRadius: 8, style: .continuous) 71 .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) 72 } 73 .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) 74 .contentShape(Rectangle()) 75 .onTapGesture { 76 // `.transient` self-dismisses; `.sticky` requires programmatic 77 // dismissal. Only `.manual` reacts to a tap. 78 guard case .manual = announcement.dismissal else { return } 79 onDismiss?() 80 } 81 .accessibilityElement(children: .combine) 82 } 83 84 /// SF Symbol that leads the banner text, one per severity. 85 private func iconName(for severity: Announcement.Severity) -> String { 86 switch severity { 87 case .info: return "bell" 88 case .warning: return "exclamationmark.triangle" 89 case .error: return "xmark.octagon" 90 } 91 } 92 93 private func tint(for severity: Announcement.Severity) -> Color { 94 switch severity { 95 case .info: return .blue 96 case .warning: return .orange 97 case .error: return .red 98 } 99 } 100 }