4/// Render the current caption on top of the image.
5struct CaptionOverlay: ViewModifier {
8 @State private var captionText: String = ""
9 @State private var isEditing: Bool = false
10 @State private var isLoading: Bool = false
12 // Holds a task that delays the "caption loading" spinner so we don't get
13 // short-lived flashes of loading content.
14 @State private var spinnerDelayTask: Task<Void, Never>? = nil
16 init(asset: PHAsset) {
20 func body(content: Content) -> some View {
21 content.overlay(alignment: Alignment(horizontal: .center, vertical: .bottom)) {
23 if isLoading && captionText.isEmpty {
24 ProgressView().controlSize(.small)
29 onCancel: revertAndExit
32 Text(self.captionText.isEmpty ? "Add a caption..." : captionText)
34 .italic(captionText.isEmpty)
42 .foregroundColor(captionText.isEmpty ? .gray : .white)
43 .frame(maxWidth: .infinity, minHeight: 44, alignment: .center)
44 .background(Color.black.opacity(0.6))
47 // If the user presses the space key and we're not already editing
48 // or loading the caption, switch into the editing view.
49 .onReceive(NotificationCenter.default.publisher(for: .triggerCaptionEditing)) { _ in
50 if !isEditing && !isLoading {
60 /// fetchCaption fetches the caption text asynchronously, so it doesn't
61 /// block the main thread.
63 /// We show a progress spinner while loading the caption, but only if it
64 /// takes more than a quarter of a second to load. In practice, captions
65 /// load near-instantly and so you normally don't see the spinnner.
66 private func fetchCaption() async {
67 spinnerDelayTask?.cancel()
69 spinnerDelayTask = Task {
70 try? await Task.sleep(for: .seconds(0.25))
71 if !Task.isCancelled {
76 captionText = await asset.getCaption()
78 spinnerDelayTask?.cancel()
82 /// enterEditingMode enables the text field, so the user can enter a caption.
83 private func enterEditingMode() {
87 /// saveAndExit saves the new caption to the Photos app and closes the text field,
88 /// displaying the new caption.
89 private func saveAndExit() {
90 let textToSave = captionText
94 await asset.setCaption(textToSave)
98 /// revertAndExit closes the text field and cancels the caption edit, reloading the
99 /// text from Photos app.
100 private func revertAndExit() {
104 captionText = await asset.getCaption()
109/// CaptionEditorView provides a text field for editing the caption.
111/// There are "Cancel" and "Save" buttons on the right-hand side, which can
112/// be triggered with keyboard shortcuts "Esc" and "⌘+Enter".
113struct CaptionEditorView: View {
114 @Binding var text: String
115 var onSave: () -> Void
116 var onCancel: () -> Void
118 @FocusState private var isFocused: Bool
120 var body: some View {
122 // Left-hand side: two invisible buttons offset the size of the
123 // buttons on the right-hand side, so text is centred in the window.
124 Button("Cancel") {} .hidden().allowsHitTesting(false).padding(.leading)
125 Button("Save") {}.hidden().allowsHitTesting(false)
127 // Centre: the editable text field.
128 TextField("Enter caption...", text: $text, axis: .vertical)
129 .textFieldStyle(.plain)
130 .foregroundColor(.white)
131 .multilineTextAlignment(.center)
138 // Right-hand side: buttons to cancel or save the current caption.
139 Button("Cancel") { onCancel() }
140 .buttonStyle(.bordered)
141 .keyboardShortcut(.escape, modifiers: [])
143 Button("Save") { onSave() }
144 .buttonStyle(.borderedProminent)
145 .keyboardShortcut(.return, modifiers: .command)
149 // Automatically focus the text field as soon as this
150 // view is displayed.
156extension Notification.Name {
157 // triggerCaptionEditing is a notification sent by the keyDown handler
158 // in the top-level view when it receives a space, telling the overlay
159 // it should switch into editing mode.
160 static let triggerCaptionEditing = Notification.Name("triggerCaptionEditing")
164 func caption(for asset: PHAsset) -> some View {
165 modifier(CaptionOverlay(asset: asset))