@craft-native/ios 0.0.387 → 0.0.392

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.387",
3
+ "version": "0.0.392",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,13 @@
1
1
  import Foundation
2
2
  import SwiftUI
3
3
  import UIKit
4
+
5
+ /// Includes deceleration, which runs in default mode after tracking ends.
6
+ func craftHybridHasActiveScroll(in view: UIView) -> Bool {
7
+ if let scroll = view as? UIScrollView,
8
+ scroll.isTracking || scroll.isDragging || scroll.isDecelerating { return true }
9
+ return view.subviews.contains { craftHybridHasActiveScroll(in: $0) }
10
+ }
4
11
  import WebKit
5
12
  import Combine
6
13
 
@@ -1261,9 +1268,14 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1261
1268
  item.accessibilityLabel = tab.accessibilityLabel ?? tab.displayTitle
1262
1269
  }
1263
1270
  if let committed = stacks[currentTab], controllers.contains(where: { $0 === committed }), selectedViewController !== committed {
1264
- UIView.performWithoutAnimation {
1265
- selectedViewController = committed
1266
- view.layoutIfNeeded()
1271
+ // UIKit owns the Liquid Glass selection pill. Suppressing this
1272
+ // transaction made a completed page swipe snap its indicator.
1273
+ // Keep native selection animation and honour Reduce Motion.
1274
+ let duration = UIAccessibility.isReduceMotionEnabled ? 0 : 0.22
1275
+ UIView.animate(withDuration: duration, delay: 0,
1276
+ options: [.beginFromCurrentState, .allowUserInteraction]) {
1277
+ self.selectedViewController = committed
1278
+ self.tabBar.layoutIfNeeded()
1267
1279
  }
1268
1280
  }
1269
1281
  tabBar.tintColor = chrome.tint ?? config.tintColor.flatMap { UIColor(hex: $0) } ?? view.tintColor
@@ -1950,6 +1962,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1950
1962
  guard self.currentTab == owner, self.view.window != nil else { return }
1951
1963
  let quietFor = neighbour ? 0.3 : 1.0
1952
1964
  guard self.activeTabSwipeTarget == nil, !self.tabSwipeSettling,
1965
+ !craftHybridHasActiveScroll(in: self.view),
1953
1966
  !self.stacks.values.contains(where: { $0.transitionCoordinator != nil }),
1954
1967
  CACurrentMediaTime() - self.lastNavigation >= quietFor else {
1955
1968
  self.prepareAdjacentNativeTabs()
@@ -272,19 +272,19 @@ enum CraftNativeActions {
272
272
  // A person picks, so the bridge never times it out.
273
273
  let options = args.first as? [String: Any] ?? [:]
274
274
  DispatchQueue.main.async {
275
- CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera) { completeFile(requestToken, $0, completion) }
275
+ CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera, requestToken: requestToken, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
276
276
  }
277
277
  return
278
278
  case ("Files", "pickVideo"):
279
279
  let options = args.first as? [String: Any] ?? [:]
280
280
  DispatchQueue.main.async {
281
- CraftNativeFiles.pickVideo(options, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
281
+ CraftNativeFiles.pickVideo(options, requestToken: requestToken, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
282
282
  }
283
283
  return
284
284
  case ("Files", "pickFile"):
285
285
  let options = args.first as? [String: Any] ?? [:]
286
286
  DispatchQueue.main.async {
287
- CraftNativeFiles.pickFile(options, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
287
+ CraftNativeFiles.pickFile(options, requestToken: requestToken, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
288
288
  }
289
289
  return
290
290
  case ("Files", "download"):
@@ -719,6 +719,7 @@ enum CraftNativeActions {
719
719
  context?.invalidate()
720
720
  #endif
721
721
  task?.cancel()
722
+ CraftNativeFiles.cancelPicker(requestToken: requestToken)
722
723
  CraftNativePasskeys.shared.cancel(requestToken: requestToken)
723
724
  }
724
725
 
@@ -121,7 +121,8 @@ enum CraftNativeFiles {
121
121
  /// larger than `maxPixels` on its longest side, upright, with no metadata
122
122
  /// (no location) left in it. Answers the file, or null when the person
123
123
  /// closed the picker. Main thread.
124
- static func pickImage(_ options: [String: Any], cameraEnabled: Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
124
+ static func pickImage(_ options: [String: Any], cameraEnabled: Bool, requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
125
+ guard !isCancelled() else { done(.failure(importCancelled())); return }
125
126
  let maxPixels = craftNativeNumber(options["maxPixels"])
126
127
  .map { $0.doubleValue }
127
128
  .flatMap { value in
@@ -136,13 +137,6 @@ enum CraftNativeFiles {
136
137
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
137
138
  return
138
139
  }
139
- let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
140
- DispatchQueue.main.async {
141
- activePicker = nil
142
- done(result)
143
- }
144
- }
145
-
146
140
  if source == "camera" {
147
141
  #if CRAFT_PERMISSION_CAMERA
148
142
  guard cameraEnabled else {
@@ -153,15 +147,9 @@ enum CraftNativeFiles {
153
147
  done(.failure(CraftNativeActionError(code: "UNAVAILABLE", message: "This device has no camera")))
154
148
  return
155
149
  }
156
- let delegate = CameraDelegate(maxPixels: maxPixels, quality: quality, done: finish)
157
150
  let picker = UIImagePickerController()
158
151
  picker.sourceType = .camera
159
- picker.delegate = delegate
160
- activePicker = delegate
161
- if !CraftPresenter.present(picker, from: nil) {
162
- activePicker = nil
163
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
164
- }
152
+ presentCamera(picker, maxPixels: maxPixels, quality: quality, requestToken: requestToken, isCancelled: isCancelled, done: done)
165
153
  #else
166
154
  done(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "The camera was not enabled when this app was built")))
167
155
  #endif
@@ -177,13 +165,13 @@ enum CraftNativeFiles {
177
165
  configuration.selectionLimit = 1
178
166
  // The file as it is (HEIC stays HEIC): it is decoded once, below, at the size wanted.
179
167
  configuration.preferredAssetRepresentationMode = .current
180
- let delegate = LibraryDelegate(maxPixels: maxPixels, quality: quality, done: finish)
168
+ let delegate = LibraryDelegate(maxPixels: maxPixels, quality: quality, requestToken: requestToken, isCancelled: isCancelled, done: done)
181
169
  let picker = PHPickerViewController(configuration: configuration)
182
170
  picker.delegate = delegate
171
+ delegate.controller = picker
183
172
  activePicker = delegate
184
173
  if !CraftPresenter.present(picker, from: nil) {
185
- activePicker = nil
186
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the photo picker")))
174
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the photo picker")))
187
175
  }
188
176
  }
189
177
 
@@ -228,56 +216,174 @@ enum CraftNativeFiles {
228
216
  return "\(base).jpg"
229
217
  }
230
218
 
231
- private final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate {
219
+ final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate {
232
220
  let maxPixels: Int
233
221
  let quality: Double
222
+ let videoLimit: Int?
223
+ let requestToken: String?
224
+ let isCancelled: () -> Bool
234
225
  let done: (Result<Any, CraftNativeActionError>) -> Void
235
-
236
- init(maxPixels: Int, quality: Double, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
226
+ weak var controller: PHPickerViewController?
227
+ private let stateLock = NSLock()
228
+ private var cancelled = false
229
+ private var answered = false
230
+ private var finished = false
231
+ private var progress: Progress?
232
+
233
+ init(maxPixels: Int = defaultMaxPixels, quality: Double = defaultQuality, videoLimit: Int? = nil, requestToken: String?, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
237
234
  self.maxPixels = maxPixels
238
235
  self.quality = quality
236
+ self.videoLimit = videoLimit
237
+ self.requestToken = requestToken
238
+ self.isCancelled = isCancelled
239
239
  self.done = done
240
240
  }
241
241
 
242
+ private func cancellationRequested() -> Bool {
243
+ stateLock.lock()
244
+ let value = cancelled
245
+ stateLock.unlock()
246
+ return value || isCancelled()
247
+ }
248
+
249
+ func cancel() {
250
+ guard !finished else { return }
251
+ stateLock.lock()
252
+ cancelled = true
253
+ stateLock.unlock()
254
+ answered = true
255
+ progress?.cancel()
256
+ controller?.dismiss(animated: false)
257
+ // A provider may ignore Progress.cancel or never answer. Release
258
+ // the UI now, retaining our own cancellation flag after the bridge
259
+ // drains its token so a late callback cannot resurrect the import.
260
+ finish(.failure(importCancelled()))
261
+ }
262
+
263
+ func finish(_ result: Result<Any, CraftNativeActionError>) {
264
+ DispatchQueue.main.async {
265
+ let cancelled = self.cancellationRequested()
266
+ if self.finished || cancelled {
267
+ if case .success(let value) = result,
268
+ let file = value as? [String: Any], let path = file["path"] as? String {
269
+ CraftNativeFiles.remove(path)
270
+ }
271
+ }
272
+ guard !self.finished else { return }
273
+ self.finished = true
274
+ self.progress = nil
275
+ self.controller?.dismiss(animated: false)
276
+ if activePicker === self { activePicker = nil }
277
+ self.done(cancelled ? .failure(importCancelled()) : result)
278
+ }
279
+ }
280
+
242
281
  func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
282
+ guard !answered else { return }
243
283
  picker.dismiss(animated: true)
244
284
  guard let provider = results.first?.itemProvider else {
245
- done(.success(NSNull()))
285
+ answered = true
286
+ finish(.success(NSNull()))
246
287
  return
247
288
  }
248
- guard provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
249
- done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "That is not a photo")))
289
+ load(provider)
290
+ }
291
+
292
+ func load(_ provider: NSItemProvider) {
293
+ guard !answered else { return }
294
+ answered = true
295
+ guard !cancellationRequested() else { finish(.failure(importCancelled())); return }
296
+ let type = videoLimit == nil ? UTType.image.identifier : UTType.movie.identifier
297
+ guard provider.hasItemConformingToTypeIdentifier(type) else {
298
+ finish(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The selected media has an unsupported type")))
250
299
  return
251
300
  }
252
301
  let name = CraftNativeFiles.jpegName(provider.suggestedName)
253
- let (maxPixels, quality, done) = (self.maxPixels, self.quality, self.done)
254
302
  // The file is only there for the length of this callback.
255
- provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
303
+ progress = provider.loadFileRepresentation(forTypeIdentifier: type) { url, error in
304
+ guard !self.cancellationRequested() else { self.finish(.failure(importCancelled())); return }
305
+ if let limit = self.videoLimit {
306
+ guard let url else {
307
+ self.finish(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
308
+ return
309
+ }
310
+ self.finish(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: self.cancellationRequested))
311
+ return
312
+ }
256
313
  guard let url, let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
257
- done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: error?.localizedDescription ?? "The photo could not be read")))
314
+ self.finish(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: error?.localizedDescription ?? "The photo could not be read")))
258
315
  return
259
316
  }
260
- done(CraftNativeFiles.writeJPEG(from: source, maxPixels: maxPixels, quality: quality, name: name))
317
+ self.finish(CraftNativeFiles.writeJPEG(from: source, maxPixels: self.maxPixels, quality: self.quality, name: name))
261
318
  }
262
319
  }
263
320
  }
264
321
 
265
322
  #if CRAFT_PERMISSION_CAMERA
266
- private final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
323
+ /// The caller checks camera permission configuration and device availability
324
+ /// before constructing a camera picker. Keep presentation ownership here so
325
+ /// failure and cancellation use the same exactly-once settlement path.
326
+ static func presentCamera(_ picker: UIImagePickerController, maxPixels: Int, quality: Double, requestToken: String?, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
327
+ let delegate = CameraDelegate(maxPixels: maxPixels, quality: quality, requestToken: requestToken, isCancelled: isCancelled, done: done)
328
+ picker.delegate = delegate
329
+ delegate.controller = picker
330
+ activePicker = delegate
331
+ if !CraftPresenter.present(picker, from: nil) {
332
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
333
+ }
334
+ }
335
+
336
+ final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
267
337
  let maxPixels: Int
268
338
  let quality: Double
339
+ let requestToken: String?
340
+ let isCancelled: () -> Bool
269
341
  let done: (Result<Any, CraftNativeActionError>) -> Void
342
+ weak var controller: UIImagePickerController?
343
+ private var answered = false
344
+ private var finished = false
345
+ private var cancelled = false
270
346
 
271
- init(maxPixels: Int, quality: Double, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
347
+ init(maxPixels: Int, quality: Double, requestToken: String?, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
272
348
  self.maxPixels = maxPixels
273
349
  self.quality = quality
350
+ self.requestToken = requestToken
351
+ self.isCancelled = isCancelled
274
352
  self.done = done
275
353
  }
276
354
 
355
+ func cancel() {
356
+ guard !finished else { return }
357
+ cancelled = true
358
+ answered = true
359
+ controller?.dismiss(animated: false)
360
+ finish(.failure(importCancelled()))
361
+ }
362
+
363
+ func finish(_ result: Result<Any, CraftNativeActionError>) {
364
+ DispatchQueue.main.async {
365
+ let cancelled = self.cancelled || self.isCancelled()
366
+ if self.finished || cancelled {
367
+ if case .success(let value) = result,
368
+ let file = value as? [String: Any], let path = file["path"] as? String {
369
+ CraftNativeFiles.remove(path)
370
+ }
371
+ }
372
+ guard !self.finished else { return }
373
+ self.finished = true
374
+ self.controller?.dismiss(animated: false)
375
+ if activePicker === self { activePicker = nil }
376
+ self.done(cancelled ? .failure(importCancelled()) : result)
377
+ }
378
+ }
379
+
277
380
  func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
381
+ guard !answered else { return }
382
+ answered = true
383
+ guard !cancelled, !isCancelled() else { finish(.failure(importCancelled())); return }
278
384
  picker.dismiss(animated: true)
279
385
  guard let image = info[.originalImage] as? UIImage else {
280
- done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
386
+ finish(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
281
387
  return
282
388
  }
283
389
  // Drawn at the size wanted, which also turns it upright.
@@ -289,17 +395,18 @@ enum CraftNativeFiles {
289
395
  format.opaque = true
290
396
  let drawn = UIGraphicsImageRenderer(size: size, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
291
397
  guard let cgImage = drawn.cgImage else {
292
- done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
398
+ finish(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
293
399
  return
294
400
  }
295
401
  let formatter = DateFormatter()
296
402
  formatter.dateFormat = "yyyyMMdd_HHmmss"
297
- done(CraftNativeFiles.writeJPEG(cgImage, quality: quality, name: "IMG_\(formatter.string(from: Date())).jpg"))
403
+ finish(CraftNativeFiles.writeJPEG(cgImage, quality: quality, name: "IMG_\(formatter.string(from: Date())).jpg"))
298
404
  }
299
405
 
300
406
  func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
301
- picker.dismiss(animated: true)
302
- done(.success(NSNull()))
407
+ guard !answered else { return }
408
+ answered = true
409
+ finish(.success(NSNull()))
303
410
  }
304
411
  }
305
412
 
@@ -309,7 +416,7 @@ enum CraftNativeFiles {
309
416
 
310
417
  /// System pickers grant access to just the selected video. Copy it while
311
418
  /// the provider's URL is valid; no movie bytes cross JavaScriptCore.
312
- static func pickVideo(_ options: [String: Any], isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
419
+ static func pickVideo(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
313
420
  guard !isCancelled() else { done(.failure(importCancelled())); return }
314
421
  guard activePicker == nil else {
315
422
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
@@ -321,33 +428,30 @@ enum CraftNativeFiles {
321
428
  guard value.isFinite else { return nil }
322
429
  return Int(max(1, min(Double(512 * 1024 * 1024), value)))
323
430
  } ?? 512 * 1024 * 1024
324
- let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
325
- DispatchQueue.main.async { activePicker = nil; done(result) }
326
- }
327
431
  if options["source"] as? String == "files" {
328
- let delegate = VideoDocumentDelegate(limit: limit, isCancelled: isCancelled, done: finish)
432
+ let delegate = DocumentDelegate(limit: limit, video: true, requestToken: requestToken, isCancelled: isCancelled, done: done)
329
433
  var types: [UTType] = [.movie]
330
434
  if let webm = UTType(filenameExtension: "webm") { types.append(webm) }
331
435
  let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
332
436
  picker.allowsMultipleSelection = false
333
437
  picker.delegate = delegate
438
+ delegate.controller = picker
334
439
  activePicker = delegate
335
440
  if !CraftPresenter.present(picker, from: nil) {
336
- activePicker = nil
337
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
441
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
338
442
  }
339
443
  } else {
340
444
  var configuration = PHPickerConfiguration()
341
445
  configuration.filter = .videos
342
446
  configuration.selectionLimit = 1
343
447
  configuration.preferredAssetRepresentationMode = .current
344
- let delegate = VideoLibraryDelegate(limit: limit, isCancelled: isCancelled, done: finish)
448
+ let delegate = LibraryDelegate(videoLimit: limit, requestToken: requestToken, isCancelled: isCancelled, done: done)
345
449
  let picker = PHPickerViewController(configuration: configuration)
346
450
  picker.delegate = delegate
451
+ delegate.controller = picker
347
452
  activePicker = delegate
348
453
  if !CraftPresenter.present(picker, from: nil) {
349
- activePicker = nil
350
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
454
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
351
455
  }
352
456
  }
353
457
  }
@@ -357,7 +461,7 @@ enum CraftNativeFiles {
357
461
  /// Pick one or more documents and copy them into app-owned storage while
358
462
  /// the security-scoped URLs are valid. `contentTypes` accepts MIME types,
359
463
  /// UTIs, or filename extensions; an empty list means any document.
360
- static func pickFile(_ options: [String: Any], isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
464
+ static func pickFile(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
361
465
  guard !isCancelled() else { done(.failure(importCancelled())); return }
362
466
  guard activePicker == nil else {
363
467
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
@@ -382,17 +486,14 @@ enum CraftNativeFiles {
382
486
  done(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes are invalid")))
383
487
  return
384
488
  }
385
- let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
386
- DispatchQueue.main.async { activePicker = nil; done(result) }
387
- }
388
- let delegate = FileDocumentDelegate(limit: limit, isCancelled: isCancelled, done: finish)
489
+ let delegate = DocumentDelegate(limit: limit, video: false, requestToken: requestToken, isCancelled: isCancelled, done: done)
389
490
  let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
390
491
  picker.allowsMultipleSelection = selectionLimit > 1
391
492
  picker.delegate = delegate
493
+ delegate.controller = picker
392
494
  activePicker = delegate
393
495
  if !CraftPresenter.present(picker, from: nil) {
394
- activePicker = nil
395
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
496
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
396
497
  }
397
498
  }
398
499
 
@@ -531,17 +632,85 @@ enum CraftNativeFiles {
531
632
  }
532
633
  }
533
634
 
534
- private final class FileDocumentDelegate: NSObject, UIDocumentPickerDelegate {
635
+ /// Screen disposal can cancel a request while its system picker is open.
636
+ /// A stale request must never dismiss a newer request's picker.
637
+ static func cancelPicker(requestToken: String) {
638
+ DispatchQueue.main.async {
639
+ #if CRAFT_PERMISSION_CAMERA
640
+ if let picker = activePicker as? CameraDelegate, picker.requestToken == requestToken {
641
+ picker.cancel()
642
+ return
643
+ }
644
+ #endif
645
+ if let picker = activePicker as? LibraryDelegate, picker.requestToken == requestToken {
646
+ picker.cancel()
647
+ return
648
+ }
649
+ guard let picker = activePicker as? DocumentDelegate,
650
+ picker.requestToken == requestToken else { return }
651
+ picker.cancel()
652
+ }
653
+ }
654
+
655
+ final class DocumentDelegate: NSObject, UIDocumentPickerDelegate {
535
656
  let limit: Int
657
+ let video: Bool
658
+ let requestToken: String?
536
659
  let isCancelled: () -> Bool
537
660
  let done: (Result<Any, CraftNativeActionError>) -> Void
538
- init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
539
- func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success([])) }
661
+ weak var controller: UIDocumentPickerViewController?
662
+ private var answered = false
663
+ private var finished = false
664
+ private var importing = false
665
+
666
+ init(limit: Int, video: Bool, requestToken: String?, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
667
+ self.limit = limit; self.video = video; self.requestToken = requestToken
668
+ self.isCancelled = isCancelled; self.done = done
669
+ }
670
+
671
+ func cancel() {
672
+ guard !finished else { return }
673
+ answered = true
674
+ controller?.dismiss(animated: false)
675
+ // Do not drain the cancellation token while a worker still polls
676
+ // it: that would let the remainder of a cancelled copy succeed.
677
+ if !importing { finish(.failure(importCancelled())) }
678
+ }
679
+
680
+ func finish(_ result: Result<Any, CraftNativeActionError>) {
681
+ DispatchQueue.main.async {
682
+ guard !self.finished else { return }
683
+ self.finished = true
684
+ self.controller?.dismiss(animated: false)
685
+ if activePicker === self { activePicker = nil }
686
+ self.done(result)
687
+ }
688
+ }
689
+
690
+ func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
691
+ guard !answered else { return }
692
+ answered = true
693
+ finish(.success(video ? NSNull() : []))
694
+ }
695
+
540
696
  func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
541
- guard !urls.isEmpty else { done(.success([])); return }
542
- let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
697
+ guard !answered else { return }
698
+ answered = true
699
+ guard !isCancelled() else { finish(.failure(importCancelled())); return }
700
+ guard !urls.isEmpty else { finish(.success(video ? NSNull() : [])); return }
701
+ importing = true
702
+ let (limit, isCancelled, video) = (self.limit, self.isCancelled, self.video)
543
703
  DispatchQueue.global(qos: .userInitiated).async {
544
- done(CraftNativeFiles.copyFiles(urls, limit: limit, isCancelled: isCancelled))
704
+ let result: Result<Any, CraftNativeActionError>
705
+ if video {
706
+ let url = urls[0]
707
+ let granted = url.startAccessingSecurityScopedResource()
708
+ defer { if granted { url.stopAccessingSecurityScopedResource() } }
709
+ result = CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled)
710
+ } else {
711
+ result = CraftNativeFiles.copyFiles(urls, limit: limit, isCancelled: isCancelled)
712
+ }
713
+ self.finish(result)
545
714
  }
546
715
  }
547
716
  }
@@ -555,42 +724,6 @@ enum CraftNativeFiles {
555
724
  return copyImport(source, limit: limit, type: type, isCancelled: isCancelled)
556
725
  }
557
726
 
558
- private final class VideoLibraryDelegate: NSObject, PHPickerViewControllerDelegate {
559
- let limit: Int
560
- let isCancelled: () -> Bool
561
- let done: (Result<Any, CraftNativeActionError>) -> Void
562
- init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
563
- func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
564
- picker.dismiss(animated: true)
565
- guard let provider = results.first?.itemProvider else { done(.success(NSNull())); return }
566
- let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
567
- provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
568
- guard let url else {
569
- done(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
570
- return
571
- }
572
- done(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled))
573
- }
574
- }
575
- }
576
-
577
- private final class VideoDocumentDelegate: NSObject, UIDocumentPickerDelegate {
578
- let limit: Int
579
- let isCancelled: () -> Bool
580
- let done: (Result<Any, CraftNativeActionError>) -> Void
581
- init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
582
- func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success(NSNull())) }
583
- func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
584
- guard let url = urls.first else { done(.success(NSNull())); return }
585
- let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
586
- DispatchQueue.global(qos: .userInitiated).async {
587
- let granted = url.startAccessingSecurityScopedResource()
588
- defer { if granted { url.stopAccessingSecurityScopedResource() } }
589
- done(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled))
590
- }
591
- }
592
- }
593
-
594
727
  // MARK: Sending files
595
728
 
596
729
  /// A multipart/form-data body from a screen's `FormData`: parts are
@@ -7367,6 +7367,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7367
7367
  }
7368
7368
  if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") {
7369
7369
  applyNavigationBarAppearance()
7370
+ trackContentScrollView()
7370
7371
  }
7371
7372
  // Asked for after the bar was hidden for a bare root screen.
7372
7373
  if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
@@ -7589,7 +7590,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7589
7590
  /// title collapses as it scrolls, and the bar's background follows it.
7590
7591
  private func trackContentScrollView() {
7591
7592
  guard let renderedRoot else { return }
7593
+ // UIKit can resize the root before nested flex wrappers lay out.
7594
+ // Resolve their frames before checking empty siblings or edges so
7595
+ // old child frames cannot toggle the root constraints each pass.
7596
+ rootStack.layoutIfNeeded()
7592
7597
  func find(_ node: RenderedNode) -> UIScrollView? {
7598
+ // Modal content belongs to a separate presentation. Hidden
7599
+ // branches must not steal tracking from the visible screen.
7600
+ guard node.type != "Modal", !node.view.isHidden else { return nil }
7593
7601
  if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical, scroll.isScrollEnabled { return scroll }
7594
7602
  if let list = node.view as? CraftNativeFlatList, list.isScrollEnabled,
7595
7603
  (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { return list }
@@ -7606,12 +7614,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7606
7614
  return list.isScrollEnabled && (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal ? node : nil
7607
7615
  }
7608
7616
  guard paddingInsets(node.style).top == 0,
7609
- let first = node.children.first(where: { $0.type != "Modal" && !$0.view.isHidden }) else { return nil }
7617
+ let first = node.children.first(where: { $0.type != "Modal" && !$0.view.isHidden && $0.view.bounds.height > 0 }) else { return nil }
7610
7618
  return leading(first)
7611
7619
  }
7612
7620
  let scroll = find(renderedRoot)
7613
7621
  let leadingScroll = leading(renderedRoot)
7614
- // Only the screen's leading scroller draws beneath floating chrome.
7622
+ // The leading scroller may overflow beneath navigation chrome.
7615
7623
  // A bounded list inside a form must clip its rows to its viewport.
7616
7624
  if let leadingScroll, leadingScroll.style["overflow"] as? String != "hidden" {
7617
7625
  leadingScroll.view.clipsToBounds = false
@@ -7626,13 +7634,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7626
7634
  let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leadingScroll?.view)
7627
7635
  let fullEdgeToEdge = rootIsSafeAreaView
7628
7636
  let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
7629
- // Only a leading vertical scroller that reaches the root's bottom
7630
- // can paint beneath a visible tab bar. A bounded list, fixed footer,
7631
- // plain screen or presented sheet must retain its safe-area bottom.
7637
+ // Bottom clearance does not depend on the leading-scroll rule for
7638
+ // navigation titles. Follow the visible scroller through wrappers,
7639
+ // even after a header or empty sibling. Its laid-out viewport must
7640
+ // reach the root's bottom; zero-size/hidden siblings do not shorten
7641
+ // it. A bounded list, fixed footer, plain screen or presented sheet
7642
+ // must retain its safe-area bottom.
7632
7643
  // Use UIKit's automatic inset for tab/accessory clearance, rather
7633
7644
  // than adding a second explicit inset (also owned by keyboard code).
7634
7645
  var tabbedScroll = false
7635
- if let scroll, scroll === leadingScroll?.view, scroll.bounds.height > 0,
7646
+ if let scroll, scroll.bounds.height > 0,
7636
7647
  let tabBar = tabBarController?.tabBar, !tabBar.isHidden, tabBar.alpha > 0,
7637
7648
  let window = view.window, tabBar.window === window,
7638
7649
  tabBar.convert(tabBar.bounds, to: view).intersects(view.bounds) {
@@ -7653,12 +7664,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7653
7664
  rootLeadingToView?.isActive = fullEdgeToEdge
7654
7665
  rootTrailingToSafeArea?.isActive = !fullEdgeToEdge
7655
7666
  rootTrailingToView?.isActive = fullEdgeToEdge
7656
- scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never
7657
- guard scroll !== trackedScrollView else { return }
7658
- trackedScrollView = scroll
7659
- // The same primary vertical scroller drives the navigation bar and
7660
- // the system tab bar's scroll-edge/minimization behavior.
7661
- setContentScrollView(scroll, for: [.top, .bottom])
7667
+ scroll?.contentInsetAdjustmentBehavior = (edgeToEdge || tabbedScroll) ? .automatic : .never
7668
+ if scroll !== trackedScrollView {
7669
+ #if compiler(>=6.2)
7670
+ if #available(iOS 26.0, *) {
7671
+ trackedScrollView?.topEdgeEffect.isHidden = false
7672
+ trackedScrollView?.bottomEdgeEffect.isHidden = false
7673
+ }
7674
+ #endif
7675
+ trackedScrollView = scroll
7676
+ // Keep UIKit's title collapse, tab selection and minimization
7677
+ // tracking; only the full-width edge backdrop changes below.
7678
+ setContentScrollView(scroll, for: [.top, .bottom])
7679
+ }
7680
+ #if compiler(>=6.2)
7681
+ if #available(iOS 26.0, *) {
7682
+ // Floating button groups carry their own material. UIKit's
7683
+ // separate scroll-edge wash would cover the content behind
7684
+ // and beyond them even with a transparent bar appearance.
7685
+ scroll?.topEdgeEffect.isHidden = topEdgeToEdge && !navigationBarSolid
7686
+ scroll?.bottomEdgeEffect.isHidden = tabbedScroll
7687
+ }
7688
+ #endif
7662
7689
  }
7663
7690
 
7664
7691
  private func observeNativeEvents() {