craft-native 0.0.123 → 0.0.125

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.
@@ -9,10 +9,10 @@ private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
9
9
  }
10
10
  }
11
11
 
12
- /// A keyed, recycling native list used by the stx-native FlatList primitive.
12
+ /// A keyed, recycling native list used by stx's native FlatList primitive.
13
13
  /// The screen controller owns rendered row state; this view owns collection
14
14
  /// diffs, viewport state, and UICollectionView cell reuse.
15
- final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout {
15
+ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout, UIGestureRecognizerDelegate {
16
16
  typealias RenderItem = (_ node: [String: Any], _ identity: String, _ previous: UIView?) -> UIView
17
17
 
18
18
  private final class Cell: UICollectionViewCell {
@@ -21,6 +21,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
21
21
  private(set) var representedIdentity: String?
22
22
  private(set) var hostedView: UIView?
23
23
  var onRecycle: ((String) -> Void)?
24
+ /// The width (or, sideways, height) the list gives this row, so a row
25
+ /// is measured at the list's width rather than the layout's estimate.
26
+ var fittingExtent: CGFloat?
24
27
 
25
28
  override func prepareForReuse() {
26
29
  super.prepareForReuse()
@@ -56,13 +59,33 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
56
59
  ) -> UICollectionViewLayoutAttributes {
57
60
  guard let hostedView = hostedView else { return layoutAttributes }
58
61
  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)
62
+ // A cell has no collectionView property; the list is its nearest
63
+ // collection view ancestor.
64
+ var ancestor = superview
65
+ while let view = ancestor, !(view is UICollectionView) { ancestor = view.superview }
66
+ if let collectionView = ancestor as? UICollectionView,
67
+ let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout,
68
+ flowLayout.scrollDirection == .horizontal {
69
+ let height = fittingExtent ?? layoutAttributes.size.height
70
+ let measured = CraftNativeFlexLayout.measure(
71
+ hostedView,
72
+ width: nil,
73
+ height: height,
74
+ maxWidth: CraftNativeFlexLayout.unbounded,
75
+ maxHeight: height
76
+ )
77
+ attributes.size = CGSize(width: max(1, ceil(measured.width)), height: max(1, height))
78
+ } else {
79
+ let width = fittingExtent ?? layoutAttributes.size.width
80
+ let measured = CraftNativeFlexLayout.measure(
81
+ hostedView,
82
+ width: width,
83
+ height: nil,
84
+ maxWidth: width,
85
+ maxHeight: CraftNativeFlexLayout.unbounded
86
+ )
87
+ attributes.size = CGSize(width: max(1, width), height: max(1, ceil(measured.height)))
88
+ }
66
89
  return attributes
67
90
  }
68
91
  }
@@ -86,11 +109,24 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
86
109
  private var renderItem: RenderItem?
87
110
  private var recycleItem: ((String) -> Void)?
88
111
  private var endReached: (() -> Void)?
112
+ var onScrollEvent: ((UIScrollView) -> Void)?
113
+ /// Called as the visible rows move, for a screen that lays out around them.
114
+ var onLayoutChanged: (() -> Void)?
115
+ var onScrollBeginDrag: ((UIScrollView) -> Void)?
116
+ var onScrollEndDrag: ((UIScrollView) -> Void)?
117
+ var onMomentumScrollBegin: ((UIScrollView) -> Void)?
118
+ var onMomentumScrollEnd: ((UIScrollView) -> Void)?
89
119
  private var endReachedThreshold = 0.1
90
120
  private var endReachedSignature: String?
121
+ private var dataContentSignature = ""
91
122
  private var columns = 1
92
123
  private var isApplyingSnapshot = false
93
124
  private var pendingApply: (() -> Void)?
125
+ private var pendingThemeRefresh = false
126
+ private let nativeRefreshControl = UIRefreshControl()
127
+ private var refreshHandler: (() -> Void)?
128
+ private let keyboardTapGesture = UITapGestureRecognizer()
129
+ private var keyboardShouldPersistTaps = "never"
94
130
 
95
131
  init() {
96
132
  let layout = CraftNativeFlatListLayout()
@@ -102,7 +138,12 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
102
138
  backgroundColor = .clear
103
139
  alwaysBounceVertical = true
104
140
  delegate = self
141
+ keyboardTapGesture.cancelsTouchesInView = false
142
+ keyboardTapGesture.delegate = self
143
+ keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
144
+ addGestureRecognizer(keyboardTapGesture)
105
145
  register(Cell.self, forCellWithReuseIdentifier: Cell.reuseIdentifier)
146
+ nativeRefreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
106
147
  diffableDataSource = UICollectionViewDiffableDataSource<Int, String>(collectionView: self) {
107
148
  [weak self] collectionView, indexPath, identity in
108
149
  guard let self = self,
@@ -114,6 +155,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
114
155
  let renderItem = self.renderItem else { return UICollectionViewCell() }
115
156
  let view = renderItem(item.node, identity, cell.hostedView)
116
157
  cell.onRecycle = self.recycleItem
158
+ cell.fittingExtent = self.itemExtent(isChrome: item.isChrome)
117
159
  cell.host(view, identity: identity)
118
160
  return cell
119
161
  }
@@ -121,6 +163,123 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
121
163
 
122
164
  required init?(coder: NSCoder) { nil }
123
165
 
166
+ /// Called when a list that does not scroll changes height, so the
167
+ /// containers around it make room.
168
+ var onFittingHeightChanged: (() -> Void)?
169
+
170
+ override var contentSize: CGSize {
171
+ didSet {
172
+ guard !isScrollEnabled, abs(oldValue.height - contentSize.height) > 0.5 else { return }
173
+ invalidateIntrinsicContentSize()
174
+ onFittingHeightChanged?()
175
+ }
176
+ }
177
+
178
+ override var isScrollEnabled: Bool {
179
+ didSet {
180
+ guard isScrollEnabled != oldValue else { return }
181
+ alwaysBounceVertical = isScrollEnabled && flowLayout.scrollDirection == .vertical
182
+ onFittingHeightChanged?()
183
+ }
184
+ }
185
+
186
+ /// The height of every row at `width`: what a list that does not scroll
187
+ /// (`scrollEnabled={false}`) takes inside a scroll view or a column.
188
+ /// Rows not measured yet count at their estimate, and the list asks for
189
+ /// room again as they are.
190
+ func fittingHeight(width: CGFloat) -> CGFloat {
191
+ guard !isScrollEnabled else { return 0 }
192
+ if abs(bounds.width - width) > 0.5 { frame.size.width = width }
193
+ layoutIfNeeded()
194
+ let content = flowLayout.collectionViewContentSize.height
195
+ return max(0, ceil(content + contentInset.top + contentInset.bottom))
196
+ }
197
+
198
+ /// The width a row gets: the list's own width inside its content padding,
199
+ /// shared between columns for data rows.
200
+ private func itemExtent(isChrome: Bool) -> CGFloat {
201
+ if flowLayout.scrollDirection == .horizontal {
202
+ return max(1, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
203
+ }
204
+ let width = max(1, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
205
+ let count = isChrome ? 1 : columns
206
+ let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
207
+ return max(1, (width - spacing) / CGFloat(count))
208
+ }
209
+
210
+ override func layoutSubviews() {
211
+ let estimate = flowLayout.scrollDirection == .horizontal
212
+ ? CGSize(width: 44, height: itemExtent(isChrome: true))
213
+ : CGSize(width: itemExtent(isChrome: false), height: 44)
214
+ if flowLayout.estimatedItemSize != estimate, bounds.width > 0 {
215
+ flowLayout.estimatedItemSize = estimate
216
+ }
217
+ super.layoutSubviews()
218
+ craftApplyCornerRadius()
219
+ }
220
+
221
+ func setContentContainerStyle(_ raw: [String: Any]?) {
222
+ func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
223
+ let padding = number(raw?["padding"]) ?? 0
224
+ let horizontal = number(raw?["paddingHorizontal"]) ?? padding
225
+ let vertical = number(raw?["paddingVertical"]) ?? padding
226
+ contentInset = UIEdgeInsets(
227
+ top: number(raw?["paddingTop"]) ?? vertical,
228
+ left: number(raw?["paddingLeft"]) ?? horizontal,
229
+ bottom: number(raw?["paddingBottom"]) ?? vertical,
230
+ right: number(raw?["paddingRight"]) ?? horizontal
231
+ )
232
+ scrollIndicatorInsets = contentInset
233
+ collectionViewLayout.invalidateLayout()
234
+ }
235
+
236
+ func setKeyboardShouldPersistTaps(_ value: Any?) {
237
+ let requested = value as? String
238
+ keyboardShouldPersistTaps = ["always", "handled", "never"].contains(requested) ? requested! : "never"
239
+ keyboardTapGesture.isEnabled = keyboardShouldPersistTaps != "always"
240
+ }
241
+
242
+ private var refreshingProp = false
243
+
244
+ /// `onRefresh` and `refreshing`, controlled as in React Native: a pull
245
+ /// the screen does not answer with `refreshing` true ends at once.
246
+ func setRefreshHandler(_ handler: (() -> Void)?, refreshing: Bool) {
247
+ refreshHandler = handler
248
+ refreshingProp = refreshing
249
+ let control = handler == nil ? nil : nativeRefreshControl
250
+ if refreshControl !== control { refreshControl = control }
251
+ if handler == nil || !refreshing {
252
+ if nativeRefreshControl.isRefreshing { nativeRefreshControl.endRefreshing() }
253
+ } else if !nativeRefreshControl.isRefreshing {
254
+ nativeRefreshControl.beginRefreshing()
255
+ }
256
+ }
257
+
258
+ @objc private func refreshTriggered() {
259
+ refreshHandler?()
260
+ DispatchQueue.main.async { [weak self] in
261
+ guard let self, !self.refreshingProp, self.nativeRefreshControl.isRefreshing else { return }
262
+ self.nativeRefreshControl.endRefreshing()
263
+ }
264
+ }
265
+
266
+ @objc private func keyboardTap() {
267
+ guard keyboardShouldPersistTaps != "always" else { return }
268
+ if keyboardShouldPersistTaps == "handled" {
269
+ let point = keyboardTapGesture.location(in: self)
270
+ var hit = hitTest(point, with: nil)
271
+ while let view = hit, view !== self {
272
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
273
+ hit = view.superview
274
+ }
275
+ }
276
+ window?.endEditing(true)
277
+ }
278
+
279
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
280
+ true
281
+ }
282
+
124
283
  var visibleItemIdentities: [String] {
125
284
  indexPathsForVisibleItems
126
285
  .sorted { $0.item < $1.item }
@@ -220,7 +379,11 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
220
379
  }
221
380
 
222
381
  let dataItems = next.filter { !$0.isChrome }
223
- let contentSignature = "\(dataItems.count):\(dataItems.last?.identity ?? "empty")"
382
+ let contentSignature = dataItems.map {
383
+ let encoded = $0.signature.base64EncodedString()
384
+ return "\($0.identity.utf8.count):\($0.identity)\(encoded.utf8.count):\(encoded)"
385
+ }.joined()
386
+ dataContentSignature = contentSignature
224
387
  if contentSignature != endReachedSignature { endReachedSignature = nil }
225
388
  }
226
389
 
@@ -232,9 +395,53 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
232
395
  renderItem = nil
233
396
  recycleItem = nil
234
397
  endReached = nil
398
+ dataContentSignature = ""
399
+ onScrollEvent = nil
400
+ onLayoutChanged = nil
401
+ onScrollBeginDrag = nil
402
+ onScrollEndDrag = nil
403
+ onMomentumScrollBegin = nil
404
+ onMomentumScrollEnd = nil
405
+ pendingThemeRefresh = false
406
+ }
407
+
408
+ func refreshThemeDefaults() {
409
+ if isApplyingSnapshot {
410
+ pendingThemeRefresh = true
411
+ return
412
+ }
413
+ var snapshot = diffableDataSource.snapshot()
414
+ let identities = snapshot.itemIdentifiers
415
+ guard !identities.isEmpty else { return }
416
+ pendingThemeRefresh = false
417
+ snapshot.reconfigureItems(identities)
418
+ isApplyingSnapshot = true
419
+ diffableDataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
420
+ self?.finishApplyingSnapshot()
421
+ }
422
+ }
423
+
424
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
425
+ onScrollEvent?(scrollView)
426
+ onLayoutChanged?()
427
+ evaluateEndReached()
428
+ }
429
+
430
+ func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
431
+ onScrollBeginDrag?(scrollView)
235
432
  }
236
433
 
237
- func scrollViewDidScroll(_ scrollView: UIScrollView) { evaluateEndReached() }
434
+ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
435
+ onScrollEndDrag?(scrollView)
436
+ }
437
+
438
+ func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
439
+ onMomentumScrollBegin?(scrollView)
440
+ }
441
+
442
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
443
+ onMomentumScrollEnd?(scrollView)
444
+ }
238
445
 
239
446
  func collectionView(
240
447
  _ collectionView: UICollectionView,
@@ -244,11 +451,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
244
451
  guard items.indices.contains(indexPath.item) else { return CGSize(width: bounds.width, height: 44) }
245
452
  let item = items[indexPath.item]
246
453
  if flowLayout.scrollDirection == .horizontal {
247
- return CGSize(width: max(1, bounds.width * 0.8), height: max(1, bounds.height))
454
+ return CGSize(width: 44, height: itemExtent(isChrome: item.isChrome))
248
455
  }
249
- let count = item.isChrome ? 1 : columns
250
- let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
251
- return CGSize(width: max(1, (bounds.width - spacing) / CGFloat(count)), height: 44)
456
+ return CGSize(width: itemExtent(isChrome: item.isChrome), height: 44)
252
457
  }
253
458
 
254
459
  private func evaluateEndReached() {
@@ -261,8 +466,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
261
466
  let remaining = dataIndices.count - lastDataOrdinal - 1
262
467
  let thresholdItems = max(1, Int(ceil(Double(dataIndices.count) * endReachedThreshold)))
263
468
  guard remaining <= thresholdItems else { return }
264
- let lastIdentity = items[dataIndices[dataIndices.count - 1]].identity
265
- let signature = "\(dataIndices.count):\(lastIdentity)"
469
+ let signature = dataContentSignature
266
470
  guard endReachedSignature != signature else { return }
267
471
  endReachedSignature = signature
268
472
  endReached()
@@ -277,6 +481,11 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
277
481
  pendingApply()
278
482
  return
279
483
  }
484
+ if self.pendingThemeRefresh {
485
+ self.pendingThemeRefresh = false
486
+ self.refreshThemeDefaults()
487
+ return
488
+ }
280
489
  self.evaluateEndReached()
281
490
  }
282
491
  }