craft-native 0.0.499 → 0.0.500

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.
@@ -85,6 +85,84 @@ final class CraftNotificationAttachments {
85
85
  // memory. `fetch` and the share sheet take the path; only files made here are
86
86
  // read back for a form, so a screen cannot post an arbitrary file of the app's.
87
87
 
88
+ /// A request waiting on a system picker, as the one picker slot sees it.
89
+ protocol CraftPickerRequest: AnyObject {
90
+ /// Where its picker is.
91
+ var presence: CraftPickerPresence { get }
92
+ /// It has neither been answered by its picker nor finished.
93
+ var isWaiting: Bool { get }
94
+ /// The picker went without a choice or a cancel reaching its delegate.
95
+ /// One that was on screen answers as a cancel; one that never appeared
96
+ /// answers that it could not be presented.
97
+ func pickerLeft(shown: Bool)
98
+ }
99
+
100
+ extension CraftPickerRequest {
101
+ /// Still waiting on a picker that can no longer answer.
102
+ var lostItsPicker: Bool { isWaiting && presence.lost }
103
+ }
104
+
105
+ /// Where a request's picker is: on its way to the screen, on it, or gone.
106
+ ///
107
+ /// UIKit tells a picker's delegate about a choice and about a cancel. It
108
+ /// says nothing when the picker goes because what presented it did (an app
109
+ /// closing the sheet the picker was on), or when it gives a presentation up
110
+ /// before it began: a system picker runs in another process and is presented
111
+ /// once that process answers, and by then the sheet it was to appear on may
112
+ /// have closed ("whose view is not in the window hierarchy", in the console
113
+ /// only). The request then never answered and kept the one picker slot:
114
+ /// every later pick was refused as BUSY until the app was restarted.
115
+ final class CraftPickerPresence {
116
+ weak var picker: UIViewController?
117
+ /// What the picker was handed to for presenting.
118
+ weak var presenter: UIViewController?
119
+ weak var watch: CraftPickerWatch?
120
+
121
+ /// The picker has been on screen.
122
+ var shown: Bool { watch?.shown ?? false }
123
+
124
+ /// The picker cannot answer any more.
125
+ var lost: Bool {
126
+ guard let picker else { return true }
127
+ if picker.presentingViewController != nil { return false }
128
+ // It was on screen, is not now, and nothing presents it.
129
+ if shown { return picker.viewIfLoaded?.window == nil }
130
+ // It has not appeared yet, and what was to present it has gone.
131
+ guard let presenter else { return true }
132
+ return presenter.viewIfLoaded?.window == nil
133
+ }
134
+ }
135
+
136
+ /// A view inside a system picker that says when the picker has been seen
137
+ /// and when it is gone: it leaves the window when the picker does and is
138
+ /// released when the picker is, whichever way that happens.
139
+ final class CraftPickerWatch: UIView {
140
+ private(set) var shown = false
141
+ private var left: ((_ shown: Bool) -> Void)?
142
+
143
+ static func attach(to picker: UIViewController, left: @escaping (_ shown: Bool) -> Void) -> CraftPickerWatch {
144
+ let watch = CraftPickerWatch(frame: .zero)
145
+ watch.isHidden = true
146
+ watch.isUserInteractionEnabled = false
147
+ watch.left = left
148
+ picker.view.addSubview(watch)
149
+ return watch
150
+ }
151
+
152
+ override func didMoveToWindow() {
153
+ super.didMoveToWindow()
154
+ if window != nil { shown = true } else if shown { report() }
155
+ }
156
+
157
+ deinit { report() }
158
+
159
+ private func report() {
160
+ guard let left else { return }
161
+ let shown = shown
162
+ DispatchQueue.main.async { left(shown) }
163
+ }
164
+ }
165
+
88
166
  enum CraftNativeFiles {
89
167
  /// The longest side of a picked photo unless the screen asks otherwise:
90
168
  /// sharp on any phone screen and in a share card, a few hundred kilobytes
@@ -104,6 +182,40 @@ enum CraftNativeFiles {
104
182
  /// The picker on screen, kept alive until it answers. One at a time.
105
183
  private static var activePicker: NSObject?
106
184
 
185
+ /// The slot is free when nothing holds it, or when what holds it is
186
+ /// waiting on a picker that can no longer answer.
187
+ private static func pickerSlotIsFree() -> Bool {
188
+ guard let request = activePicker as? CraftPickerRequest else { return activePicker == nil }
189
+ guard request.lostItsPicker else { return false }
190
+ activePicker = nil
191
+ request.pickerLeft(shown: request.presence.shown)
192
+ return true
193
+ }
194
+
195
+ /// Follow `picker` for the request that is waiting on it.
196
+ private static func watch(_ picker: UIViewController, for request: CraftPickerRequest) {
197
+ let presence = request.presence
198
+ presence.picker = picker
199
+ presence.presenter = CraftPresenter.topViewController()
200
+ presence.watch = CraftPickerWatch.attach(to: picker) { [weak request] shown in request?.pickerLeft(shown: shown) }
201
+ awaitAppearance(of: request, checks: 60)
202
+ }
203
+
204
+ /// Until the picker is on screen nothing of its own can say it never
205
+ /// will be, and UIKit may hold a presentation it cannot make for as long
206
+ /// as the picker's process takes to answer. Look twice a second, for half
207
+ /// a minute at most, and answer for a picker whose presenter has gone.
208
+ private static func awaitAppearance(of request: CraftPickerRequest, checks: Int) {
209
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak request] in
210
+ guard let request, request.isWaiting, !request.presence.shown else { return }
211
+ if request.presence.lost {
212
+ request.pickerLeft(shown: false)
213
+ } else if checks > 1 {
214
+ awaitAppearance(of: request, checks: checks - 1)
215
+ }
216
+ }
217
+ }
218
+
107
219
  // MARK: Files of our own
108
220
 
109
221
  /// A new file called `name`, in a folder of its own so two files can share
@@ -208,7 +320,7 @@ enum CraftNativeFiles {
208
320
  .map { $0.doubleValue }
209
321
  .flatMap { $0.isFinite ? $0 : nil } ?? defaultQuality))
210
322
  let source = options["source"] as? String ?? "library"
211
- guard activePicker == nil else {
323
+ guard pickerSlotIsFree() else {
212
324
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
213
325
  return
214
326
  }
@@ -245,6 +357,7 @@ enum CraftNativeFiles {
245
357
  picker.delegate = delegate
246
358
  delegate.controller = picker
247
359
  activePicker = delegate
360
+ watch(picker, for: delegate)
248
361
  if !CraftPresenter.present(picker, from: nil) {
249
362
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the photo picker")))
250
363
  }
@@ -291,7 +404,7 @@ enum CraftNativeFiles {
291
404
  return "\(base).jpg"
292
405
  }
293
406
 
294
- final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate {
407
+ final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate, CraftPickerRequest {
295
408
  let maxPixels: Int
296
409
  let quality: Double
297
410
  let videoLimit: Int?
@@ -353,8 +466,18 @@ enum CraftNativeFiles {
353
466
  }
354
467
  }
355
468
 
469
+ let presence = CraftPickerPresence()
470
+ var isWaiting: Bool { !answered && !finished }
471
+
472
+ func pickerLeft(shown: Bool) {
473
+ guard lostItsPicker else { return }
474
+ answered = true
475
+ finish(shown ? .success(NSNull()) : .failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the \(videoLimit == nil ? "photo" : "video") picker")))
476
+ }
477
+
356
478
  func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
357
- guard !answered else { return }
479
+ // A picker whose request was already answered still closes.
480
+ guard !answered else { picker.dismiss(animated: true); return }
358
481
  picker.dismiss(animated: true)
359
482
  guard let provider = results.first?.itemProvider else {
360
483
  answered = true
@@ -403,12 +526,13 @@ enum CraftNativeFiles {
403
526
  picker.delegate = delegate
404
527
  delegate.controller = picker
405
528
  activePicker = delegate
529
+ watch(picker, for: delegate)
406
530
  if !CraftPresenter.present(picker, from: nil) {
407
531
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
408
532
  }
409
533
  }
410
534
 
411
- final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
535
+ final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate, CraftPickerRequest {
412
536
  let maxPixels: Int
413
537
  let quality: Double
414
538
  let requestToken: String?
@@ -483,6 +607,15 @@ enum CraftNativeFiles {
483
607
  answered = true
484
608
  finish(.success(NSNull()))
485
609
  }
610
+
611
+ let presence = CraftPickerPresence()
612
+ var isWaiting: Bool { !answered && !finished }
613
+
614
+ func pickerLeft(shown: Bool) {
615
+ guard lostItsPicker else { return }
616
+ answered = true
617
+ finish(shown ? .success(NSNull()) : .failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
618
+ }
486
619
  }
487
620
 
488
621
  #endif
@@ -493,7 +626,7 @@ enum CraftNativeFiles {
493
626
  /// the provider's URL is valid; no movie bytes cross JavaScriptCore.
494
627
  static func pickVideo(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
495
628
  guard !isCancelled() else { done(.failure(importCancelled())); return }
496
- guard activePicker == nil else {
629
+ guard pickerSlotIsFree() else {
497
630
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
498
631
  return
499
632
  }
@@ -512,6 +645,7 @@ enum CraftNativeFiles {
512
645
  picker.delegate = delegate
513
646
  delegate.controller = picker
514
647
  activePicker = delegate
648
+ watch(picker, for: delegate)
515
649
  if !CraftPresenter.present(picker, from: nil) {
516
650
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
517
651
  }
@@ -525,6 +659,7 @@ enum CraftNativeFiles {
525
659
  picker.delegate = delegate
526
660
  delegate.controller = picker
527
661
  activePicker = delegate
662
+ watch(picker, for: delegate)
528
663
  if !CraftPresenter.present(picker, from: nil) {
529
664
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
530
665
  }
@@ -538,7 +673,7 @@ enum CraftNativeFiles {
538
673
  /// UTIs, or filename extensions; an empty list means any document.
539
674
  static func pickFile(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
540
675
  guard !isCancelled() else { done(.failure(importCancelled())); return }
541
- guard activePicker == nil else {
676
+ guard pickerSlotIsFree() else {
542
677
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
543
678
  return
544
679
  }
@@ -567,6 +702,7 @@ enum CraftNativeFiles {
567
702
  picker.delegate = delegate
568
703
  delegate.controller = picker
569
704
  activePicker = delegate
705
+ watch(picker, for: delegate)
570
706
  if !CraftPresenter.present(picker, from: nil) {
571
707
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
572
708
  }
@@ -727,7 +863,7 @@ enum CraftNativeFiles {
727
863
  }
728
864
  }
729
865
 
730
- final class DocumentDelegate: NSObject, UIDocumentPickerDelegate {
866
+ final class DocumentDelegate: NSObject, UIDocumentPickerDelegate, CraftPickerRequest {
731
867
  let limit: Int
732
868
  let video: Bool
733
869
  let requestToken: String?
@@ -768,6 +904,15 @@ enum CraftNativeFiles {
768
904
  finish(.success(video ? NSNull() : []))
769
905
  }
770
906
 
907
+ let presence = CraftPickerPresence()
908
+ var isWaiting: Bool { !answered && !finished }
909
+
910
+ func pickerLeft(shown: Bool) {
911
+ guard lostItsPicker else { return }
912
+ answered = true
913
+ finish(shown ? .success(video ? NSNull() : []) : .failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the \(video ? "video" : "file") picker")))
914
+ }
915
+
771
916
  func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
772
917
  guard !answered else { return }
773
918
  answered = true
@@ -316,7 +316,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
316
316
  rows += (pending + columns - 1) / columns
317
317
  content = CGFloat(rows) * 44 + CGFloat(max(0, rows - 1)) * flowLayout.minimumLineSpacing
318
318
  }
319
- return max(0, ceil(content + contentInset.top + contentInset.bottom))
319
+ // Its rows and its own padding: not the room left for what floats over it.
320
+ return max(0, ceil(content + contentPadding.top + contentPadding.bottom))
320
321
  }
321
322
 
322
323
  /// The width a row gets: the list's own width inside its content padding,
@@ -406,6 +407,24 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
406
407
  bottom: nonNegative(raw?["paddingBottom"]) ?? vertical,
407
408
  right: nonNegative(raw?["paddingRight"]) ?? horizontal
408
409
  )
410
+ guard contentPadding != inset else { return }
411
+ contentPadding = inset
412
+ applyContentInsets()
413
+ }
414
+
415
+ /// `contentContainerStyle`'s padding, which the list draws as its insets.
416
+ private var contentPadding: UIEdgeInsets = .zero
417
+
418
+ /// How far the compact tab actions reach over the end of this list. The
419
+ /// rows get that much more room at the end, so the last one can be
420
+ /// brought clear of them (#523).
421
+ var floatingCover: CGFloat = 0 {
422
+ didSet { if abs(oldValue - floatingCover) > 0.5 { applyContentInsets() } }
423
+ }
424
+
425
+ private func applyContentInsets() {
426
+ var inset = contentPadding
427
+ if flowLayout.scrollDirection == .vertical { inset.bottom += floatingCover }
409
428
  guard contentInset != inset else { return }
410
429
  contentInset = inset
411
430
  scrollIndicatorInsets = inset
@@ -507,11 +526,10 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
507
526
  let oldItems = itemsByIdentity
508
527
  let viewportAnchor = currentViewportAnchor()
509
528
  var seen: [String: Int] = [:]
529
+ var taken = Set<String>()
510
530
  var next = nodes.enumerated().map { index, node -> Item in
511
531
  let base = Self.identity(for: node) ?? "index:\(index)"
512
- let occurrence = seen[base, default: 0]
513
- seen[base] = occurrence + 1
514
- let identity = occurrence == 0 ? base : "\(base)#\(occurrence)"
532
+ let identity = Self.uniqueIdentity(base, seen: &seen, taken: &taken)
515
533
  let props = node["props"] as? [String: Any] ?? [:]
516
534
  let role = props["listRole"] as? String
517
535
  let signature = (try? JSONSerialization.data(withJSONObject: node, options: [.sortedKeys])) ?? Data()
@@ -532,7 +550,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
532
550
  return dataItemCount - 1
533
551
  }
534
552
  items = next
535
- itemsByIdentity = Dictionary(uniqueKeysWithValues: next.map { ($0.identity, $0) })
553
+ itemsByIdentity = Dictionary(next.map { ($0.identity, $0) }, uniquingKeysWith: { first, _ in first })
536
554
 
537
555
  flowLayout.scrollDirection = horizontal ? .horizontal : .vertical
538
556
  alwaysBounceHorizontal = horizontal
@@ -680,7 +698,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
680
698
  let lastDataOrdinal = dataOrdinals[lastVisible]
681
699
  guard lastDataOrdinal >= 0 else { return }
682
700
  let remaining = dataItemCount - lastDataOrdinal - 1
683
- let thresholdItems = max(1, Int(ceil(Double(dataItemCount) * endReachedThreshold)))
701
+ let thresholdItems = max(1, craftNativeInt(ceil(Double(dataItemCount) * endReachedThreshold)))
684
702
  guard remaining <= thresholdItems else { return }
685
703
  let signature = dataContentSignature
686
704
  guard endReachedSignature != signature else { return }
@@ -864,6 +882,25 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
864
882
  setContentOffset(clamped(offset), animated: false)
865
883
  }
866
884
 
885
+ /// A row's identity, its own in this list.
886
+ ///
887
+ /// Rows that share a key are told apart by a suffix: `run`, then `run#1`.
888
+ /// A row whose own key was `run#1` then had the identity of the second
889
+ /// `run`, and the list's table and its snapshot both trap on a repeated
890
+ /// identity: three tags keyed `run`, `run` and `run#1` ended the process.
891
+ /// The suffix counts on until the identity is one no row has taken. A
892
+ /// list with no such key has the identities it always had.
893
+ static func uniqueIdentity(_ base: String, seen: inout [String: Int], taken: inout Set<String>) -> String {
894
+ var occurrence = seen[base, default: 0]
895
+ var identity = occurrence == 0 ? base : "\(base)#\(occurrence)"
896
+ while !taken.insert(identity).inserted {
897
+ occurrence += 1
898
+ identity = "\(base)#\(occurrence)"
899
+ }
900
+ seen[base] = occurrence + 1
901
+ return identity
902
+ }
903
+
867
904
  private static func identity(for node: [String: Any]) -> String? {
868
905
  let props = node["props"] as? [String: Any] ?? [:]
869
906
  return [node["key"], props["key"], props["testID"], node["id"]]