1import PhotosUI
2import UIKit
3
4/// What a page shows selected as it opens.
5enum Reveal {
6 /// The first match of a search.
7 case text(String)
8 /// A paragraph the Tags Summary lists, by id.
9 case paragraph(String)
10}
11
12final class PageViewController: UIViewController, PHPickerViewControllerDelegate,
13 UIImagePickerControllerDelegate, UINavigationControllerDelegate
14{
15 /// Posted when the app leaves the screen or quits: marked text is committed so it saves.
16 static let leaving = Notification.Name("SnowboundLeaving")
17
18 private let section: Section
19 private let page: String
20 private let reveal: Reveal?
21 private let titleFocus: Bool
22 private lazy var canvas = CanvasView(section: section, page: page)
23 private let bar = ConflictBar()
24 /// What records or plays on this page, over its foot.
25 private let media = MediaBar()
26 private var playback: Playback?
27 private var mediaClock: Timer?
28 /// Reading View, beside the page menu where the page has one.
29 private var reader: UIBarButtonItem?
30 /// Opens another page or conflict page of the section.
31 var onOpen: ((String) -> Void)?
32 private lazy var done = UIBarButtonItem(
33 systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() })
34 private lazy var undo = UIBarButtonItem(
35 systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
36 private lazy var redo = UIBarButtonItem(
37 systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })
38 /// Shows the drawing tools, which a finger then draws with too.
39 private lazy var draw = UIBarButtonItem(
40 title: "Draw", image: UIImage(systemName: "pencil.tip.crop.circle"),
41 primaryAction: UIAction { [weak self] _ in
42 guard let canvas = self?.canvas else { return }
43 canvas.showPicker(!canvas.pickerShown)
44 })
45 private lazy var more = UIBarButtonItem(
46 title: "Page", image: UIImage(systemName: "ellipsis.circle"),
47 menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }]))
48
49 init(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false) {
50 self.section = section
51 self.page = page
52 self.reveal = reveal
53 self.titleFocus = titleFocus
54 super.init(nibName: nil, bundle: nil)
55 navigationItem.largeTitleDisplayMode = .never
56 navigationItem.rightBarButtonItems = [more, draw]
57 showTitle(section.row(of: page)?.row.title ?? "")
58 }
59
60 required init?(coder: NSCoder) { fatalError() }
61
62 override func loadView() {
63 let view = UIView()
64 view.backgroundColor = .systemBackground
65 canvas.translatesAutoresizingMaskIntoConstraints = false
66 bar.translatesAutoresizingMaskIntoConstraints = false
67 view.addSubview(canvas)
68 view.addSubview(bar)
69 media.translatesAutoresizingMaskIntoConstraints = false
70 view.addSubview(media)
71 let wide = media.widthAnchor.constraint(equalToConstant: 480)
72 wide.priority = .required - 1
73 NSLayoutConstraint.activate([
74 wide,
75 media.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
76 media.leadingAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
77 media.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -12),
78 canvas.leadingAnchor.constraint(equalTo: view.leadingAnchor),
79 canvas.trailingAnchor.constraint(equalTo: view.trailingAnchor),
80 canvas.topAnchor.constraint(equalTo: view.topAnchor),
81 canvas.bottomAnchor.constraint(equalTo: view.bottomAnchor),
82 bar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
83 bar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
84 bar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
85 ])
86 self.view = view
87 canvas.onChange = { [weak self] in self?.editingChanged() }
88 canvas.onOpened = { [weak self] in self?.opened() }
89 canvas.onDate = { [weak self] field, date in self?.pickDate(field, date) }
90 canvas.onPlay = { [weak self] request in self?.play(request) }
91 media.onPause = { [weak self] in self?.pauseMedia() }
92 media.onStop = { [weak self] in self?.stopMedia() }
93 NotificationCenter.default.addObserver(self, selector: #selector(showMedia), name: Recorder.changed, object: nil)
94 canvas.formatBar.onPicture = { [weak self] camera in self?.pickPicture(camera: camera) }
95 bar.onAction = { [weak self] in self?.conflictAction() }
96 NotificationCenter.default.addObserver(
97 self, selector: #selector(sectionChanged), name: Section.changed, object: section)
98 NotificationCenter.default.addObserver(self, selector: #selector(leaving), name: Self.leaving, object: nil)
99 conflictChanged()
100 registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (controller: PageViewController, _) in
101 controller.fitTitle()
102 }
103 }
104
105 /// The page is on screen: a found word or tagged paragraph is selected, a new page's
106 /// title takes the caret.
107 private func opened() {
108 let revealed =
109 switch reveal {
110 case .text(let query): canvas.find(query)
111 case .paragraph(let id): canvas.selectParagraph(id)
112 case nil: false
113 }
114 if revealed || titleFocus && canvas.focusTitle() {
115 _ = canvas.becomeFirstResponder()
116 }
117 if Prototype.reading, canvas.reading?.offered == true {
118 reader = UIBarButtonItem(
119 title: "Reading View", image: UIImage(systemName: "text.justify.left"),
120 primaryAction: UIAction { [weak self] _ in self?.toggleReading() })
121 editingChanged()
122 }
123 }
124
125 private func showTitle(_ title: String) {
126 self.title = title.isEmpty ? "Untitled Page" : title
127 }
128
129 /// A phone's bar has no room for the title beside the editing buttons, and the page
130 /// shows it anyway, as Notes leaves it out.
131 private func fitTitle() {
132 let crowded = canvas.isFirstResponder && traitCollection.horizontalSizeClass == .compact
133 navigationItem.titleView = crowded ? UIView() : nil
134 }
135
136 /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it.
137 private func editingChanged() {
138 if !canvas.readOnly, let title = canvas.pageTitle {
139 showTitle(title)
140 section.retitle(page, title)
141 }
142 fitTitle()
143 draw.isSelected = canvas.pickerShown
144 draw.isEnabled = !canvas.readOnly
145 undo.isEnabled = canvas.canUndo(redo: false)
146 redo.isEnabled = canvas.canUndo(redo: true)
147 // Drawing keeps undo at hand, as typing does.
148 reader?.isSelected = canvas.readingShown
149 guard canvas.isFirstResponder || canvas.pickerShown else {
150 let resting = [more, draw] + (reader.map { [$0] } ?? [])
151 if navigationItem.rightBarButtonItems != resting { navigationItem.rightBarButtonItems = resting }
152 return
153 }
154 let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo]
155 if navigationItem.rightBarButtonItems?.first !== items.first
156 || navigationItem.rightBarButtonItems?.count != items.count
157 {
158 navigationItem.rightBarButtonItems = items
159 }
160 }
161
162 // MARK: Page menu
163
164 /// The desktop's page and insert commands that suit a touch screen.
165 private func pageMenu() -> [UIMenuElement] {
166 let editable: UIMenuElement.Attributes = canvas.readOnly ? .disabled : []
167 let scene = view.window?.windowScene?.delegate as? SceneDelegate
168 let insert = UIMenu(
169 title: "Insert", image: UIImage(systemName: "plus.circle"),
170 children: [
171 UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle"), attributes: editable) {
172 [weak self] _ in self?.pickPicture(camera: false)
173 },
174 UIAction(
175 title: "Take Photo", image: UIImage(systemName: "camera"),
176 attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? editable : .disabled
177 ) { [weak self] _ in self?.pickPicture(camera: true) },
178 UIMenu(options: .displayInline, children: [
179 UIAction(title: "Date", image: UIImage(systemName: "calendar"), attributes: editable) {
180 [weak self] _ in self?.canvas.insertDate(true, time: false)
181 },
182 UIAction(title: "Time", image: UIImage(systemName: "clock"), attributes: editable) {
183 [weak self] _ in self?.canvas.insertDate(false, time: true)
184 },
185 UIAction(title: "Date & Time", image: UIImage(systemName: "calendar.badge.clock"), attributes: editable) {
186 [weak self] _ in self?.canvas.insertDate(true, time: true)
187 },
188 ]),
189 UIAction(
190 title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable
191 ) { [weak self] _ in self?.canvas.insertSpace() },
192 UIMenu(options: .displayInline, children: [
193 UIAction(
194 title: "Record Audio", image: UIImage(systemName: "mic"),
195 attributes: Recorder.current == nil ? editable : .disabled
196 ) { [weak self] _ in self?.record(video: false) },
197 UIAction(
198 title: "Record Video", image: UIImage(systemName: "video"),
199 attributes: Recorder.current == nil && Recorder.canRecordVideo ? editable : .disabled
200 ) { [weak self] _ in self?.record(video: true) },
201 ]),
202 ])
203 return (Prototype.reading ? [readingAction()] : []) + [
204 insert,
205 UIAction(title: "Page Background", image: UIImage(systemName: "paintpalette"), attributes: editable) {
206 [weak self] _ in self?.showPaper()
207 },
208 themeMenu(),
209 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
210 guard let self else { return }
211 scene?.showTags(of: section.notebook, from: self)
212 },
213 UIMenu(options: .displayInline, children: [
214 UIAction(title: "Print", image: UIImage(systemName: "printer")) { [weak self] _ in
215 self?.printPage(export: false)
216 },
217 UIAction(title: "Export as PDF", image: UIImage(systemName: "doc.richtext")) { [weak self] _ in
218 self?.printPage(export: true)
219 },
220 ]),
221 UIMenu(options: .displayInline, children: [
222 UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in
223 scene?.newPage(subpage: true)
224 },
225 UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) {
226 [weak self] _ in
227 guard let self else { return }
228 section.delete(page)
229 },
230 ]),
231 ]
232 }
233
234 /// Reading View: the page reflowed into the screen's width, read-only, where it reflows.
235 private func readingAction() -> UIAction {
236 let reading = canvas.reading
237 return UIAction(
238 title: "Reading View", subtitle: reading.flatMap { Prototype.readingNote($0.verdict) },
239 image: UIImage(systemName: "text.justify.left"),
240 attributes: reading?.offered == true || canvas.readingShown ? [] : .disabled,
241 state: canvas.readingShown ? .on : .off
242 ) { [weak self] _ in self?.toggleReading() }
243 }
244
245 private func toggleReading() {
246 // A recording goes on the page as laid out.
247 guard Recorder.current?.canvas !== canvas else { return }
248 canvas.showReading(!canvas.readingShown)
249 }
250
251 /// The page's theme, its section's and its notebook's, each pickable; the page wears a
252 /// new one when it opens again.
253 private func themeMenu() -> UIMenu {
254 guard let themes = section.themes(of: page) else {
255 return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: [])
256 }
257 let scopes: [(String, String?)] = [
258 ("This Page", themes.page), ("This Section", themes.section), ("This Notebook", themes.notebook),
259 ]
260 let page = page
261 let menus = scopes.enumerated().map { scope, entry in
262 let (title, current) = entry
263 let choices = [(nil as String?, "None")] + themes.themes.map { ($0.id, $0.name) }
264 return UIMenu(title: title, children: choices.map { id, name in
265 UIAction(title: name, state: id == current ? .on : .off) { [weak self] _ in
266 guard let self else { return }
267 section.setTheme(id, scope: UInt8(scope), of: page) { [weak self] _ in
268 self?.onOpen?(page)
269 }
270 }
271 })
272 }
273 return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: menus)
274 }
275
276 /// Prints the page through the system's print sheet, or with `export` offers its PDF in
277 /// the share sheet, laid out as OneNote 2010 prints it on the region's paper.
278 private func printPage(export: Bool) {
279 let letter = ["US", "CA", "MX", "PR", "PH", "CL", "CO", "VE", "GT", "CR", "PA", "DO", "SV", "NI", "HN", "BZ"]
280 .contains(Locale.current.region?.identifier ?? "US")
281 let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89)
282 let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? ""
283 guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else {
284 return alert(
285 export ? "Couldn't Export the PDF" : "Couldn't Print", "The page could not be laid out on paper.")
286 }
287 if export {
288 let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-")
289 .trimmingCharacters(in: .whitespaces)
290 let file = FileManager.default.temporaryDirectory
291 .appendingPathComponent((name.isEmpty ? "Untitled" : name) + ".pdf")
292 do {
293 try pdf.write(to: file)
294 } catch {
295 return
296 }
297 let share = UIActivityViewController(activityItems: [file], applicationActivities: nil)
298 share.popoverPresentationController?.barButtonItem = more
299 present(share, animated: true)
300 } else {
301 let info = UIPrintInfo.printInfo()
302 info.jobName = title
303 let printer = UIPrintInteractionController.shared
304 printer.printInfo = info
305 printer.printingItem = pdf
306 printer.present(from: more, animated: true)
307 }
308 }
309
310 private func showPaper() {
311 let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas))
312 navigation.sheetPresentationController?.detents = [.medium(), .large()]
313 navigation.sheetPresentationController?.prefersGrabberVisible = true
314 navigation.sheetPresentationController?.largestUndimmedDetentIdentifier = .medium
315 present(navigation, animated: true)
316 }
317
318 @objc private func leaving() { canvas.commitComposition() }
319
320 override func viewWillDisappear(_ animated: Bool) {
321 super.viewWillDisappear(animated)
322 // A recording goes on the page it started on, as the desktop's does on leaving it.
323 if Recorder.current?.canvas === canvas { Recorder.current?.stop() }
324 stopPlayback()
325 }
326
327 // MARK: Recording and playback
328
329 func record(video: Bool) {
330 stopPlayback()
331 Recorder.start(video: video, on: canvas) { [weak self] title, message in self?.alert(title, message) }
332 }
333
334 func stopRecording() {
335 if Recorder.current?.canvas === canvas { Recorder.current?.stop() }
336 }
337
338 private func play(_ request: PlayRequest) {
339 guard Recorder.current == nil else { return }
340 playback = Playback(request)
341 showMedia()
342 }
343
344 private func stopPlayback() {
345 guard playback != nil else { return }
346 playback = nil
347 canvas.played(at: nil)
348 showMedia()
349 }
350
351 private func pauseMedia() {
352 if let recorder = Recorder.current, recorder.canvas === canvas {
353 recorder.pause(!(recorder.elapsed?.paused ?? false))
354 } else {
355 playback?.toggle()
356 }
357 showMedia()
358 }
359
360 private func stopMedia() {
361 if Recorder.current?.canvas === canvas {
362 Recorder.current?.stop()
363 } else {
364 stopPlayback()
365 }
366 }
367
368 /// The bar shows the clock of what records or plays, and a video's picture as it plays.
369 @objc private func showMedia() {
370 if let recorder = Recorder.current, recorder.canvas === canvas {
371 media.picture.isHidden = true
372 if recorder.saving {
373 media.showSaving(recorder.video ? "Saving video…" : "Saving audio…")
374 } else if let elapsed = recorder.elapsed {
375 let state = elapsed.paused ? "Paused" : recorder.video ? "Recording video" : "Recording"
376 media.show(
377 "\(state) \(MediaBar.clock(elapsed.ms))", recording: true,
378 paused: recorder.pausable ? elapsed.paused : nil)
379 }
380 } else if let playback {
381 if playback.failed {
382 stopPlayback()
383 return alert("Couldn't Play the Recording", "This kind of recording doesn't play on this device.")
384 }
385 let total = playback.durationMs.map { " / " + MediaBar.clock($0) } ?? ""
386 media.show(
387 "\(playback.request.name) \(MediaBar.clock(playback.ms))\(total)", recording: false,
388 paused: !playback.playing, playing: true)
389 if let picture = playback.picture() { media.picture.image = picture }
390 media.picture.isHidden = playback.request.video == nil
391 canvas.played(at: playback.ms)
392 } else {
393 media.isHidden = true
394 canvas.bottomInset = 0
395 mediaClock?.invalidate()
396 mediaClock = nil
397 return
398 }
399 // The line being written stays above the bar.
400 canvas.bottomInset = view.keyboardLayoutGuide.layoutFrame.minY - media.frame.minY
401 if mediaClock == nil {
402 mediaClock = Timer.scheduledTimer(withTimeInterval: 1.0 / 15, repeats: true) { [weak self] _ in
403 self?.showMedia()
404 }
405 }
406 }
407
408 @objc private func sectionChanged(_ notification: Notification) {
409 let flags = notification.userInfo?["flags"] as? UInt32 ?? 0
410 if flags & Section.rejected != 0 {
411 // Nothing typed is lost unannounced: the page's text goes on the clipboard.
412 UIPasteboard.general.string = canvas.pageText
413 canvas.reload(discard: true)
414 alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.")
415 } else if flags & Section.remote != 0 {
416 canvas.reload(discard: false)
417 }
418 if flags & Section.listed != 0 {
419 if section.row(of: page) == nil {
420 // Deleted elsewhere, or a conflict page resolved: back to what the section lists.
421 if let first = section.rows.first { onOpen?(first.id) }
422 return
423 }
424 conflictChanged()
425 }
426 showStatus()
427 }
428
429 /// Only trouble shows: saving goes on quietly, as Notes saves.
430 private func showStatus() {
431 let status: String? =
432 switch section.status {
433 case .offline: "Offline"
434 case .notSaving: "Not Saving"
435 default: nil
436 }
437 if #available(iOS 26, *) {
438 navigationItem.subtitle = status
439 } else {
440 navigationItem.prompt = status
441 }
442 }
443
444 // MARK: Conflicts
445
446 private func conflictChanged() {
447 guard let (row, version) = section.row(of: page) else { return bar.show(nil) }
448 if version != nil {
449 // Deleting a conflict page cannot be undone, so the button asks first.
450 let delete = UIAction(
451 title: "Delete Conflict Page", image: UIImage(systemName: "trash"), attributes: .destructive
452 ) { [weak self] _ in self?.conflictAction() }
453 bar.show(
454 "Changes in red couldn’t be merged. This version can’t be edited.", action: "Delete",
455 menu: UIMenu(children: [delete]))
456 } else if !row.versions.isEmpty {
457 bar.show("This page has changes that couldn’t be merged.", action: "See Version")
458 } else {
459 bar.show(nil)
460 }
461 canvas.topInset = bar.isHidden ? 0 : bar.systemLayoutSizeFitting(view.bounds.size).height
462 }
463
464 private func conflictAction() {
465 guard let (row, version) = section.row(of: page) else { return }
466 if version != nil {
467 section.delete(page) { [weak self] in self?.onOpen?(row.id) }
468 } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) {
469 onOpen?(newest.id)
470 }
471 }
472
473 // MARK: Date
474
475 /// Changes the page's date or time, as OneNote's date and time under the title do.
476 private func pickDate(_ field: DateField, _ shown: Date) {
477 let picker = UIDatePicker()
478 picker.datePickerMode = field == .date ? .date : .time
479 picker.preferredDatePickerStyle = field == .date ? .inline : .wheels
480 picker.date = shown
481 picker.minimumDate = Date(timeIntervalSince1970: -11_644_473_600)
482 picker.accessibilityLabel = field == .date ? "Page date" : "Page time"
483 let controller = UIViewController()
484 controller.title = field == .date ? "Change Page Date" : "Change Page Time"
485 picker.translatesAutoresizingMaskIntoConstraints = false
486 controller.view.backgroundColor = .systemBackground
487 controller.view.addSubview(picker)
488 NSLayoutConstraint.activate([
489 picker.centerXAnchor.constraint(equalTo: controller.view.centerXAnchor),
490 picker.topAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.topAnchor, constant: 8),
491 picker.widthAnchor.constraint(lessThanOrEqualTo: controller.view.widthAnchor, constant: -32),
492 ])
493 controller.navigationItem.leftBarButtonItem = UIBarButtonItem(
494 systemItem: .cancel, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) })
495 controller.navigationItem.rightBarButtonItem = UIBarButtonItem(
496 title: "Apply",
497 primaryAction: UIAction { [weak self] _ in
498 // The field not being changed keeps what the page had.
499 let calendar = Calendar.current
500 let kept: Set<Calendar.Component> = field == .date ? [.hour, .minute, .second] : [.era, .year, .month, .day]
501 let changed: Set<Calendar.Component> = field == .date ? [.era, .year, .month, .day] : [.hour, .minute]
502 var components = calendar.dateComponents(kept, from: shown)
503 let chosen = calendar.dateComponents(changed, from: picker.date)
504 for component in changed { components.setValue(chosen.value(for: component), for: component) }
505 if field == .time { components.second = calendar.component(.second, from: shown) }
506 self?.dismiss(animated: true)
507 if let date = calendar.date(from: components) { self?.canvas.changeDate(date) }
508 })
509 let navigation = UINavigationController(rootViewController: controller)
510 navigation.sheetPresentationController?.detents = field == .date ? [.large()] : [.medium()]
511 present(navigation, animated: true)
512 }
513
514 // MARK: Pictures
515
516 private func pickPicture(camera: Bool) {
517 if camera {
518 guard UIImagePickerController.isSourceTypeAvailable(.camera) else { return }
519 let picker = UIImagePickerController()
520 picker.sourceType = .camera
521 picker.delegate = self
522 present(picker, animated: true)
523 } else {
524 var configuration = PHPickerConfiguration()
525 configuration.filter = .images
526 let picker = PHPickerViewController(configuration: configuration)
527 picker.delegate = self
528 present(picker, animated: true)
529 }
530 }
531
532 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
533 picker.dismiss(animated: true)
534 guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
535 provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
536 guard let image = image as? UIImage else { return }
537 DispatchQueue.main.async { self?.canvas.insertPicture(image) }
538 }
539 }
540
541 func imagePickerController(
542 _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
543 ) {
544 picker.dismiss(animated: true)
545 if let image = info[.originalImage] as? UIImage { canvas.insertPicture(image) }
546 }
547}
548
549/// OneNote's information bar above a page whose changes could not all be merged.
550final class ConflictBar: UIView {
551 private let label = UILabel()
552 private let button = UIButton(configuration: .bordered())
553 var onAction: (() -> Void)?
554
555 init() {
556 super.init(frame: .zero)
557 backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.35, green: 0.28, blue: 0.1, alpha: 1) : UIColor(red: 1, green: 0.93, blue: 0.76, alpha: 1) }
558 let icon = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill"))
559 icon.tintColor = .systemOrange
560 icon.setContentHuggingPriority(.required, for: .horizontal)
561 label.font = .preferredFont(forTextStyle: .footnote)
562 label.numberOfLines = 0
563 button.addAction(
564 UIAction { [weak self] _ in
565 if self?.button.menu == nil { self?.onAction?() }
566 }, for: .primaryActionTriggered)
567 button.setContentHuggingPriority(.required, for: .horizontal)
568 let stack = UIStackView(arrangedSubviews: [icon, label, button])
569 stack.spacing = 10
570 stack.alignment = .center
571 stack.translatesAutoresizingMaskIntoConstraints = false
572 addSubview(stack)
573 NSLayoutConstraint.activate([
574 stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
575 stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
576 stack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
577 stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
578 ])
579 isHidden = true
580 }
581
582 required init?(coder: NSCoder) { fatalError() }
583
584 /// Shows `message` with a button titled `action`, which opens `menu` when there is one.
585 func show(_ message: String?, action: String = "", menu: UIMenu? = nil) {
586 isHidden = message == nil
587 label.text = message
588 button.configuration?.title = action
589 button.menu = menu
590 button.showsMenuAsPrimaryAction = menu != nil
591 }
592}