craft-native 0.0.122 → 0.0.124

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.
@@ -44,6 +44,10 @@ enum CraftNativeActions {
44
44
  guard config.enableHaptics else { return failure("CAPABILITY_DISABLED", "Haptics is disabled") }
45
45
  triggerHaptic(style: body["style"] as? String ?? "medium")
46
46
  return .success(true)
47
+ case "hapticPrepare":
48
+ guard config.enableHaptics else { return failure("CAPABILITY_DISABLED", "Haptics is disabled") }
49
+ CraftHaptics.prepare(body["kind"] as? String)
50
+ return .success(true)
47
51
  case "clipboardWrite":
48
52
  guard config.enableClipboard else { return failure("CAPABILITY_DISABLED", "Clipboard is disabled") }
49
53
  guard let text = body["text"] as? String else {
@@ -82,6 +86,12 @@ enum CraftNativeActions {
82
86
  sync = perform(action: "getDeviceInfo", body: [:], config: config)
83
87
  case ("Haptics", "impact"):
84
88
  sync = perform(action: "haptic", body: ["style": args.first ?? NSNull()], config: config)
89
+ case ("Haptics", "notification"):
90
+ sync = perform(action: "haptic", body: ["style": (args.first as? String) ?? "success"], config: config)
91
+ case ("Haptics", "selection"):
92
+ sync = perform(action: "haptic", body: ["style": "selection"], config: config)
93
+ case ("Haptics", "prepare"):
94
+ sync = perform(action: "hapticPrepare", body: ["kind": args.first ?? NSNull()], config: config)
85
95
  case ("Clipboard", "write"):
86
96
  sync = perform(action: "clipboardWrite", body: ["text": args.first ?? NSNull()], config: config)
87
97
  case ("Clipboard", "read"):
@@ -460,15 +470,7 @@ enum CraftNativeActions {
460
470
  }
461
471
 
462
472
  static func triggerHaptic(style: String) {
463
- switch style {
464
- case "light": UIImpactFeedbackGenerator(style: .light).impactOccurred()
465
- case "heavy": UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
466
- case "success": UINotificationFeedbackGenerator().notificationOccurred(.success)
467
- case "warning": UINotificationFeedbackGenerator().notificationOccurred(.warning)
468
- case "error": UINotificationFeedbackGenerator().notificationOccurred(.error)
469
- case "selection": UISelectionFeedbackGenerator().selectionChanged()
470
- default: UIImpactFeedbackGenerator(style: .medium).impactOccurred()
471
- }
473
+ CraftHaptics.play(style)
472
474
  }
473
475
 
474
476
  static func currentAppState() -> String {
@@ -532,3 +534,89 @@ enum CraftNativeActions {
532
534
  ]
533
535
  }
534
536
  }
537
+
538
+ /// The Taptic Engine's generators, held for the life of the process.
539
+ ///
540
+ /// Each haptic used to make a fresh generator at the moment of the tap. A new
541
+ /// generator has to wake the engine before it can play, which is the tens of
542
+ /// milliseconds between a finger landing and the feel that makes web haptics
543
+ /// read as late. These are made once, prepared again after every use, and
544
+ /// `prepare` lets a page warm one before a gesture it knows is coming (the
545
+ /// start of a drag, a long press about to complete).
546
+ ///
547
+ /// The three kinds are the three UIKit has, and they are not interchangeable:
548
+ /// a notification is the success, warning or error pattern, a selection is the
549
+ /// detent a picker plays, and an impact is a single tap at one of five weights.
550
+ /// The page used to get impacts for all of them.
551
+ ///
552
+ /// Main thread only, like every UIFeedbackGenerator. Every caller is.
553
+ enum CraftHaptics {
554
+ private static var impacts: [UIImpactFeedbackGenerator.FeedbackStyle: UIImpactFeedbackGenerator] = [:]
555
+ private static var notifier: UINotificationFeedbackGenerator?
556
+ private static var selector: UISelectionFeedbackGenerator?
557
+
558
+ /// An impact weight by name, or nil for a name that is not one.
559
+ static func impactStyle(_ name: String) -> UIImpactFeedbackGenerator.FeedbackStyle? {
560
+ switch name {
561
+ case "light": return .light
562
+ case "medium": return .medium
563
+ case "heavy": return .heavy
564
+ case "soft": return .soft
565
+ case "rigid": return .rigid
566
+ default: return nil
567
+ }
568
+ }
569
+
570
+ /// Play one haptic: an impact weight, a notification type, or `selection`.
571
+ /// Anything else plays a medium impact, as an unknown style always has.
572
+ static func play(_ style: String) {
573
+ switch style {
574
+ case "success": notification().notificationOccurred(.success); notification().prepare()
575
+ case "warning": notification().notificationOccurred(.warning); notification().prepare()
576
+ case "error": notification().notificationOccurred(.error); notification().prepare()
577
+ case "selection": selection().selectionChanged(); selection().prepare()
578
+ default:
579
+ let generator = impact(impactStyle(style) ?? .medium)
580
+ generator.impactOccurred()
581
+ generator.prepare()
582
+ }
583
+ }
584
+
585
+ /// Warm a generator so the next haptic plays without the engine's wake-up
586
+ /// delay. `impact`, `notification` or `selection` for one kind, an impact
587
+ /// weight or notification type for that one, nothing for all of them.
588
+ /// UIKit keeps a prepared engine awake for a few seconds only.
589
+ static func prepare(_ kind: String?) {
590
+ switch kind {
591
+ case "impact": impact(.medium).prepare()
592
+ case "notification", "success", "warning", "error": notification().prepare()
593
+ case "selection": selection().prepare()
594
+ case let name? where impactStyle(name) != nil: impact(impactStyle(name)!).prepare()
595
+ default:
596
+ impact(.medium).prepare()
597
+ notification().prepare()
598
+ selection().prepare()
599
+ }
600
+ }
601
+
602
+ private static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) -> UIImpactFeedbackGenerator {
603
+ if let generator = impacts[style] { return generator }
604
+ let generator = UIImpactFeedbackGenerator(style: style)
605
+ impacts[style] = generator
606
+ return generator
607
+ }
608
+
609
+ private static func notification() -> UINotificationFeedbackGenerator {
610
+ if let notifier { return notifier }
611
+ let generator = UINotificationFeedbackGenerator()
612
+ notifier = generator
613
+ return generator
614
+ }
615
+
616
+ private static func selection() -> UISelectionFeedbackGenerator {
617
+ if let selector { return selector }
618
+ let generator = UISelectionFeedbackGenerator()
619
+ selector = generator
620
+ return generator
621
+ }
622
+ }
@@ -56,13 +56,32 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
56
56
  ) -> UICollectionViewLayoutAttributes {
57
57
  guard let hostedView = hostedView else { return layoutAttributes }
58
58
  let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
59
- let target = CGSize(width: layoutAttributes.size.width, height: UIView.layoutFittingCompressedSize.height)
60
- let measured = hostedView.systemLayoutSizeFitting(
61
- target,
62
- withHorizontalFittingPriority: .required,
63
- verticalFittingPriority: .fittingSizeLevel
64
- )
65
- attributes.size.height = max(1, measured.height)
59
+ // A cell has no collectionView property; the list is its nearest
60
+ // collection view ancestor.
61
+ var ancestor = superview
62
+ while let view = ancestor, !(view is UICollectionView) { ancestor = view.superview }
63
+ if let collectionView = ancestor as? UICollectionView,
64
+ let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout,
65
+ flowLayout.scrollDirection == .horizontal {
66
+ let target = CGSize(
67
+ width: UIView.layoutFittingCompressedSize.width,
68
+ height: layoutAttributes.size.height
69
+ )
70
+ let measured = hostedView.systemLayoutSizeFitting(
71
+ target,
72
+ withHorizontalFittingPriority: .fittingSizeLevel,
73
+ verticalFittingPriority: .required
74
+ )
75
+ attributes.size.width = max(1, measured.width)
76
+ } else {
77
+ let target = CGSize(width: layoutAttributes.size.width, height: UIView.layoutFittingCompressedSize.height)
78
+ let measured = hostedView.systemLayoutSizeFitting(
79
+ target,
80
+ withHorizontalFittingPriority: .required,
81
+ verticalFittingPriority: .fittingSizeLevel
82
+ )
83
+ attributes.size.height = max(1, measured.height)
84
+ }
66
85
  return attributes
67
86
  }
68
87
  }
@@ -86,11 +105,20 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
86
105
  private var renderItem: RenderItem?
87
106
  private var recycleItem: ((String) -> Void)?
88
107
  private var endReached: (() -> Void)?
108
+ var onScrollEvent: ((UIScrollView) -> Void)?
109
+ /// Called as the visible rows move, for a screen that lays out around them.
110
+ var onLayoutChanged: (() -> Void)?
111
+ var onScrollBeginDrag: ((UIScrollView) -> Void)?
112
+ var onScrollEndDrag: ((UIScrollView) -> Void)?
113
+ var onMomentumScrollBegin: ((UIScrollView) -> Void)?
114
+ var onMomentumScrollEnd: ((UIScrollView) -> Void)?
89
115
  private var endReachedThreshold = 0.1
90
116
  private var endReachedSignature: String?
117
+ private var dataContentSignature = ""
91
118
  private var columns = 1
92
119
  private var isApplyingSnapshot = false
93
120
  private var pendingApply: (() -> Void)?
121
+ private var pendingThemeRefresh = false
94
122
 
95
123
  init() {
96
124
  let layout = CraftNativeFlatListLayout()
@@ -220,7 +248,11 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
220
248
  }
221
249
 
222
250
  let dataItems = next.filter { !$0.isChrome }
223
- let contentSignature = "\(dataItems.count):\(dataItems.last?.identity ?? "empty")"
251
+ let contentSignature = dataItems.map {
252
+ let encoded = $0.signature.base64EncodedString()
253
+ return "\($0.identity.utf8.count):\($0.identity)\(encoded.utf8.count):\(encoded)"
254
+ }.joined()
255
+ dataContentSignature = contentSignature
224
256
  if contentSignature != endReachedSignature { endReachedSignature = nil }
225
257
  }
226
258
 
@@ -232,9 +264,53 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
232
264
  renderItem = nil
233
265
  recycleItem = nil
234
266
  endReached = nil
267
+ dataContentSignature = ""
268
+ onScrollEvent = nil
269
+ onLayoutChanged = nil
270
+ onScrollBeginDrag = nil
271
+ onScrollEndDrag = nil
272
+ onMomentumScrollBegin = nil
273
+ onMomentumScrollEnd = nil
274
+ pendingThemeRefresh = false
275
+ }
276
+
277
+ func refreshThemeDefaults() {
278
+ if isApplyingSnapshot {
279
+ pendingThemeRefresh = true
280
+ return
281
+ }
282
+ var snapshot = diffableDataSource.snapshot()
283
+ let identities = snapshot.itemIdentifiers
284
+ guard !identities.isEmpty else { return }
285
+ pendingThemeRefresh = false
286
+ snapshot.reconfigureItems(identities)
287
+ isApplyingSnapshot = true
288
+ diffableDataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
289
+ self?.finishApplyingSnapshot()
290
+ }
291
+ }
292
+
293
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
294
+ onScrollEvent?(scrollView)
295
+ onLayoutChanged?()
296
+ evaluateEndReached()
297
+ }
298
+
299
+ func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
300
+ onScrollBeginDrag?(scrollView)
301
+ }
302
+
303
+ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
304
+ onScrollEndDrag?(scrollView)
305
+ }
306
+
307
+ func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
308
+ onMomentumScrollBegin?(scrollView)
235
309
  }
236
310
 
237
- func scrollViewDidScroll(_ scrollView: UIScrollView) { evaluateEndReached() }
311
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
312
+ onMomentumScrollEnd?(scrollView)
313
+ }
238
314
 
239
315
  func collectionView(
240
316
  _ collectionView: UICollectionView,
@@ -244,7 +320,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
244
320
  guard items.indices.contains(indexPath.item) else { return CGSize(width: bounds.width, height: 44) }
245
321
  let item = items[indexPath.item]
246
322
  if flowLayout.scrollDirection == .horizontal {
247
- return CGSize(width: max(1, bounds.width * 0.8), height: max(1, bounds.height))
323
+ return CGSize(width: 44, height: max(1, bounds.height))
248
324
  }
249
325
  let count = item.isChrome ? 1 : columns
250
326
  let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
@@ -261,8 +337,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
261
337
  let remaining = dataIndices.count - lastDataOrdinal - 1
262
338
  let thresholdItems = max(1, Int(ceil(Double(dataIndices.count) * endReachedThreshold)))
263
339
  guard remaining <= thresholdItems else { return }
264
- let lastIdentity = items[dataIndices[dataIndices.count - 1]].identity
265
- let signature = "\(dataIndices.count):\(lastIdentity)"
340
+ let signature = dataContentSignature
266
341
  guard endReachedSignature != signature else { return }
267
342
  endReachedSignature = signature
268
343
  endReached()
@@ -277,6 +352,11 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
277
352
  pendingApply()
278
353
  return
279
354
  }
355
+ if self.pendingThemeRefresh {
356
+ self.pendingThemeRefresh = false
357
+ self.refreshThemeDefaults()
358
+ return
359
+ }
280
360
  self.evaluateEndReached()
281
361
  }
282
362
  }