@onekeyfe/react-native-perp-depth-bar 6.7.0-11 → 6.7.0-13

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.
@@ -16,8 +16,11 @@ Pod::Spec.new do |s|
16
16
  s.source_files = [
17
17
  "ios/**/*.{swift}",
18
18
  "ios/**/*.{m,mm}",
19
+ "ios/PerpDepthBarInterop.hpp",
19
20
  "cpp/**/*.{hpp,cpp}",
20
21
  ]
22
+ s.exclude_files = "ios/tests/**/*"
23
+ s.public_header_files = "ios/PerpDepthBarInterop.hpp"
21
24
 
22
25
  s.dependency 'React-jsi'
23
26
  s.dependency 'React-callinvoker'
@@ -26,4 +29,11 @@ Pod::Spec.new do |s|
26
29
  add_nitrogen_files(s)
27
30
 
28
31
  install_modules_dependencies(s)
32
+
33
+ s.test_spec "Tests" do |test_spec|
34
+ test_spec.source_files = "ios/tests/**/*.swift"
35
+ test_spec.pod_target_xcconfig = {
36
+ "SWIFT_OBJC_INTEROP_MODE" => "objcxx"
37
+ }
38
+ end
29
39
  end
package/README.md CHANGED
@@ -6,6 +6,8 @@ Native iOS and Android views for an order-book depth column and a bid/ask ratio
6
6
  track. Each view owns native drawing and animation; the application provides
7
7
  formatted prices, sizes and percentages.
8
8
 
9
+ The native ownership and update contract is in [docs/SPEC.md](docs/SPEC.md).
10
+
9
11
  ## Installation
10
12
 
11
13
  ```sh
@@ -37,7 +37,16 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
37
37
  private var hasLaidOut = false
38
38
 
39
39
  // MARK: - Props (required props -> non-optional in generated spec)
40
- var percents: [Double] = [] { didSet { scheduleLayout() } }
40
+ private var imperativeDepth = false
41
+ private var displayPercents: [Double] = []
42
+ var percents: [Double] {
43
+ get { displayPercents }
44
+ set {
45
+ guard !imperativeDepth else { return }
46
+ displayPercents = newValue
47
+ scheduleLayout()
48
+ }
49
+ }
41
50
  var rowHeight: Double = 0 { didSet { scheduleLayout() } }
42
51
  var rowMarginTop: Double = 0 { didSet { scheduleLayout() } }
43
52
  var barInset: Double = 0 { didSet { scheduleLayout() } }
@@ -56,8 +65,25 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
56
65
  private var cachedColor: CGColor = UIColor.clear.cgColor
57
66
 
58
67
  // MARK: - Text props
59
- var prices: [String] = [] { didSet { scheduleLayout() } }
60
- var sizes: [String] = [] { didSet { scheduleLayout() } }
68
+ private var imperativeText = false
69
+ private var displayPrices: [String] = []
70
+ private var displaySizes: [String] = []
71
+ var prices: [String] {
72
+ get { displayPrices }
73
+ set {
74
+ guard !imperativeText else { return }
75
+ displayPrices = newValue
76
+ scheduleLayout()
77
+ }
78
+ }
79
+ var sizes: [String] {
80
+ get { displaySizes }
81
+ set {
82
+ guard !imperativeText else { return }
83
+ displaySizes = newValue
84
+ scheduleLayout()
85
+ }
86
+ }
61
87
  var priceFontSize: Double = 11 { didSet { scheduleLayout() } }
62
88
  var sizeFontSize: Double = 11 { didSet { scheduleLayout() } }
63
89
  var textInset: Double = 0 { didSet { scheduleLayout() } }
@@ -111,32 +137,52 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
111
137
  /// path the prop uses — only the data source differs.
112
138
  func setDepth(buffer: ArrayBuffer) throws {
113
139
  let count = buffer.size / 4
114
- guard count > 0 else {
115
- percents = []
116
- return
117
- }
118
- // `buffer.data` is a `UInt8` pointer; reinterpret it as little-endian
119
- // Float32. iOS (arm/x86) is little-endian and JS `Float32Array` is too, so
120
- // an unaligned little-endian load matches the prop semantics exactly.
121
140
  var depths = [Double](repeating: 0, count: count)
122
- let raw = UnsafeRawPointer(buffer.data)
123
- for i in 0..<count {
124
- let bits = raw.loadUnaligned(fromByteOffset: i * 4, as: UInt32.self)
125
- depths[i] = Double(Float(bitPattern: UInt32(littleEndian: bits)))
141
+ if count > 0 {
142
+ // `buffer.data` is a `UInt8` pointer; reinterpret it as little-endian
143
+ // Float32. iOS (arm/x86) is little-endian and JS `Float32Array` is too, so
144
+ // an unaligned little-endian load matches the prop semantics exactly.
145
+ let raw = UnsafeRawPointer(buffer.data)
146
+ for i in 0..<count {
147
+ let bits = raw.loadUnaligned(fromByteOffset: i * 4, as: UInt32.self)
148
+ depths[i] = Double(Float(bitPattern: UInt32(littleEndian: bits)))
149
+ }
150
+ }
151
+ // Finish reading the caller's buffer before publishing Swift-owned values.
152
+ // Layout and its layer state are owned by the main thread.
153
+ if Thread.isMainThread {
154
+ publishDepth(depths)
155
+ } else {
156
+ DispatchQueue.main.async { [weak self] in self?.publishDepth(depths) }
126
157
  }
127
- // Feed through the single source of truth so the clamp/target/easing logic
128
- // is byte-for-byte identical to the `percents` prop path.
129
- percents = depths
158
+ }
159
+
160
+ private func publishDepth(_ depths: [Double]) {
161
+ imperativeDepth = true
162
+ displayPercents = depths
163
+ scheduleLayout()
130
164
  }
131
165
 
132
166
  /// Imperatively update the per-row price/size text — companion to `setDepth`
133
167
  /// for the text layer. Bypasses the high-frequency `prices`/`sizes` prop write
134
- /// path; assigning them feeds through the identical `didSet` → `scheduleLayout`
135
- /// → `performLayout` path the prop setters use (REACT-NATIVE-1JZ). Call in the
168
+ /// path; publication feeds through the same `scheduleLayout` → `performLayout`
169
+ /// path the prop setters use (REACT-NATIVE-1JZ). Call in the
136
170
  /// SAME frame as `setDepth` so bar fill and row text share one source frame.
137
171
  func setText(prices: [String], sizes: [String]) throws {
138
- self.prices = prices
139
- self.sizes = sizes
172
+ if Thread.isMainThread {
173
+ publishText(prices: prices, sizes: sizes)
174
+ } else {
175
+ DispatchQueue.main.async { [weak self] in
176
+ self?.publishText(prices: prices, sizes: sizes)
177
+ }
178
+ }
179
+ }
180
+
181
+ private func publishText(prices: [String], sizes: [String]) {
182
+ imperativeText = true
183
+ displayPrices = prices
184
+ displaySizes = sizes
185
+ scheduleLayout()
140
186
  }
141
187
 
142
188
  private func handleTap(atY y: CGFloat) {
@@ -150,8 +196,7 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
150
196
  }
151
197
 
152
198
  private func scheduleLayout() {
153
- // Imperative setDepth/setText run on the JS thread (off-main); `setNeedsLayout`
154
- // must be issued on the main thread.
199
+ // UIKit layout requests belong on the main thread.
155
200
  if Thread.isMainThread {
156
201
  view.setNeedsLayout()
157
202
  } else {
@@ -66,8 +66,16 @@ final class HybridPerpSideRatio: HybridPerpSideRatioSpec {
66
66
  /// `scheduleLayout` → `performLayout` clamp/split/display-link path the prop
67
67
  /// setters use — only the data source differs.
68
68
  func setRatio(bidPercentage: Double, askPercentage: Double) throws {
69
- self.bidPercentage = bidPercentage
70
- self.askPercentage = askPercentage
69
+ if Thread.isMainThread {
70
+ self.bidPercentage = bidPercentage
71
+ self.askPercentage = askPercentage
72
+ } else {
73
+ DispatchQueue.main.async { [weak self] in
74
+ guard let self else { return }
75
+ self.bidPercentage = bidPercentage
76
+ self.askPercentage = askPercentage
77
+ }
78
+ }
71
79
  }
72
80
 
73
81
  private func scheduleLayout() {
@@ -0,0 +1,5 @@
1
+ #pragma once
2
+
3
+ #ifdef __cplusplus
4
+ #include <NitroModules/ArrayBufferHolder.hpp>
5
+ #endif
@@ -0,0 +1,295 @@
1
+ import NitroModules
2
+ import UIKit
3
+ import XCTest
4
+
5
+ @testable import PerpDepthBar
6
+
7
+ @MainActor
8
+ final class HybridPerpDepthBarsTests: XCTestCase {
9
+ func testOffMainUpdatesWaitForMainAndSnapshotBufferBytes() throws {
10
+ let depth = makeDepthView()
11
+ depth.percents = [10]
12
+ depth.prices = ["old price"]
13
+ depth.sizes = ["old size"]
14
+ depth.view.layoutIfNeeded()
15
+
16
+ let buffer = makeBuffer([25, 75])
17
+ runOffMain {
18
+ try! depth.setDepth(buffer: buffer)
19
+ try! depth.setText(prices: ["25", "75"], sizes: ["2.5"])
20
+ }
21
+
22
+ // The worker returned while the main queue was blocked by runOffMain.
23
+ XCTAssertEqual(depth.percents, [10])
24
+ XCTAssertEqual(depth.prices, ["old price"])
25
+ XCTAssertEqual(depth.sizes, ["old size"])
26
+
27
+ write([90, 90], into: buffer)
28
+ drainMainQueue()
29
+ XCTAssertEqual(depth.percents, [25, 75])
30
+ XCTAssertEqual(depth.prices, ["25", "75"])
31
+ XCTAssertEqual(depth.sizes, ["2.5"])
32
+
33
+ depth.view.layoutIfNeeded()
34
+ let bars = barLayers(in: depth)
35
+ XCTAssertEqual(bars.count, 2)
36
+ XCTAssertEqual(Double(bars[0].transform.m11), 0.25, accuracy: 0.001)
37
+ XCTAssertEqual(Double(bars[1].transform.m11), 0.75, accuracy: 0.001)
38
+ XCTAssertEqual(textStrings(in: depth), ["25", "2.5", "75", ""])
39
+ }
40
+
41
+ func testOffMainEmptyDepthClearsOnlyAfterMainPublication() throws {
42
+ let depth = makeDepthView()
43
+ depth.percents = [60]
44
+ depth.prices = ["60"]
45
+ depth.sizes = ["6"]
46
+ depth.placeholderText = "--"
47
+ depth.placeholderRows = 2
48
+ depth.view.layoutIfNeeded()
49
+
50
+ let emptyBuffer = makeBuffer([])
51
+ runOffMain {
52
+ try! depth.setDepth(buffer: emptyBuffer)
53
+ try! depth.setText(prices: [], sizes: [])
54
+ }
55
+ XCTAssertEqual(depth.percents, [60])
56
+ XCTAssertEqual(depth.prices, ["60"])
57
+ XCTAssertEqual(depth.sizes, ["6"])
58
+
59
+ drainMainQueue()
60
+ depth.view.layoutIfNeeded()
61
+ XCTAssertTrue(depth.percents.isEmpty)
62
+ XCTAssertTrue(depth.prices.isEmpty)
63
+ XCTAssertTrue(depth.sizes.isEmpty)
64
+ XCTAssertEqual(barLayers(in: depth).count, 2)
65
+ XCTAssertEqual(textStrings(in: depth), ["--", "--", "--", "--"])
66
+ XCTAssertTrue(barLayers(in: depth).allSatisfy { $0.transform.m11 == 0 })
67
+ }
68
+
69
+ func testRapidGrowAndShrinkPublishInOrderWithRealLayers() throws {
70
+ let depth = makeDepthView()
71
+ depth.percents = [5]
72
+ depth.prices = ["initial"]
73
+ depth.sizes = ["initial"]
74
+
75
+ let grow = makeBuffer([10, 20, 30])
76
+ let shrink = makeBuffer([40])
77
+ let growAgain = makeBuffer([70, 80])
78
+ runOffMain {
79
+ try! depth.setDepth(buffer: grow)
80
+ try! depth.setText(prices: ["a", "b", "c"], sizes: ["1", "2", "3"])
81
+ try! depth.setDepth(buffer: shrink)
82
+ try! depth.setText(prices: ["d"], sizes: ["4"])
83
+ try! depth.setDepth(buffer: growAgain)
84
+ try! depth.setText(prices: ["e", "f"], sizes: ["7", "8"])
85
+ }
86
+ XCTAssertEqual(depth.percents, [5])
87
+ XCTAssertEqual(depth.prices, ["initial"])
88
+ XCTAssertEqual(depth.sizes, ["initial"])
89
+
90
+ drainMainQueue()
91
+ depth.view.layoutIfNeeded()
92
+ XCTAssertEqual(depth.percents, [70, 80])
93
+ XCTAssertEqual(depth.prices, ["e", "f"])
94
+ XCTAssertEqual(depth.sizes, ["7", "8"])
95
+ XCTAssertEqual(barLayers(in: depth).count, 2)
96
+ XCTAssertEqual(textStrings(in: depth), ["e", "7", "f", "8"])
97
+ }
98
+
99
+ func testMainThreadUpdatesRemainSynchronous() throws {
100
+ let depth = makeDepthView()
101
+ try depth.setDepth(buffer: makeBuffer([30, 50]))
102
+ try depth.setText(prices: ["p1", "p2"], sizes: ["s1", "s2"])
103
+
104
+ XCTAssertEqual(depth.percents, [30, 50])
105
+ XCTAssertEqual(depth.prices, ["p1", "p2"])
106
+ XCTAssertEqual(depth.sizes, ["s1", "s2"])
107
+ depth.view.layoutIfNeeded()
108
+ XCTAssertEqual(barLayers(in: depth).count, 2)
109
+ XCTAssertEqual(textStrings(in: depth), ["p1", "s1", "p2", "s2"])
110
+ }
111
+
112
+ func testImperativeDepthIgnoresLaterPropsWithoutTakingTextOwnership() throws {
113
+ let depth = makeDepthView()
114
+ depth.percents = [10]
115
+ depth.percents = [20]
116
+ XCTAssertEqual(depth.percents, [20])
117
+
118
+ try depth.setDepth(buffer: makeBuffer([35]))
119
+ depth.percents = []
120
+ depth.percents = [99, 99]
121
+ depth.prices = ["prop price"]
122
+ depth.sizes = ["prop size"]
123
+ depth.view.layoutIfNeeded()
124
+
125
+ XCTAssertEqual(depth.percents, [35])
126
+ XCTAssertEqual(barLayers(in: depth).count, 1)
127
+ XCTAssertEqual(Double(barLayers(in: depth)[0].transform.m11), 0.35, accuracy: 0.001)
128
+ XCTAssertEqual(textStrings(in: depth), ["prop price", "prop size"])
129
+
130
+ try depth.setDepth(buffer: makeBuffer([]))
131
+ depth.percents = [99]
132
+ depth.view.layoutIfNeeded()
133
+ XCTAssertTrue(depth.percents.isEmpty)
134
+ XCTAssertTrue(barLayers(in: depth).isEmpty)
135
+ }
136
+
137
+ func testImperativeTextIgnoresLaterPropsWithoutTakingDepthOwnership() throws {
138
+ let depth = makeDepthView()
139
+ depth.prices = ["initial"]
140
+ depth.sizes = ["initial"]
141
+ depth.prices = ["updated"]
142
+ depth.sizes = ["updated"]
143
+ XCTAssertEqual(depth.prices, ["updated"])
144
+ XCTAssertEqual(depth.sizes, ["updated"])
145
+
146
+ runOffMain {
147
+ try! depth.setText(prices: ["imperative price"], sizes: ["imperative size"])
148
+ }
149
+ drainMainQueue()
150
+ depth.prices = []
151
+ depth.sizes = []
152
+ depth.prices = ["stale price"]
153
+ depth.sizes = ["stale size"]
154
+ depth.percents = [40]
155
+ depth.view.layoutIfNeeded()
156
+
157
+ XCTAssertEqual(depth.percents, [40])
158
+ XCTAssertEqual(textStrings(in: depth), ["imperative price", "imperative size"])
159
+
160
+ try depth.setText(prices: [], sizes: [])
161
+ depth.prices = ["stale"]
162
+ depth.sizes = ["stale"]
163
+ depth.view.layoutIfNeeded()
164
+ XCTAssertTrue(depth.prices.isEmpty)
165
+ XCTAssertTrue(depth.sizes.isEmpty)
166
+ XCTAssertEqual(textStrings(in: depth), ["", ""])
167
+ }
168
+
169
+ func testOffMainDepthSurvivesLaterFabricPropWrites() {
170
+ let depth = makeDepthView()
171
+ let buffer = makeBuffer([25, 75])
172
+ runOffMain {
173
+ try! depth.setDepth(buffer: buffer)
174
+ }
175
+ drainMainQueue()
176
+ depth.percents = []
177
+ depth.view.layoutIfNeeded()
178
+ XCTAssertEqual(depth.percents, [25, 75])
179
+ XCTAssertEqual(barLayers(in: depth).count, 2)
180
+ }
181
+
182
+ func testRatioPairPublishesTogetherOnMainWithRealLayers() throws {
183
+ let ratio = HybridPerpSideRatio()
184
+ ratio.view.frame = CGRect(x: 0, y: 0, width: 202, height: 20)
185
+ ratio.reducedMotion = true
186
+ ratio.view.layoutIfNeeded()
187
+
188
+ runOffMain {
189
+ try! ratio.setRatio(bidPercentage: 20, askPercentage: 80)
190
+ try! ratio.setRatio(bidPercentage: 70, askPercentage: 30)
191
+ }
192
+ XCTAssertEqual(ratio.bidPercentage, 50)
193
+ XCTAssertEqual(ratio.askPercentage, 50)
194
+ drainMainQueue()
195
+ ratio.view.layoutIfNeeded()
196
+ XCTAssertEqual(ratio.bidPercentage, 70)
197
+ XCTAssertEqual(ratio.askPercentage, 30)
198
+ let layers = try XCTUnwrap(ratio.view.layer.sublayers)
199
+ XCTAssertEqual(layers[0].frame.width, 140, accuracy: 0.001)
200
+ XCTAssertEqual(layers[1].frame.width, 60, accuracy: 0.001)
201
+
202
+ try ratio.setRatio(bidPercentage: 30, askPercentage: 70)
203
+ XCTAssertEqual(ratio.bidPercentage, 30)
204
+ XCTAssertEqual(ratio.askPercentage, 70)
205
+ }
206
+
207
+ func testQueuedRatioUpdateDoesNotRetainDisposedView() {
208
+ var ratio: HybridPerpSideRatio? = HybridPerpSideRatio()
209
+ weak var weakRatio = ratio
210
+ runOffMain {
211
+ try! ratio?.setRatio(bidPercentage: 20, askPercentage: 80)
212
+ }
213
+ ratio = nil
214
+ XCTAssertNil(weakRatio)
215
+ drainMainQueue()
216
+ XCTAssertNil(weakRatio)
217
+ }
218
+
219
+ func testQueuedUpdateDoesNotRetainDisposedView() {
220
+ final class OwnerBox {
221
+ var owner: HybridPerpDepthBars?
222
+
223
+ init(_ owner: HybridPerpDepthBars) {
224
+ self.owner = owner
225
+ }
226
+ }
227
+
228
+ let box = OwnerBox(makeDepthView())
229
+ weak var weakOwner: HybridPerpDepthBars?
230
+ weakOwner = box.owner
231
+ let buffer = makeBuffer([35])
232
+ runOffMain {
233
+ let owner = box.owner
234
+ try! owner?.setDepth(buffer: buffer)
235
+ try! owner?.setText(prices: ["35"], sizes: ["3.5"])
236
+ }
237
+
238
+ box.owner = nil
239
+ XCTAssertNil(weakOwner)
240
+ drainMainQueue()
241
+ XCTAssertNil(weakOwner)
242
+ }
243
+
244
+ private func makeDepthView() -> HybridPerpDepthBars {
245
+ let depth = HybridPerpDepthBars()
246
+ depth.view.frame = CGRect(x: 0, y: 0, width: 240, height: 160)
247
+ depth.rowHeight = 28
248
+ depth.rowMarginTop = 4
249
+ depth.reducedMotion = true
250
+ return depth
251
+ }
252
+
253
+ private func makeBuffer(_ values: [Float]) -> ArrayBuffer {
254
+ let buffer = ArrayBuffer.allocate(size: values.count * MemoryLayout<UInt32>.size)
255
+ write(values, into: buffer)
256
+ return buffer
257
+ }
258
+
259
+ private func write(_ values: [Float], into buffer: ArrayBuffer) {
260
+ precondition(buffer.size == values.count * MemoryLayout<UInt32>.size)
261
+ for (index, value) in values.enumerated() {
262
+ var bits = value.bitPattern.littleEndian
263
+ withUnsafeBytes(of: &bits) { bytes in
264
+ for byte in 0..<MemoryLayout<UInt32>.size {
265
+ buffer.data[index * MemoryLayout<UInt32>.size + byte] = bytes[byte]
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ private func runOffMain(_ update: @escaping () -> Void) {
272
+ let finished = DispatchSemaphore(value: 0)
273
+ DispatchQueue.global(qos: .userInitiated).async {
274
+ update()
275
+ finished.signal()
276
+ }
277
+ XCTAssertEqual(finished.wait(timeout: .now() + 2), .success)
278
+ }
279
+
280
+ private func drainMainQueue() {
281
+ let published = expectation(description: "main-queue depth publication")
282
+ DispatchQueue.main.async { published.fulfill() }
283
+ wait(for: [published], timeout: 2)
284
+ }
285
+
286
+ private func barLayers(in depth: HybridPerpDepthBars) -> [CALayer] {
287
+ (depth.view.layer.sublayers ?? []).filter { !($0 is CATextLayer) }
288
+ }
289
+
290
+ private func textStrings(in depth: HybridPerpDepthBars) -> [String] {
291
+ (depth.view.layer.sublayers ?? [])
292
+ .compactMap { $0 as? CATextLayer }
293
+ .compactMap { $0.string as? String }
294
+ }
295
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-perp-depth-bar",
3
- "version": "6.7.0-11",
3
+ "version": "6.7.0-13",
4
4
  "description": "Native order-book depth bars and bid/ask ratio views for OneKey",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",