@onekeyfe/react-native-native-list 3.0.157-alpha.254 → 3.0.157-alpha.255

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.
Files changed (87) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +20 -0
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +179 -0
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -2
  5. package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +53 -0
  6. package/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt +5 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt +215 -0
  8. package/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +15 -6
  9. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +3 -1
  10. package/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt +29 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +16 -1
  12. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +163 -26
  13. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +4 -0
  14. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt +34 -0
  15. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt +28 -0
  16. package/docs/DESIGN.md +4 -2
  17. package/docs/ROW_TEMPLATES.md +45 -0
  18. package/docs/SPEC.md +227 -1
  19. package/docs/STYLE_SPEC.md +8 -0
  20. package/ios/HybridNativeList.swift +30 -0
  21. package/ios/NativeListActivityDetailsView.swift +229 -0
  22. package/ios/NativeListActivityRenderer.swift +24 -2
  23. package/ios/NativeListMediaPreviewSlot.swift +199 -0
  24. package/ios/NativeListMediaTileRenderer.swift +10 -4
  25. package/ios/NativeListModels.swift +4 -1
  26. package/ios/NativeListScrollPositionTracker.swift +30 -0
  27. package/ios/NativeListSectionHeaderRenderer.swift +27 -1
  28. package/ios/RNCNativeListView.swift +159 -4
  29. package/ios/tests/NativeListActivityPresentationTests.swift +43 -0
  30. package/ios/tests/NativeListLifecycleTests.swift +123 -0
  31. package/ios/tests/NativeListMediaVisibilityTests.swift +42 -0
  32. package/ios/tests/NativeListScrollPositionTrackerTests.swift +25 -0
  33. package/lib/module/NativeList.js +56 -3
  34. package/lib/module/NativeList.web.js +5 -0
  35. package/lib/module/validation.js +64 -4
  36. package/lib/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  37. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  38. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  39. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  40. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  41. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  42. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  43. package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  44. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  45. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  46. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  47. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  48. package/lib/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  49. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  50. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  51. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  52. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  53. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  54. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  55. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  56. package/lib/typescript/src/NativeList.nitro.d.ts +3 -0
  57. package/lib/typescript/src/NativeList.types.d.ts +14 -1
  58. package/lib/typescript/src/models.d.ts +33 -5
  59. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  60. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  61. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  62. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  66. package/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  67. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  68. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  69. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  70. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  71. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  72. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  73. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  74. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  75. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  76. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  77. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  78. package/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  79. package/package.json +6 -5
  80. package/scripts/enable-container-slots.mjs +58 -0
  81. package/scripts/enable-container-slots.test.mjs +58 -0
  82. package/src/NativeList.nitro.ts +3 -0
  83. package/src/NativeList.tsx +84 -2
  84. package/src/NativeList.types.ts +14 -1
  85. package/src/NativeList.web.tsx +5 -0
  86. package/src/models.ts +38 -1
  87. package/src/validation.ts +146 -4
@@ -0,0 +1,199 @@
1
+ import AVFoundation
2
+ import UIKit
3
+
4
+ private final class NativeListPausedVideoView: UIView {
5
+ var onWindowChanged: (() -> Void)?
6
+ var onLayoutChanged: (() -> Void)?
7
+ override class var layerClass: AnyClass { AVPlayerLayer.self }
8
+ var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
9
+ override func didMoveToWindow() { super.didMoveToWindow(); onWindowChanged?() }
10
+ override func layoutSubviews() { super.layoutSubviews(); onLayoutChanged?() }
11
+ }
12
+
13
+ /// Uses the same AVPlayer source semantics as native Video, always paused and muted.
14
+ final class NativeListMediaPreviewSlot {
15
+ let view = UIView()
16
+ private let image = NativeListImageSlot()
17
+ private let video = NativeListPausedVideoView()
18
+ private var identity: String?
19
+ private var epoch = 0
20
+ private var candidateGeneration = 0
21
+ private var playerGeneration = 0
22
+ private var result: Bool?
23
+ private var completion: ((Bool) -> Void)?
24
+ private var player: AVPlayer?
25
+ private var itemObservation: NSKeyValueObservation?
26
+ private var readyObservation: NSKeyValueObservation?
27
+ private var resumeVideo: (() -> Void)?
28
+ private var lifecycleObservers: [NSObjectProtocol] = []
29
+ private var viewportObservations: [NSKeyValueObservation] = []
30
+
31
+ init() {
32
+ view.isUserInteractionEnabled = false
33
+ for child in [image.view, video] {
34
+ child.frame = view.bounds
35
+ child.autoresizingMask = [.flexibleWidth, .flexibleHeight]
36
+ view.addSubview(child)
37
+ }
38
+ video.clipsToBounds = true
39
+ video.isHidden = true
40
+ video.onWindowChanged = { [weak self] in self?.observeViewport(); self?.updateVisibility() }
41
+ video.onLayoutChanged = { [weak self] in self?.updateVisibility() }
42
+ lifecycleObservers = [
43
+ NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.releasePlayer() },
44
+ NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.updateVisibility() },
45
+ ]
46
+ }
47
+
48
+ func bind(
49
+ _ source: [String: Any], key: String, fit: String? = nil,
50
+ placeholder: String = "#0000000F", probeOrder: [String] = ["image"],
51
+ completion: ((Bool) -> Void)? = nil
52
+ ) {
53
+ let next = NativeListImageSlot.signature([
54
+ "source": source, "key": key, "fit": fit ?? "", "order": probeOrder,
55
+ "placeholder": placeholder,
56
+ ])
57
+ self.completion = completion
58
+ if identity == next {
59
+ if let result { completion?(result) }
60
+ updateVisibility()
61
+ return
62
+ }
63
+ recycle()
64
+ identity = next
65
+ self.completion = completion
66
+ video.playerLayer.videoGravity = (fit ?? source.string("contentFit")) == "cover" ? .resizeAspectFill : .resizeAspect
67
+ probe(source, key: key, fit: fit, placeholder: placeholder, order: probeOrder, attempt: 0, token: epoch)
68
+ }
69
+
70
+ private func probe(
71
+ _ source: [String: Any], key: String, fit: String?, placeholder: String,
72
+ order: [String], attempt: Int, token: Int
73
+ ) {
74
+ guard token == epoch else { return }
75
+ candidateGeneration &+= 1
76
+ let candidate = candidateGeneration
77
+ resumeVideo = nil
78
+ viewportObservations.removeAll()
79
+ releasePlayer()
80
+ guard attempt < order.count else {
81
+ result = false
82
+ completion?(false)
83
+ return
84
+ }
85
+ let finished: (Bool) -> Void = { [weak self] loaded in
86
+ guard let self, self.epoch == token, self.candidateGeneration == candidate else { return }
87
+ if loaded {
88
+ guard self.result != true else { return }
89
+ self.result = true
90
+ self.completion?(true)
91
+ } else {
92
+ self.probe(source, key: key, fit: fit, placeholder: placeholder, order: order, attempt: attempt + 1, token: token)
93
+ }
94
+ }
95
+ if order[attempt] == "image" {
96
+ video.isHidden = true
97
+ image.view.isHidden = false
98
+ image.bind(source, key: key, fit: fit, placeholder: placeholder, completion: finished)
99
+ return
100
+ }
101
+ image.recycle()
102
+ image.view.isHidden = true
103
+ video.isHidden = false
104
+ guard let url = URL(string: source.string("uri")), !source.string("uri").isEmpty else { finished(false); return }
105
+ resumeVideo = { [weak self] in
106
+ guard let self, self.epoch == token, self.candidateGeneration == candidate, self.player == nil else { return }
107
+ self.playerGeneration &+= 1
108
+ let generation = self.playerGeneration
109
+ let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": source.dictionary("headers") ?? [:]])
110
+ let item = AVPlayerItem(asset: asset)
111
+ item.preferredForwardBufferDuration = 1
112
+ let player = AVPlayer(playerItem: item)
113
+ player.isMuted = true
114
+ player.pause()
115
+ self.player = player
116
+ self.video.playerLayer.player = player
117
+ self.itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in
118
+ DispatchQueue.main.async {
119
+ guard let self, self.playerGeneration == generation else { return }
120
+ if item.status == .failed { finished(false) }
121
+ }
122
+ }
123
+ self.readyObservation = self.video.playerLayer.observe(\.isReadyForDisplay, options: [.initial, .new]) { [weak self] layer, _ in
124
+ DispatchQueue.main.async {
125
+ guard let self, self.playerGeneration == generation else { return }
126
+ if layer.isReadyForDisplay { finished(true) }
127
+ }
128
+ }
129
+ }
130
+ observeViewport()
131
+ updateVisibility()
132
+ }
133
+
134
+ private func observeViewport() {
135
+ viewportObservations.removeAll()
136
+ guard resumeVideo != nil, video.window != nil else { return }
137
+ var ancestor = video.superview
138
+ while let current = ancestor {
139
+ if let scrollView = current as? UIScrollView {
140
+ viewportObservations.append(scrollView.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
141
+ viewportObservations.append(scrollView.observe(\.bounds, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
142
+ }
143
+ // Pager or host layouts may translate a retained page without scrolling it.
144
+ viewportObservations.append(current.layer.observe(\.transform, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
145
+ viewportObservations.append(current.layer.observe(\.position, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
146
+ viewportObservations.append(current.layer.observe(\.isHidden, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
147
+ viewportObservations.append(current.layer.observe(\.opacity, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
148
+ ancestor = current.superview
149
+ }
150
+ }
151
+
152
+ /// Retained pager pages can have a window while lying outside its clipped viewport.
153
+ static func intersectsViewport(_ view: UIView) -> Bool {
154
+ guard let window = view.window, !view.bounds.isEmpty else { return false }
155
+ var visible = view.convert(view.bounds, to: window).intersection(window.bounds)
156
+ var ancestor: UIView? = view
157
+ while let current = ancestor {
158
+ if current.isHidden || current.alpha <= 0.01 { return false }
159
+ if current.clipsToBounds || current is UIScrollView {
160
+ visible = visible.intersection(current.convert(current.bounds, to: window))
161
+ }
162
+ if visible.isNull || visible.isEmpty { return false }
163
+ ancestor = current.superview
164
+ }
165
+ return true
166
+ }
167
+
168
+ private func updateVisibility() {
169
+ if Self.intersectsViewport(video) && UIApplication.shared.applicationState == .active { resumeVideo?() }
170
+ else { releasePlayer() }
171
+ }
172
+ private func releasePlayer() {
173
+ playerGeneration &+= 1
174
+ itemObservation = nil
175
+ readyObservation = nil
176
+ player?.pause()
177
+ video.playerLayer.player = nil
178
+ player?.replaceCurrentItem(with: nil)
179
+ player = nil
180
+ }
181
+ func recycle() {
182
+ epoch &+= 1
183
+ candidateGeneration &+= 1
184
+ resumeVideo = nil
185
+ viewportObservations.removeAll()
186
+ releasePlayer()
187
+ image.recycle()
188
+ image.view.isHidden = false
189
+ video.isHidden = true
190
+ identity = nil
191
+ result = nil
192
+ completion = nil
193
+ }
194
+ deinit {
195
+ lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
196
+ player?.pause()
197
+ player?.replaceCurrentItem(with: nil)
198
+ }
199
+ }
@@ -8,7 +8,7 @@ final class NativeListMediaTileCell: NativeListRendererCell {
8
8
  }
9
9
 
10
10
  private let picture = UIView()
11
- private let image = NativeListImageSlot()
11
+ private let image = NativeListMediaPreviewSlot()
12
12
  private let network = NativeListImageSlot()
13
13
  private let errorIcon = UIImageView()
14
14
  private let title = NativeListTextLabel()
@@ -27,7 +27,7 @@ final class NativeListMediaTileCell: NativeListRendererCell {
27
27
  override var defaultCornerRadius: CGFloat { 16 }
28
28
  override var showsSelection: Bool { false }
29
29
  override var pressChangesBackground: Bool { false }
30
- override var assetFields: [String] { ["image", "networkImage"] }
30
+ override var assetFields: [String] { ["image", "media", "networkImage"] }
31
31
  override var isHighlighted: Bool { didSet { picture.alpha = isHighlighted ? 0.8 : 1 } }
32
32
 
33
33
  override init(frame: CGRect) {
@@ -120,7 +120,8 @@ final class NativeListMediaTileCell: NativeListRendererCell {
120
120
  errorIcon.isHidden = state != "error"
121
121
  errorIcon.image = nativeListIcon(named: "ImageSquareWavesOutline")
122
122
  errorIcon.tintColor = UIColor(nativeListHex: "#00000044", fallback: .lightGray)
123
- if state != "empty", state != "error", let source = item.data.dictionary("image") {
123
+ let media = item.data.dictionary("media")
124
+ if state != "empty", state != "error", let source = media?.dictionary("source") ?? item.data.dictionary("image") {
124
125
  // Legacy source-backed media had a transparent container. Clear only a fill this
125
126
  // renderer applied, so OneKeyImage keeps ownership of its own placeholder color.
126
127
  if ownsImageFill {
@@ -129,7 +130,12 @@ final class NativeListMediaTileCell: NativeListRendererCell {
129
130
  }
130
131
  image.bind(
131
132
  source, key: "\(item.key):media", fit: imageStyle["contentFit"] as? String,
132
- placeholder: placeholder)
133
+ placeholder: placeholder, probeOrder: media?["probeOrder"] as? [String] ?? ["image"]
134
+ ) { [weak self] loaded in
135
+ self?.errorIcon.isHidden = loaded
136
+ self?.ownsImageFill = !loaded
137
+ self?.image.view.backgroundColor = loaded ? .clear : UIColor(nativeListHex: placeholder, fallback: .lightGray)
138
+ }
133
139
  } else {
134
140
  image.recycle()
135
141
  image.view.backgroundColor =
@@ -93,6 +93,7 @@ struct NativeListConfig {
93
93
  var selectedKeys: Set<String>
94
94
  let reorderable: Bool
95
95
  let pullToRefresh: Bool
96
+ let refreshTriggerDistance: CGFloat?
96
97
  var refreshing: Bool
97
98
  let loadMore: Bool
98
99
  let endReachedThreshold: Double
@@ -144,7 +145,7 @@ struct NativeListConfig {
144
145
  generation: generation,
145
146
  layout: layout,
146
147
  orientation: layoutData["orientation"] as? String ?? "vertical",
147
- gridColumns: min(4, max(1, layoutData["gridColumns"] as? Int ?? 1)),
148
+ gridColumns: min(7, max(1, layoutData["gridColumns"] as? Int ?? 1)),
148
149
  stickyHeaders: layoutData["stickyHeaders"] as? Bool ?? false,
149
150
  contentPadding: contentPadding,
150
151
  contentPaddingHorizontal: contentPaddingHorizontal,
@@ -156,6 +157,8 @@ struct NativeListConfig {
156
157
  selectedKeys: selectedKeys,
157
158
  reorderable: capabilities?["reorderable"] as? Bool ?? false,
158
159
  pullToRefresh: capabilities?["pullToRefresh"] as? Bool ?? false,
160
+ refreshTriggerDistance: (capabilities?["refreshTriggerDistance"] as? Double)
161
+ .flatMap { $0.isFinite && $0 > 0 ? CGFloat($0) : nil },
159
162
  refreshing: capabilities?["refreshing"] as? Bool ?? false,
160
163
  loadMore: capabilities?["loadMore"] as? Bool ?? false,
161
164
  endReachedThreshold: capabilities?["endReachedThreshold"] as? Double ?? 0.2,
@@ -0,0 +1,30 @@
1
+ import Foundation
2
+
3
+ /// A per-view hysteresis observer. A nil result means no bridge event is needed.
4
+ struct NativeListScrollPositionTracker {
5
+ private var start: Double?
6
+ private var end: Double?
7
+ private var previous: Bool?
8
+
9
+ mutating func configure(json: String) {
10
+ start = nil
11
+ end = nil
12
+ previous = nil
13
+ guard let data = json.data(using: .utf8),
14
+ let values = try? JSONSerialization.jsonObject(with: data) as? [String: Double],
15
+ let start = values["start"], let end = values["end"],
16
+ start.isFinite, end.isFinite, start >= 0, end > start else { return }
17
+ self.start = start
18
+ self.end = end
19
+ }
20
+
21
+ mutating func resetDelivery() { previous = nil }
22
+
23
+ mutating func update(offset: Double) -> Bool? {
24
+ guard let start, let end, offset.isFinite else { return nil }
25
+ let next = previous == true ? offset > start : offset >= end
26
+ guard previous != next else { return nil }
27
+ previous = next
28
+ return next
29
+ }
30
+ }
@@ -99,7 +99,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
99
99
  if layout == "table" { return 28 }
100
100
  if item.data.string("presentation") == "networkSelector" { return 47 }
101
101
  if item.data.string("variant") == "history" {
102
- return 16
102
+ return item.data.bool("titleLoading") ? 20 : 16
103
103
  }
104
104
  if item.data.string("variant") == "summary" { return 68 }
105
105
  if item.data.string("variant") == "gallery" { return 32 }
@@ -110,6 +110,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
110
110
  private let mainStack = UIStackView()
111
111
  private let titleRowStack = UIStackView()
112
112
  private let titleLabel = NativeListDottedUnderlineLabel()
113
+ private let titleSpinner = UIActivityIndicatorView(style: .medium)
113
114
  private let subtitleLabel = NativeListTextLabel()
114
115
  private let trailingStack = UIStackView()
115
116
  private let accessoryButtons = [NativeListAccessoryButton(type: .system)]
@@ -132,6 +133,12 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
132
133
  root.axis = .horizontal
133
134
  root.alignment = .center
134
135
  root.spacing = 12
136
+ titleSpinner.translatesAutoresizingMaskIntoConstraints = false
137
+ titleSpinner.isAccessibilityElement = false
138
+ NSLayoutConstraint.activate([
139
+ titleSpinner.widthAnchor.constraint(equalToConstant: 20),
140
+ titleSpinner.heightAnchor.constraint(equalToConstant: 20),
141
+ ])
135
142
  mainStack.axis = .vertical
136
143
  mainStack.alignment = .fill
137
144
  mainStack.spacing = 2
@@ -174,6 +181,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
174
181
  selectorTypographyRestorers = []
175
182
  currentItem = item
176
183
  currentTheme = theme
184
+ titleSpinner.stopAnimating()
177
185
  NSLayoutConstraint.deactivate(selectorConstraints)
178
186
  selectorConstraints = []
179
187
  root.arrangedSubviews.forEach {
@@ -244,6 +252,8 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
244
252
  if style["lineGap"] != nil { mainStack.spacing = CGFloat(style.double("lineGap")) }
245
253
  if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) }
246
254
  applyTextStyle(item)
255
+ titleSpinner.color = titleLabel.textColor
256
+ updateTitleSpinner()
247
257
  contentInsets = UIEdgeInsets(
248
258
  top: topInset, left: leftInset, bottom: -bottomInset, right: -rightInset)
249
259
  root.alignment =
@@ -301,6 +311,18 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
301
311
  override func recycleContent() {
302
312
  checkboxControl.reset()
303
313
  currentItem = nil
314
+ titleSpinner.stopAnimating()
315
+ }
316
+ override func didMoveToWindow() {
317
+ super.didMoveToWindow()
318
+ updateTitleSpinner()
319
+ }
320
+ private func updateTitleSpinner() {
321
+ if window != nil, currentItem?.data.bool("titleLoading") == true {
322
+ titleSpinner.startAnimating()
323
+ } else {
324
+ titleSpinner.stopAnimating()
325
+ }
304
326
  }
305
327
  @objc private func titlePressed() {
306
328
  if let item = currentItem {
@@ -324,6 +346,10 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
324
346
  layout: String,
325
347
  _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
326
348
  ) {
349
+ if item.data.bool("titleLoading") {
350
+ root.addArrangedSubview(titleSpinner)
351
+ root.setCustomSpacing(8, after: titleSpinner)
352
+ }
327
353
  root.addArrangedSubview(mainStack)
328
354
  let variant = item.data.string("variant")
329
355
  // OneKey patch: title help is a separate target from checkbox and value actions.
@@ -4,6 +4,54 @@ import UIKit
4
4
  import UniformTypeIdentifiers
5
5
 
6
6
  final class NativeListView: UIView {
7
+ private var refreshEmittedForDrag = false
8
+ private var minimumDragOffset: CGFloat = 0
9
+ private var reactContainerSlots: [UIView] = []
10
+ private var containerSlotHeights: [CGFloat] = [0, 0, 0]
11
+ private var lastLaidOutContentHeight: CGFloat?
12
+ private var needsContentShrinkCorrection = false
13
+ private var contentShrinkCorrectionScheduled = false
14
+
15
+ @objc func mountContainerSlot(_ view: UIView, atIndex index: Int) {
16
+ reactContainerSlots.insert(view, at: min(index, reactContainerSlots.count))
17
+ collectionView.addSubview(view)
18
+ setNeedsLayout()
19
+ }
20
+
21
+ @objc func unmountContainerSlot(_ view: UIView) {
22
+ reactContainerSlots.removeAll { $0 === view }
23
+ view.removeFromSuperview()
24
+ }
25
+
26
+ func setContainerSlotHeightsJson(_ json: String) {
27
+ guard let data = json.data(using: .utf8),
28
+ let heights = try? JSONSerialization.jsonObject(with: data) as? [Double],
29
+ heights.count == 3, heights.allSatisfy({ $0.isFinite && $0 >= 0 }) else { return }
30
+ containerSlotHeights = heights.map { CGFloat($0) }
31
+ if let config { configureLayout(config) }
32
+ setNeedsLayout()
33
+ }
34
+
35
+ private func layoutContainerSlots() {
36
+ guard reactContainerSlots.count == 3 else { return }
37
+ let empty = config?.items.isEmpty ?? true
38
+ let header = containerSlotHeights[0]
39
+ let emptyHeight = empty ? containerSlotHeights[1] : 0
40
+ let footer = containerSlotHeights[2]
41
+ let paddingTop = config?.contentPaddingTop ?? 0
42
+ let paddingBottom = config?.contentPaddingBottom ?? 0
43
+ let footerY = max(paddingTop + header + emptyHeight,
44
+ collectionView.contentSize.height - paddingBottom - footer)
45
+ let positions = [paddingTop, paddingTop + header, footerY]
46
+ for (index, view) in reactContainerSlots.enumerated() {
47
+ view.isHidden = index == 1 && !empty
48
+ let horizontalPadding = config?.contentPaddingHorizontal ?? 0
49
+ view.frame = CGRect(x: horizontalPadding, y: positions[index],
50
+ width: max(0, collectionView.bounds.width - horizontalPadding * 2),
51
+ height: containerSlotHeights[index])
52
+ }
53
+ }
54
+
7
55
  private final class ActionAnchorRecord {
8
56
  let token: String
9
57
  weak var sourceView: UIView?
@@ -47,6 +95,26 @@ final class NativeListView: UIView {
47
95
  case end(Bool)
48
96
  }
49
97
 
98
+ private var scrollPositionTracker = NativeListScrollPositionTracker()
99
+ var onScrollPositionThresholdChange: ((Bool) -> Void)? {
100
+ didSet {
101
+ scrollPositionTracker.resetDelivery()
102
+ emitScrollPositionThresholdIfNeeded()
103
+ }
104
+ }
105
+
106
+ func setScrollPositionThresholdsJson(_ json: String) {
107
+ scrollPositionTracker.configure(json: json)
108
+ emitScrollPositionThresholdIfNeeded()
109
+ }
110
+
111
+ private func emitScrollPositionThresholdIfNeeded() {
112
+ guard let callback = onScrollPositionThresholdChange,
113
+ let next = scrollPositionTracker.update(offset: Double(max(0,
114
+ collectionView.contentOffset.y + collectionView.contentInset.top))) else { return }
115
+ callback(next)
116
+ }
117
+
50
118
  var onRowAction: ((String) -> Void)?
51
119
  var onActionAnchorInvalidated: ((String) -> Void)?
52
120
  var onSelectionDelta: ((String) -> Void)?
@@ -60,7 +128,14 @@ final class NativeListView: UIView {
60
128
  }
61
129
 
62
130
  private let flowLayout = NativeListFlowLayout()
63
- private lazy var collectionView = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout)
131
+ private lazy var collectionView: NativeListCollectionView = {
132
+ let view = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout)
133
+ view.onContentLayout = { [weak self] in
134
+ self?.layoutContainerSlots()
135
+ self?.contentLayoutDidChange()
136
+ }
137
+ return view
138
+ }()
64
139
  private let footerContainer = UIView()
65
140
  private var footerCell: NativeListRowHost = NativeListRendererRegistry.create(.action)
66
141
  private var footerRendererKey = NativeListRendererKey.action
@@ -333,6 +408,7 @@ final class NativeListView: UIView {
333
408
  rebindRowsForLayoutDirection(config)
334
409
  }
335
410
  performPendingScrollIfNeeded()
411
+ layoutContainerSlots()
336
412
  }
337
413
 
338
414
  // A layout-direction change may arrive without a layout pass (semantic attribute or trait).
@@ -673,6 +749,39 @@ final class NativeListView: UIView {
673
749
  collectionView.setContentOffset(point, animated: animated)
674
750
  }
675
751
 
752
+ private func contentLayoutDidChange() {
753
+ let height = collectionView.contentSize.height
754
+ if config?.orientation != "horizontal",
755
+ let previous = lastLaidOutContentHeight, height < previous {
756
+ needsContentShrinkCorrection = true
757
+ }
758
+ lastLaidOutContentHeight = height
759
+ scheduleContentShrinkCorrection()
760
+ }
761
+
762
+ private func scheduleContentShrinkCorrection() {
763
+ guard needsContentShrinkCorrection, !contentShrinkCorrectionScheduled, !disposed else { return }
764
+ contentShrinkCorrectionScheduled = true
765
+ // Wait for the collection layout and ancestor pager inset observers to settle.
766
+ DispatchQueue.main.async { [weak self] in
767
+ guard let self else { return }
768
+ self.contentShrinkCorrectionScheduled = false
769
+ guard !self.disposed, self.needsContentShrinkCorrection,
770
+ !self.collectionView.isTracking, !self.collectionView.isDragging,
771
+ !self.collectionView.isDecelerating,
772
+ self.collectionView.refreshControl?.isRefreshing != true,
773
+ self.config?.refreshing != true else { return }
774
+ self.needsContentShrinkCorrection = false
775
+ guard self.config?.orientation != "horizontal", self.collectionView.bounds.height > 0 else { return }
776
+ let insets = self.collectionView.adjustedContentInset
777
+ let maximum = max(-insets.top,
778
+ self.collectionView.contentSize.height - self.collectionView.bounds.height + insets.bottom)
779
+ if self.collectionView.contentOffset.y > maximum {
780
+ self.setScrollOffset(self.collectionView.contentOffset.y, animated: false)
781
+ }
782
+ }
783
+ }
784
+
676
785
  func setRefreshing(_ refreshing: Bool) {
677
786
  guard var current = config else { return }
678
787
  current.refreshing = refreshing
@@ -681,6 +790,7 @@ final class NativeListView: UIView {
681
790
  collectionView.refreshControl?.beginRefreshing()
682
791
  } else {
683
792
  collectionView.refreshControl?.endRefreshing()
793
+ scheduleContentShrinkCorrection()
684
794
  }
685
795
  }
686
796
 
@@ -693,11 +803,14 @@ final class NativeListView: UIView {
693
803
  flowLayout.minimumLineSpacing = config.itemSpacing
694
804
  flowLayout.minimumInteritemSpacing = config.itemSpacing
695
805
  flowLayout.sectionInset = UIEdgeInsets(
696
- top: config.contentPaddingTop,
806
+ top: config.contentPaddingTop + containerSlotHeights[0],
697
807
  left: config.contentPaddingHorizontal + (isRightToLeft ? indexGutter : 0),
698
- bottom: config.contentPaddingBottom,
808
+ bottom: config.contentPaddingBottom + containerSlotHeights[2],
699
809
  right: config.contentPaddingHorizontal + (isRightToLeft ? 0 : indexGutter)
700
810
  )
811
+ flowLayout.minimumContainerContentHeight = config.items.isEmpty
812
+ ? config.contentPaddingTop + config.contentPaddingBottom + containerSlotHeights.reduce(0, +)
813
+ : 0
701
814
  flowLayout.stickyItemIndexes = config.stickyHeaders
702
815
  ? Set(config.items.enumerated().compactMap {
703
816
  $0.element.type == "sectionHeader" && $0.element.data.bool("sticky", default: true) && $0.element.data.string("variant") != "summary"
@@ -1347,6 +1460,7 @@ final class NativeListView: UIView {
1347
1460
  } else {
1348
1461
  collectionView.refreshControl = nil
1349
1462
  }
1463
+ if !config.refreshing { scheduleContentShrinkCorrection() }
1350
1464
  }
1351
1465
 
1352
1466
  private func configureFooter(_ config: NativeListConfig) {
@@ -1539,6 +1653,7 @@ final class NativeListView: UIView {
1539
1653
  current.rowPressToggles == next.rowPressToggles,
1540
1654
  current.reorderable == next.reorderable,
1541
1655
  current.pullToRefresh == next.pullToRefresh,
1656
+ current.refreshTriggerDistance == next.refreshTriggerDistance,
1542
1657
  current.refreshing == next.refreshing,
1543
1658
  current.loadMore == next.loadMore,
1544
1659
  current.endReachedThreshold == next.endReachedThreshold,
@@ -1849,6 +1964,8 @@ final class NativeListView: UIView {
1849
1964
  itemsByKey.removeAll()
1850
1965
  sectionIndexEntries.removeAll()
1851
1966
  pendingScrollRequest = nil
1967
+ needsContentShrinkCorrection = false
1968
+ lastLaidOutContentHeight = nil
1852
1969
  lastVisibleRange = nil
1853
1970
  onRowAction = nil
1854
1971
  onActionAnchorInvalidated = nil
@@ -1856,6 +1973,7 @@ final class NativeListView: UIView {
1856
1973
  onReorder = nil
1857
1974
  onEndReached = nil
1858
1975
  onVisibleRangeChanged = nil
1976
+ onScrollPositionThresholdChange = nil
1859
1977
  }
1860
1978
 
1861
1979
  private func createActionAnchor(origin: NativeListActionOrigin) -> [String: Any]? {
@@ -1921,6 +2039,9 @@ final class NativeListView: UIView {
1921
2039
  }
1922
2040
 
1923
2041
  @objc private func refreshTriggered() {
2042
+ guard !disposed, config?.pullToRefresh == true,
2043
+ config?.refreshing != true, !refreshEmittedForDrag else { return }
2044
+ refreshEmittedForDrag = true
1924
2045
  emit(onRowAction, ["actionKey": "nativeList.refresh"])
1925
2046
  }
1926
2047
 
@@ -2142,7 +2263,31 @@ extension NativeListView: UICollectionViewDelegateFlowLayout {
2142
2263
  return item.isRowPressEnabled
2143
2264
  }
2144
2265
 
2266
+ func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
2267
+ minimumDragOffset = 0
2268
+ refreshEmittedForDrag = false
2269
+ }
2270
+
2271
+ func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
2272
+ defer { if !decelerate { scheduleContentShrinkCorrection() } }
2273
+ minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top)
2274
+ guard let threshold = config?.refreshTriggerDistance,
2275
+ config?.pullToRefresh == true, config?.refreshing != true,
2276
+ minimumDragOffset <= -threshold, !refreshEmittedForDrag else { return }
2277
+ collectionView.refreshControl?.beginRefreshing()
2278
+ refreshTriggered()
2279
+ }
2280
+
2281
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
2282
+ scheduleContentShrinkCorrection()
2283
+ }
2284
+
2145
2285
  func scrollViewDidScroll(_ scrollView: UIScrollView) {
2286
+ if scrollView.isDragging {
2287
+ minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top)
2288
+ }
2289
+ layoutContainerSlots()
2290
+ emitScrollPositionThresholdIfNeeded()
2146
2291
  invalidateActionAnchor(reason: "scroll")
2147
2292
  syncSectionIndexToVisibleRows()
2148
2293
  emitVisibleRangeIfNeeded()
@@ -2205,6 +2350,13 @@ extension NativeListView: UICollectionViewDragDelegate, UICollectionViewDropDele
2205
2350
  // quick tap highlighted and unhighlighted a row within one frame and the pressed background never
2206
2351
  // rendered. Deliver touches immediately, as Android rows do.
2207
2352
  private final class NativeListCollectionView: UICollectionView {
2353
+ var onContentLayout: (() -> Void)?
2354
+
2355
+ override func layoutSubviews() {
2356
+ super.layoutSubviews()
2357
+ onContentLayout?()
2358
+ }
2359
+
2208
2360
  override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
2209
2361
  super.init(frame: frame, collectionViewLayout: layout)
2210
2362
  delaysContentTouches = false
@@ -2223,6 +2375,7 @@ private final class NativeListCollectionView: UICollectionView {
2223
2375
 
2224
2376
  final class NativeListFlowLayout: UICollectionViewFlowLayout {
2225
2377
  var stickyItemIndexes: Set<Int> = []
2378
+ var minimumContainerContentHeight: CGFloat = 0
2226
2379
  private var horizontalAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
2227
2380
  private var horizontalContentSize: CGSize = .zero
2228
2381
 
@@ -2251,7 +2404,9 @@ final class NativeListFlowLayout: UICollectionViewFlowLayout {
2251
2404
  }
2252
2405
 
2253
2406
  override var collectionViewContentSize: CGSize {
2254
- scrollDirection == .horizontal ? horizontalContentSize : super.collectionViewContentSize
2407
+ if scrollDirection == .horizontal { return horizontalContentSize }
2408
+ let size = super.collectionViewContentSize
2409
+ return CGSize(width: size.width, height: max(size.height, minimumContainerContentHeight))
2255
2410
  }
2256
2411
 
2257
2412
  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {