Skip to main content

Blink/Views/FocusedImage/CaptionOverlay.swift

1import SwiftUI
2import Photos
4/// Render the current caption on top of the image.
5struct CaptionOverlay: ViewModifier {
6 var asset: PHAsset
7
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) {
17 self.asset = asset
18 }
20 func body(content: Content) -> some View {
21 content.overlay(alignment: Alignment(horizontal: .center, vertical: .bottom)) {
22 HStack {
23 if isLoading && captionText.isEmpty {
24 ProgressView().controlSize(.small)
25 } else if isEditing {
26 CaptionEditorView(
27 text: $captionText,
28 onSave: saveAndExit,
29 onCancel: revertAndExit
30 )
31 } else {
32 Text(self.captionText.isEmpty ? "Add a caption..." : captionText)
33 .font(.headline)
34 .italic(captionText.isEmpty)
36 .onTapGesture {
37 enterEditingMode()
38 }
39 .padding()
40 }
41 }
42 .foregroundColor(captionText.isEmpty ? .gray : .white)
43 .frame(maxWidth: .infinity, minHeight: 44, alignment: .center)
44 .background(Color.black.opacity(0.6))
45 .cornerRadius(8)
46 .padding()
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 {
51 enterEditingMode()
52 }
53 }
54 .task {
55 await fetchCaption()
56 }
57 }
58 }
60 /// fetchCaption fetches the caption text asynchronously, so it doesn't
61 /// block the main thread.
62 ///
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 {
72 isLoading = true
73 }
74 }
76 captionText = await asset.getCaption()
78 spinnerDelayTask?.cancel()
79 isLoading = false
80 }
82 /// enterEditingMode enables the text field, so the user can enter a caption.
83 private func enterEditingMode() {
84 isEditing = true
85 }
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
91 isEditing = false
93 Task {
94 await asset.setCaption(textToSave)
95 }
96 }
98 /// revertAndExit closes the text field and cancels the caption edit, reloading the
99 /// text from Photos app.
100 private func revertAndExit() {
101 isEditing = false
103 Task {
104 captionText = await asset.getCaption()
105 }
106 }
109/// CaptionEditorView provides a text field for editing the caption.
110///
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 {
121 HStack {
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)
132 .padding()
133 .focused($isFocused)
134 .onSubmit {
135 onSave()
136 }
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)
146 .padding(.trailing)
147 }
148 .onAppear {
149 // Automatically focus the text field as soon as this
150 // view is displayed.
151 isFocused = true
152 }
153 }
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")
163extension View {
164 func caption(for asset: PHAsset) -> some View {
165 modifier(CaptionOverlay(asset: asset))
166 }