import SwiftUI import Photos /// Render the current caption on top of the image. struct CaptionOverlay: ViewModifier { var asset: PHAsset @State private var captionText: String = "" @State private var isEditing: Bool = false @State private var isLoading: Bool = false // Holds a task that delays the "caption loading" spinner so we don't get // short-lived flashes of loading content. @State private var spinnerDelayTask: Task? = nil init(asset: PHAsset) { self.asset = asset } func body(content: Content) -> some View { content.overlay(alignment: Alignment(horizontal: .center, vertical: .bottom)) { HStack { if isLoading && captionText.isEmpty { ProgressView().controlSize(.small) } else if isEditing { CaptionEditorView( text: $captionText, onSave: saveAndExit, onCancel: revertAndExit ) } else { Text(self.captionText.isEmpty ? "Add a caption..." : captionText) .font(.headline) .italic(captionText.isEmpty) .onTapGesture { enterEditingMode() } .padding() } } .foregroundColor(captionText.isEmpty ? .gray : .white) .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) .background(Color.black.opacity(0.6)) .cornerRadius(8) .padding() // If the user presses the space key and we're not already editing // or loading the caption, switch into the editing view. .onReceive(NotificationCenter.default.publisher(for: .triggerCaptionEditing)) { _ in if !isEditing && !isLoading { enterEditingMode() } } .task { await fetchCaption() } } } /// fetchCaption fetches the caption text asynchronously, so it doesn't /// block the main thread. /// /// We show a progress spinner while loading the caption, but only if it /// takes more than a quarter of a second to load. In practice, captions /// load near-instantly and so you normally don't see the spinnner. private func fetchCaption() async { spinnerDelayTask?.cancel() spinnerDelayTask = Task { try? await Task.sleep(for: .seconds(0.25)) if !Task.isCancelled { isLoading = true } } captionText = await asset.getCaption() spinnerDelayTask?.cancel() isLoading = false } /// enterEditingMode enables the text field, so the user can enter a caption. private func enterEditingMode() { isEditing = true } /// saveAndExit saves the new caption to the Photos app and closes the text field, /// displaying the new caption. private func saveAndExit() { let textToSave = captionText isEditing = false Task { await asset.setCaption(textToSave) } } /// revertAndExit closes the text field and cancels the caption edit, reloading the /// text from Photos app. private func revertAndExit() { isEditing = false Task { captionText = await asset.getCaption() } } } /// CaptionEditorView provides a text field for editing the caption. /// /// There are "Cancel" and "Save" buttons on the right-hand side, which can /// be triggered with keyboard shortcuts "Esc" and "⌘+Enter". struct CaptionEditorView: View { @Binding var text: String var onSave: () -> Void var onCancel: () -> Void @FocusState private var isFocused: Bool var body: some View { HStack { // Left-hand side: two invisible buttons offset the size of the // buttons on the right-hand side, so text is centred in the window. Button("Cancel") {} .hidden().allowsHitTesting(false).padding(.leading) Button("Save") {}.hidden().allowsHitTesting(false) // Centre: the editable text field. TextField("Enter caption...", text: $text, axis: .vertical) .textFieldStyle(.plain) .foregroundColor(.white) .multilineTextAlignment(.center) .padding() .focused($isFocused) .onSubmit { onSave() } // Right-hand side: buttons to cancel or save the current caption. Button("Cancel") { onCancel() } .buttonStyle(.bordered) .keyboardShortcut(.escape, modifiers: []) Button("Save") { onSave() } .buttonStyle(.borderedProminent) .keyboardShortcut(.return, modifiers: .command) .padding(.trailing) } .onAppear { // Automatically focus the text field as soon as this // view is displayed. isFocused = true } } } extension Notification.Name { // triggerCaptionEditing is a notification sent by the keyDown handler // in the top-level view when it receives a space, telling the overlay // it should switch into editing mode. static let triggerCaptionEditing = Notification.Name("triggerCaptionEditing") } extension View { func caption(for asset: PHAsset) -> some View { modifier(CaptionOverlay(asset: asset)) } }