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 undo = UIBarButtonItem(
33 title: "undo", image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
34 private lazy var redo = UIBarButtonItem(
35 title: "redo", image: UIImage(systemName: "arrow.uturn.forward"), primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })
36 /// Shows the drawing tools, which a finger then draws with too.
37 private lazy var draw = UIBarButtonItem(
38 title: "Draw", image: UIImage(systemName: "pencil.tip.crop.circle"),
39 primaryAction: UIAction { [weak self] _ in
40 guard let canvas = self?.canvas else { return }
41 canvas.showPicker(!canvas.pickerShown)
42 })
43 private lazy var more = UIBarButtonItem(
44 title: "Page", image: UIImage(systemName: "ellipsis.circle"),
45 menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }]))
46
47 init(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false) {
48 self.section = section
49 self.page = page
50 self.reveal = reveal
51 self.titleFocus = titleFocus
52 super.init(nibName: nil, bundle: nil)
53 navigationItem.largeTitleDisplayMode = .never
54 navigationItem.rightBarButtonItems = [more, draw]
55 showTitle(section.row(of: page)?.row.title ?? "")
56 }
57
58 required init?(coder: NSCoder) { fatalError() }
59
60 override func loadView() {
61 let view = UIView()
62 view.backgroundColor = .systemBackground
63 canvas.translatesAutoresizingMaskIntoConstraints = false
64 bar.translatesAutoresizingMaskIntoConstraints = false
65 view.addSubview(canvas)
66 view.addSubview(bar)
67 media.translatesAutoresizingMaskIntoConstraints = false
68 view.addSubview(media)
69 let wide = media.widthAnchor.constraint(equalToConstant: 480)
70 wide.priority = .required - 1
71 NSLayoutConstraint.activate([
72 wide,
73 media.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
74 media.leadingAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
75 media.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -12),
76 canvas.leadingAnchor.constraint(equalTo: view.leadingAnchor),
77 canvas.trailingAnchor.constraint(equalTo: view.trailingAnchor),
78 canvas.topAnchor.constraint(equalTo: view.topAnchor),
79 canvas.bottomAnchor.constraint(equalTo: view.bottomAnchor),
80 bar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
81 bar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
82 bar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
83 ])
84 self.view = view
85 canvas.onChange = { [weak self] in self?.editingChanged() }
86 canvas.onOpened = { [weak self] in self?.opened() }
87 canvas.onDate = { [weak self] field, date in self?.pickDate(field, date) }
88 canvas.onPlay = { [weak self] request in self?.play(request) }
89 media.onPause = { [weak self] in self?.pauseMedia() }
90 media.onStop = { [weak self] in self?.stopMedia() }
91 NotificationCenter.default.addObserver(self, selector: #selector(showMedia), name: Recorder.changed, object: nil)
92 canvas.formatBar.insert = { [weak self] in self?.insertActions() ?? [] }
93 canvas.formatBar.onPaper = { [weak self] in
94 // The keyboard would cover the picker's sheet.
95 _ = self?.canvas.resignFirstResponder()
96 self?.showPaper()
97 }
98 bar.onAction = { [weak self] in self?.conflictAction() }
99 NotificationCenter.default.addObserver(
100 self, selector: #selector(sectionChanged), name: Section.changed, object: section)
101 NotificationCenter.default.addObserver(self, selector: #selector(leaving), name: Self.leaving, object: nil)
102 conflictChanged()
103 registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (controller: PageViewController, _) in
104 controller.fitTitle()
105 }
106 }
107
108 /// The page is on screen: a found word or tagged paragraph is selected, a new page's
109 /// title takes the caret.
110 private func opened() {
111 let revealed =
112 switch reveal {
113 case .text(let query): canvas.find(query)
114 case .paragraph(let id): canvas.selectParagraph(id)
115 case nil: false
116 }
117 if revealed || titleFocus && canvas.focusTitle() {
118 _ = canvas.becomeFirstResponder()
119 }
120 if canvas.reading?.offered == true {
121 reader = UIBarButtonItem(
122 title: "Reading View", image: UIImage(systemName: "text.justify.left"),
123 primaryAction: UIAction { [weak self] _ in self?.toggleReading() })
124 editingChanged()
125 }
126 }
127
128 private func showTitle(_ title: String) {
129 self.title = title.isEmpty ? "Untitled Page" : title
130 }
131
132 /// A phone's bar has no room for the title beside the editing buttons, and the page
133 /// shows it anyway, as Notes leaves it out.
134 private func fitTitle() {
135 let crowded = canvas.isFirstResponder && traitCollection.horizontalSizeClass == .compact
136 navigationItem.titleView = crowded ? UIView() : nil
137 }
138
139 /// Undo and redo join the bar while editing or drawing; the format bar puts the keyboard
140 /// away.
141 private func editingChanged() {
142 if !canvas.readOnly, let title = canvas.pageTitle {
143 showTitle(title)
144 section.retitle(page, title)
145 }
146 fitTitle()
147 draw.isSelected = canvas.pickerShown
148 draw.isEnabled = !canvas.readOnly
149 undo.isEnabled = canvas.canUndo(redo: false)
150 redo.isEnabled = canvas.canUndo(redo: true)
151 // Drawing keeps undo at hand, as typing does.
152 reader?.isSelected = canvas.readingShown
153 guard canvas.isFirstResponder || canvas.pickerShown else {
154 let resting = [more, draw] + (reader.map { [$0] } ?? [])
155 if navigationItem.rightBarButtonItems != resting { navigationItem.rightBarButtonItems = resting }
156 return
157 }
158 let editing = [more, draw, redo, undo]
159 if navigationItem.rightBarButtonItems != editing { navigationItem.rightBarButtonItems = editing }
160 }
161
162 // MARK: Page menu
163
164 /// The desktop's page commands that suit a touch screen.
165 private func pageMenu() -> [UIMenuElement] {
166 let scene = view.window?.windowScene?.delegate as? SceneDelegate
167 return [readingAction()] + [
168 themeMenu(),
169 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
170 guard let self else { return }
171 scene?.showTags(of: section.notebook, from: self)
172 },
173 UIMenu(options: .displayInline, children: [
174 UIAction(title: "Print", image: UIImage(systemName: "printer")) { [weak self] _ in
175 self?.printPage(export: false)
176 },
177 UIAction(title: "Export as PDF", image: UIImage(systemName: "doc.richtext")) { [weak self] _ in
178 self?.printPage(export: true)
179 },
180 ]),
181 UIMenu(options: .displayInline, children: [
182 UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in
183 scene?.newPage(subpage: true)
184 },
185 UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) {
186 [weak self] _ in
187 guard let self else { return }
188 section.delete([page])
189 },
190 ]),
191 ]
192 }
193
194 /// The desktop's Insert tab, as the format bar's `+` offers it.
195 private func insertActions() -> [UIMenuElement] {
196 [
197 UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle")) { [weak self] _ in
198 self?.pickPicture(camera: false)
199 },
200 UIAction(
201 title: "Take Photo", image: UIImage(systemName: "camera"),
202 attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? [] : .disabled
203 ) { [weak self] _ in self?.pickPicture(camera: true) },
204 UIMenu(options: .displayInline, children: [
205 UIAction(title: "Date", image: UIImage(systemName: "calendar")) { [weak self] _ in
206 self?.canvas.insertDate(true, time: false)
207 },
208 UIAction(title: "Time", image: UIImage(systemName: "clock")) { [weak self] _ in
209 self?.canvas.insertDate(false, time: true)
210 },
211 UIAction(title: "Date & Time", image: UIImage(systemName: "calendar.badge.clock")) { [weak self] _ in
212 self?.canvas.insertDate(true, time: true)
213 },
214 ]),
215 UIAction(title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal")) { [weak self] _ in
216 self?.canvas.insertSpace()
217 },
218 UIMenu(options: .displayInline, children: [
219 UIAction(
220 title: "Record Audio", image: UIImage(systemName: "mic"),
221 attributes: Recorder.current == nil ? [] : .disabled
222 ) { [weak self] _ in self?.record(video: false) },
223 UIAction(
224 title: "Record Video", image: UIImage(systemName: "video"),
225 attributes: Recorder.current == nil && Recorder.canRecordVideo ? [] : .disabled
226 ) { [weak self] _ in self?.record(video: true) },
227 ]),
228 ]
229 }
230
231 /// Reading View: the page reflowed into the screen's width where it reflows.
232 private func readingAction() -> UIAction {
233 let reading = canvas.reading
234 return UIAction(
235 title: "Reading View",
236 image: UIImage(systemName: "text.justify.left"),
237 attributes: reading?.offered == true || canvas.readingShown ? [] : .disabled,
238 state: canvas.readingShown ? .on : .off
239 ) { [weak self] _ in self?.toggleReading() }
240 }
241
242 private func toggleReading() {
243 // A recording goes on the page as laid out.
244 guard Recorder.current?.canvas !== canvas else { return }
245 canvas.showReading(!canvas.readingShown)
246 }
247
248 /// The page's theme, its section's and its notebook's, each pickable; the page wears a
249 /// new one when it opens again.
250 private func themeMenu() -> UIMenu {
251 guard let themes = section.themes(of: page) else {
252 return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: [])
253 }
254 let scopes: [(String, String?)] = [
255 ("This Page", themes.page), ("This Section", themes.section), ("This Notebook", themes.notebook),
256 ]
257 let page = page
258 let menus = scopes.enumerated().map { scope, entry in
259 let (title, current) = entry
260 let choices = [(nil as String?, "None")] + themes.themes.map { ($0.id, $0.name) }
261 return UIMenu(title: title, children: choices.map { id, name in
262 UIAction(title: name, state: id == current ? .on : .off) { [weak self] _ in
263 guard let self else { return }
264 section.setTheme(id, scope: UInt8(scope), of: page) { [weak self] _ in
265 self?.onOpen?(page)
266 }
267 }
268 })
269 }
270 return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: menus)
271 }
272
273 /// Prints the page through the system's print sheet, or with `export` offers its PDF in
274 /// the share sheet, laid out as OneNote 2010 prints it on the region's paper.
275 private func printPage(export: Bool) {
276 let letter = ["US", "CA", "MX", "PR", "PH", "CL", "CO", "VE", "GT", "CR", "PA", "DO", "SV", "NI", "HN", "BZ"]
277 .contains(Locale.current.region?.identifier ?? "US")
278 let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89)
279 let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? ""
280 guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else {
281 return alert(
282 export ? "Couldn't Export the PDF" : "Couldn't Print", "The page could not be laid out on paper.")
283 }
284 if export {
285 let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-")
286 .trimmingCharacters(in: .whitespaces)
287 let file = FileManager.default.temporaryDirectory
288 .appendingPathComponent((name.isEmpty ? "Untitled" : name) + ".pdf")
289 do {
290 try pdf.write(to: file)
291 } catch {
292 return
293 }
294 let share = UIActivityViewController(activityItems: [file], applicationActivities: nil)
295 share.popoverPresentationController?.barButtonItem = more
296 present(share, animated: true)
297 } else {
298 let info = UIPrintInfo.printInfo()
299 info.jobName = title
300 let printer = UIPrintInteractionController.shared
301 printer.printInfo = info
302 printer.printingItem = pdf
303 printer.present(from: more, animated: true)
304 }
305 }
306
307 private func showPaper() {
308 let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas))
309 navigation.sheetPresentationController?.detents = [.medium(), .large()]
310 navigation.sheetPresentationController?.prefersGrabberVisible = true
311 navigation.sheetPresentationController?.largestUndimmedDetentIdentifier = .medium
312 present(navigation, animated: true)
313 }
314
315 @objc private func leaving() { canvas.commitComposition() }
316
317 override func viewWillDisappear(_ animated: Bool) {
318 super.viewWillDisappear(animated)
319 // A recording goes on the page it started on, as the desktop's does on leaving it.
320 if Recorder.current?.canvas === canvas { Recorder.current?.stop() }
321 stopPlayback()
322 }
323
324 // MARK: Recording and playback
325
326 func record(video: Bool) {
327 stopPlayback()
328 Recorder.start(video: video, on: canvas) { [weak self] title, message in self?.alert(title, message) }
329 }
330
331 func stopRecording() {
332 if Recorder.current?.canvas === canvas { Recorder.current?.stop() }
333 }
334
335 private func play(_ request: PlayRequest) {
336 guard Recorder.current == nil else { return }
337 playback = Playback(request)
338 showMedia()
339 }
340
341 private func stopPlayback() {
342 guard playback != nil else { return }
343 playback = nil
344 canvas.played(at: nil)
345 showMedia()
346 }
347
348 private func pauseMedia() {
349 if let recorder = Recorder.current, recorder.canvas === canvas {
350 recorder.pause(!(recorder.elapsed?.paused ?? false))
351 } else {
352 playback?.toggle()
353 }
354 showMedia()
355 }
356
357 private func stopMedia() {
358 if Recorder.current?.canvas === canvas {
359 Recorder.current?.stop()
360 } else {
361 stopPlayback()
362 }
363 }
364
365 /// The bar shows the clock of what records or plays, and a video's picture as it plays.
366 @objc private func showMedia() {
367 if let recorder = Recorder.current, recorder.canvas === canvas {
368 media.picture.isHidden = true
369 if recorder.saving {
370 media.showSaving(recorder.video ? "Saving video…" : "Saving audio…")
371 } else if let elapsed = recorder.elapsed {
372 let state = elapsed.paused ? "Paused" : recorder.video ? "Recording video" : "Recording"
373 media.show(
374 "\(state) \(MediaBar.clock(elapsed.ms))", recording: true,
375 paused: recorder.pausable ? elapsed.paused : nil)
376 }
377 } else if let playback {
378 if playback.failed {
379 stopPlayback()
380 return alert("Couldn't Play the Recording", "This kind of recording doesn't play on this device.")
381 }
382 let total = playback.durationMs.map { " / " + MediaBar.clock($0) } ?? ""
383 media.show(
384 "\(playback.request.name) \(MediaBar.clock(playback.ms))\(total)", recording: false,
385 paused: !playback.playing, playing: true)
386 if let picture = playback.picture() { media.picture.image = picture }
387 media.picture.isHidden = playback.request.video == nil
388 canvas.played(at: playback.ms)
389 } else {
390 media.isHidden = true
391 canvas.bottomInset = 0
392 mediaClock?.invalidate()
393 mediaClock = nil
394 return
395 }
396 // The line being written stays above the bar.
397 canvas.bottomInset = view.keyboardLayoutGuide.layoutFrame.minY - media.frame.minY
398 if mediaClock == nil {
399 mediaClock = Timer.scheduledTimer(withTimeInterval: 1.0 / 15, repeats: true) { [weak self] _ in
400 self?.showMedia()
401 }
402 }
403 }
404
405 @objc private func sectionChanged(_ notification: Notification) {
406 let flags = notification.userInfo?["flags"] as? UInt32 ?? 0
407 if flags & Section.rejected != 0 {
408 let kept = canvas.keep()
409 canvas.reload(discard: true)
410 alert("Change Not Saved", "The page shows what was last saved."
411 + (kept ? " Your version is kept as a separate page." : ""))
412 } else if flags & Section.remote != 0 {
413 canvas.reload(discard: false)
414 } else {
415 canvas.reload(discard: false, pendingOnly: true)
416 if notification.userInfo?["unchanged"] as? Bool == true { return }
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}