Skip to content
Source
Sign inSign up

T3NativeFilePresentation.swift

356 lines13.6 KB

1import ImageIO
2import QuickLook
3import UIKit
4import UniformTypeIdentifiers
5​
6private final class FilePreviewItem: NSObject, QLPreviewItem {
7 var previewItemURL: URL?
8 var previewItemTitle: String?
9}
10​
11private enum FilePreviewState { case loading, ready, failed }
12​
13private final class FilePreviewController: QLPreviewController {
14 var onAppear: (() -> Void)?
15 // Quick Look shows a blank page for an item without a file, so loading and failure are ours to show.
16 private let spinner = UIActivityIndicatorView(style: .large)
17 private let failure = UILabel()
18 var state = FilePreviewState.ready {
19 didSet { if isViewLoaded { showState() } }
20 }
21​
22 override func viewDidLoad() {
23 super.viewDidLoad()
24 failure.text = "This image could not be loaded."
25 failure.textColor = .secondaryLabel
26 failure.textAlignment = .center
27 failure.numberOfLines = 0
28 for status in [spinner, failure] as [UIView] {
29 status.isUserInteractionEnabled = false
30 status.translatesAutoresizingMaskIntoConstraints = false
31 view.addSubview(status)
32 NSLayoutConstraint.activate([
33 status.centerXAnchor.constraint(equalTo: view.centerXAnchor),
34 status.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 64),
35 status.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, constant: -48),
36 ])
37 }
38 showState()
39 }
40​
41 override func viewDidLayoutSubviews() {
42 super.viewDidLayoutSubviews()
43 view.bringSubviewToFront(spinner)
44 view.bringSubviewToFront(failure)
45 }
46​
47 override func viewDidAppear(_ animated: Bool) {
48 super.viewDidAppear(animated)
49 onAppear?()
50 }
51​
52 private func showState() {
53 if state == .loading { spinner.startAnimating() } else { spinner.stopAnimating() }
54 failure.isHidden = state != .failed
55 }
57​
58final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource,
59 QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate {
60 let identifier: String
61 private var controller: FilePreviewController?
62 private let completion: (Error?) -> Void
63 private weak var sources: T3PresentationSources?
64 private let sourceIdentifiers: [String]
65 private var items: [FilePreviewItem] = []
66 private var urls: [URL?] = []
67 private var states: [FilePreviewState] = []
68 private var selectedIndex = 0
69 private weak var presenter: UIViewController?
70 /// Files that arrived while their page was off screen; Quick Look may still hold the blank page.
71 private var staleIndices = Set<Int>()
72 private var currentIndexObservation: NSKeyValueObservation?
73 private var download: (index: Int, task: Task<Void, Never>)?
74 private var placeholder: Task<Void, Never>?
75 private var openError: Error?
76 private var dismissRequested = false
77 private var finished = false
78​
79 init(identifier: String, sources: T3PresentationSources, sourceIdentifiers: [String],
80 completion: @escaping (Error?) -> Void) {
81 self.identifier = identifier
82 self.sources = sources
83 self.sourceIdentifiers = sourceIdentifiers
84 self.completion = completion
85 super.init()
86 }
87​
88 /// Downloads the file before presenting so Quick Look can refuse a format the caller should share instead.
89 func present(url: URL, title: String, from presenter: UIViewController) {
90 load(urls: [url], titles: [title], selectedIndex: 0, from: presenter, opensWhileLoading: false)
91 }
92​
93 /// Opens the selected image once it is ready, or a loading page if it is slow, and loads the rest
94 /// of the gallery behind it so a slow or broken sibling never holds the viewer back.
95 func present(urls: [URL?], titles: [String], selectedIndex: Int, from presenter: UIViewController) {
96 load(urls: urls, titles: titles, selectedIndex: selectedIndex, from: presenter, opensWhileLoading: true)
97 }
98​
99 /// Delivers a URL that was still resolving when the gallery opened, or nil when it never will.
100 func update(index: Int, url: URL?) {
101 guard !finished, urls.indices.contains(index), urls[index] == nil, states[index] == .loading else { return }
102 guard let url else {
103 failed(index, error: URLError(.resourceUnavailable))
104 return
105 }
106 urls[index] = url
107 loadNext()
108 }
109​
110 private func load(urls: [URL?], titles: [String], selectedIndex: Int, from presenter: UIViewController,
111 opensWhileLoading: Bool) {
112 items = titles.map { title in
113 let item = FilePreviewItem()
114 item.previewItemTitle = title
115 return item
116 }
117 self.urls = urls
118 self.selectedIndex = selectedIndex
119 self.presenter = presenter
120 states = Array(repeating: .loading, count: urls.count)
121 if opensWhileLoading {
122 // A short grace keeps the zoom from the thumbnail into a loaded image when the file is quick.
123 placeholder = Task { @MainActor [self] in
124 try? await Task.sleep(nanoseconds: 250_000_000)
125 guard !Task.isCancelled, !finished, controller == nil else { return }
126 show()
127 }
128 }
129 loadNext()
130 }
131​
132 /// Downloads one file at a time, nearest the page on screen first. The page on screen takes over
133 /// from a background download, which restarts later.
134 private func loadNext() {
135 guard !finished, !dismissRequested else { return }
136 let current = controller?.currentPreviewItemIndex ?? selectedIndex
137 guard let next = urls.indices
138 .filter({ urls[$0] != nil && states[$0] == .loading })
139 .min(by: { abs($0 - current) < abs($1 - current) })
140 else { return }
141 if let download {
142 guard next == current, download.index != current else { return }
143 download.task.cancel()
144 }
145 guard let url = urls[next] else { return }
146 let title = items[next].previewItemTitle ?? ""
147 let task = Task { @MainActor [self] in
148 let result: Result<URL, Error>
149 do {
150 result = .success(try await Self.prepareFile(url: url, title: title))
151 } catch {
152 result = .failure(error)
153 }
154 guard !finished, !Task.isCancelled else {
155 if case .success(let file) = result {
156 try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
157 }
158 return
159 }
160 download = nil
161 switch result {
162 case .success(let file): loaded(next, file: file)
163 case .failure(let error): failed(next, error: error)
164 }
165 loadNext()
166 }
167 download = (next, task)
168 }
169​
170 private func loaded(_ index: Int, file: URL) {
171 // Quick Look knows which formats it renders; refuse before presenting so the caller
172 // can fall back instead of showing an "unsupported format" page.
173 guard QLPreviewController.canPreview(file as NSURL) else {
174 try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
175 failed(index, error: NSError(
176 domain: "T3NativePresentation",
177 code: 3,
178 userInfo: [NSLocalizedDescriptionKey: "This file type cannot be previewed on this device."]
179 ))
180 return
181 }
182 items[index].previewItemURL = file
183 states[index] = .ready
184 if controller != nil { refresh(index) } else if index == selectedIndex { show() }
185 }
186​
187 private func failed(_ index: Int, error: Error) {
188 guard index != selectedIndex else {
189 openError = error
190 dismiss()
191 return
192 }
193 states[index] = .failed
194 refresh(index)
195 }
196​
197 private func show() {
198 guard let presenter else { finish(); return }
199 placeholder?.cancel()
200 let preview = FilePreviewController()
201 preview.delegate = self
202 preview.dataSource = self
203 preview.currentPreviewItemIndex = selectedIndex
204 preview.onAppear = { [weak self] in self?.resumePendingDismissal() }
205 controller = preview
206 preview.state = states[selectedIndex]
207 currentIndexObservation = preview.observe(\.currentPreviewItemIndex) { [weak self] _, _ in
208 DispatchQueue.main.async { self?.currentItemChanged() }
209 }
210 presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in
211 resumePendingDismissal()
212 }
213 preview.presentationController?.delegate = self
214 }
215​
216 private func refresh(_ index: Int) {
217 guard let controller else { return }
218 guard controller.currentPreviewItemIndex == index else {
219 if states[index] == .ready { staleIndices.insert(index) }
220 return
221 }
222 if states[index] == .ready { controller.refreshCurrentPreviewItem() }
223 controller.state = states[index]
224 }
225​
226 private func currentItemChanged() {
227 guard !finished, let controller, states.indices.contains(controller.currentPreviewItemIndex) else { return }
228 let index = controller.currentPreviewItemIndex
229 if staleIndices.remove(index) != nil { controller.refreshCurrentPreviewItem() }
230 controller.state = states[index]
231 loadNext()
232 }
233​
234 func dismiss() {
235 dismissRequested = true
236 download?.task.cancel()
237 guard !finished else { return }
238 guard let controller else { finish(); return }
239 // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal.
240 // Starting a second modal transition while UIKit is settling the first can strand it.
241 guard !controller.isBeingPresented, !controller.isBeingDismissed else { return }
242 controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() }
243 }
244​
245 private func resumePendingDismissal() {
246 // Appearance callbacks run before UIKit has cleared the current transition.
247 DispatchQueue.main.async { [weak self] in
248 if self?.dismissRequested == true { self?.dismiss() }
249 }
250 }
251​
252 func numberOfPreviewItems(in controller: QLPreviewController) -> Int { items.count }
253​
254 func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
255 items[index]
256 }
257​
258 private func sourceView(for item: QLPreviewItem) -> UIView? {
259 guard !UIAccessibility.isReduceMotionEnabled,
260 let index = items.firstIndex(where: { $0 === item }),
261 sourceIdentifiers.indices.contains(index)
262 else { return nil }
263 return sources?.view(for: sourceIdentifiers[index])
264 }
265​
266 func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? {
267 sourceView(for: item)
268 }
269​
270 func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem,
271 inSourceView view: AutoreleasingUnsafeMutablePointer<UIView?>) -> CGRect {
272 guard let source = sourceView(for: item) else { return .zero }
273 view.pointee = source
274 return source.bounds
275 }
276​
277 func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() }
278​
279 func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() }
280​
281 private func finish(error: Error? = nil) {
282 guard !finished else { return }
283 finished = true
284 download?.task.cancel()
285 download = nil
286 placeholder?.cancel()
287 placeholder = nil
288 currentIndexObservation = nil
289 for item in items {
290 if let file = item.previewItemURL {
291 try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
292 }
293 }
294 items.removeAll()
295 urls.removeAll()
296 states.removeAll()
297 let result = error ?? openError
298 DispatchQueue.main.async { [completion] in completion(result) }
299 }
300​
301 /// Copy original bytes so preview and sharing do not mutate a draft or workspace file.
302 nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL {
303 try Task.checkCancellation()
304 let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)")
305 try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
306 do {
307 let download = directory.appendingPathComponent("original")
308 if url.isFileURL {
309 try FileManager.default.copyItem(at: url, to: download)
310 } else if url.scheme == "data" {
311 try Data(contentsOf: url).write(to: download, options: .atomic)
312 } else {
313 guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else {
314 throw URLError(.unsupportedURL)
315 }
316 let (temporaryFile, response) = try await URLSession.shared.download(from: url)
317 guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else {
318 throw URLError(.badServerResponse)
319 }
320 try FileManager.default.moveItem(at: temporaryFile, to: download)
321 }
322 try Task.checkCancellation()
323 // Detect images and PDFs by content; preserve other extensions so Quick Look can decide support.
324 let filename = URL(fileURLWithPath: title).lastPathComponent as NSString
325 let originalExtension = filename.pathExtension
326 var type: UTType?
327 if let image = CGImageSourceCreateWithURL(download as CFURL, nil),
328 CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image),
329 let detectedType = UTType(imageType as String) {
330 type = detectedType
331 } else if CGPDFDocument(download as CFURL) != nil {
332 type = .pdf
333 } else if originalExtension.lowercased() == "svg" {
334 type = .svg
335 }
336 let fileExtension: String
337 if let type {
338 fileExtension = UTType(filenameExtension: originalExtension) == type
339 ? originalExtension : type.preferredFilenameExtension ?? originalExtension
340 } else {
341 fileExtension = originalExtension
342 }
343 let stem = filename.deletingPathExtension
344 var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_")
345 while name.utf8.count > 200 { name.removeLast() }
346 let baseName = name.isEmpty ? "Preview" : name
347 let file = directory.appendingPathComponent(
348 fileExtension.isEmpty ? baseName : "\(baseName).\(fileExtension)")
349 try FileManager.default.moveItem(at: download, to: file)
350 return file
351 } catch {
352 try? FileManager.default.removeItem(at: directory)
353 throw error
354 }
355 }