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

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 +165 -26
  13. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +5 -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 +241 -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
@@ -4,9 +4,11 @@ final class NativeListActivityCell: NativeListRendererCell {
4
4
  override class func measure(
5
5
  _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String
6
6
  ) -> CGFloat? {
7
+ if item.data["amounts"] != nil { return NativeListActivityDetailsView.measure(item.data, width: width) }
7
8
  return item.data.dictionaries("footerActions").isEmpty ? 60 : 100
8
9
  }
9
10
 
11
+ private let details = NativeListActivityDetailsView()
10
12
  private let visual = NativeListLeadingVisual()
11
13
  private let column = UIStackView()
12
14
  private let titleLine = UIStackView()
@@ -22,7 +24,7 @@ final class NativeListActivityCell: NativeListRendererCell {
22
24
  private let buttons = (0..<3).map { _ in UIButton(type: .system) }
23
25
  private var actionKeys: [String] = []
24
26
  private var dimensions: [NSLayoutConstraint] = []
25
- override var assetFields: [String] { ["leading", "secondaryLeading"] }
27
+ override var assetFields: [String] { ["leading", "secondaryLeading", "amounts"] }
26
28
 
27
29
  override init(frame: CGRect) {
28
30
  super.init(frame: frame)
@@ -60,7 +62,11 @@ final class NativeListActivityCell: NativeListRendererCell {
60
62
  button.addTarget(self, action: #selector(pressed(_:)), for: .touchUpInside)
61
63
  actions.addArrangedSubview(button)
62
64
  }
63
- [visual, column, amounts].forEach(root.addArrangedSubview)
65
+ [visual, column, amounts, details].forEach(root.addArrangedSubview)
66
+ details.isHidden = true
67
+ details.onAction = { [weak self] key, view, source, slot in
68
+ self?.emitAction(key, from: view, source: source, slot: slot)
69
+ }
64
70
  }
65
71
  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
66
72
  override func bindContent(
@@ -68,6 +74,21 @@ final class NativeListActivityCell: NativeListRendererCell {
68
74
  checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
69
75
  ) {
70
76
  let data = item.data
77
+ let rich = data["amounts"] != nil
78
+ details.isHidden = !rich
79
+ [visual, column, amounts].forEach { $0.isHidden = rich }
80
+ root.axis = rich ? .vertical : .horizontal
81
+ root.alignment = rich ? .fill : .center
82
+ if rich {
83
+ let style = data.dictionary("style") ?? [:]
84
+ let hp = CGFloat(style.double("horizontalPadding", default: data.string("presentation") == "table" ? 16 : 12))
85
+ let vp = CGFloat(style.double("verticalPadding", default: 8))
86
+ contentInsets = UIEdgeInsets(top: vp, left: hp, bottom: vp, right: hp)
87
+ visual.recycle()
88
+ details.bind(item, theme: theme)
89
+ return
90
+ }
91
+ details.recycle()
71
92
  let style = data.dictionary("style") ?? [:]
72
93
  let image = style.dictionary("image") ?? [:]
73
94
  root.spacing = 12
@@ -173,6 +194,7 @@ final class NativeListActivityCell: NativeListRendererCell {
173
194
  }
174
195
  override func recycleContent() {
175
196
  visual.recycle()
197
+ details.recycle()
176
198
  actionKeys = []
177
199
  buttons.forEach { $0.isHidden = true }
178
200
  }
@@ -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.