@onekeyfe/react-native-perp-depth-bar 3.0.42 → 3.0.43

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.
@@ -1,18 +1,25 @@
1
1
  package com.margelo.nitro.perpdepthbar
2
2
 
3
- import android.animation.ValueAnimator
4
3
  import android.graphics.Canvas
5
4
  import android.graphics.Paint
5
+ import android.view.Choreographer
6
6
  import android.view.MotionEvent
7
7
  import android.view.View
8
- import android.view.animation.PathInterpolator
9
8
  import com.facebook.proguard.annotations.DoNotStrip
10
9
  import com.facebook.react.uimanager.ThemedReactContext
10
+ import kotlin.math.abs
11
+ import kotlin.math.exp
11
12
 
12
13
  /**
13
- * Renders an entire column of order-book depth bars for one side. Draws N
14
- * rects whose horizontal fill fraction animates on the UI thread via a single
15
- * [ValueAnimator], replacing N reanimated `DepthBar` instances.
14
+ * Renders an entire column of order-book depth bars for one side. Draws N rects
15
+ * whose horizontal fill fraction is eased on the UI thread.
16
+ *
17
+ * Animation model: a single [Choreographer] frame loop continuously eases every
18
+ * row's on-screen fill toward its latest target (exponential smoothing). New
19
+ * data just retargets — the loop keeps gliding and only stops once every row has
20
+ * settled. Updates therefore chain into continuous motion regardless of data
21
+ * cadence, and a fluctuating row count no longer freezes the column (only a
22
+ * coin/tick switch snaps, via `epoch`).
16
23
  */
17
24
  @DoNotStrip
18
25
  class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBarsSpec() {
@@ -22,14 +29,18 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
22
29
  private val pricePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.LEFT }
23
30
  private val sizePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.RIGHT }
24
31
 
25
- private var current = DoubleArray(0)
26
- private var start = DoubleArray(0)
27
- private var target = DoubleArray(0)
32
+ private var current = DoubleArray(0) // on-screen fill fraction per row (0..1)
33
+ private var target = DoubleArray(0) // latest target fill fraction per row
28
34
 
29
35
  private var lastEpoch = Double.NaN
30
36
  private var hasDrawn = false
31
37
  private var isDisposed = false
32
- private var animator: ValueAnimator? = null
38
+
39
+ // Continuous-easing frame loop.
40
+ private val choreographer = Choreographer.getInstance()
41
+ private var frameScheduled = false
42
+ private var lastFrameNanos = 0L
43
+ private val frameCallback = Choreographer.FrameCallback { now -> onFrame(now) }
33
44
 
34
45
  override val view: View = object : View(context) {
35
46
  override fun onDraw(canvas: Canvas) {
@@ -153,7 +164,7 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
153
164
 
154
165
  /**
155
166
  * Called by Nitro once after a prop-update transaction. Recompute targets and
156
- * (re)start the animation here so a multi-prop update animates once.
167
+ * (re)start the easing loop here so a multi-prop update retargets once.
157
168
  */
158
169
  override fun afterUpdate() {
159
170
  super.afterUpdate()
@@ -164,13 +175,11 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
164
175
  private fun syncTargets() {
165
176
  val count = percents.size
166
177
  val epochChanged = epoch != lastEpoch
167
- val countChanged = target.size != count
168
- val snap = !hasDrawn || reducedMotion || epochChanged || countChanged
169
-
178
+ val snap = !hasDrawn || reducedMotion || epochChanged
170
179
  val newTarget = DoubleArray(count) { clampFrac(percents[it]) }
171
180
 
172
181
  if (snap) {
173
- animator?.cancel()
182
+ cancelFrameLoop()
174
183
  current = newTarget.copyOf()
175
184
  target = newTarget
176
185
  lastEpoch = epoch
@@ -179,27 +188,63 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
179
188
  return
180
189
  }
181
190
 
182
- // Animate each row from its current value to the new target.
183
- if (current.size != count) current = current.copyOf(count)
184
- start = current.copyOf()
191
+ // Keep existing rows easing; brand-new rows appear at their target (no
192
+ // grow-from-zero flash). Then retarget and let the frame loop glide.
193
+ if (current.size != count) {
194
+ val resized = DoubleArray(count) { i -> if (i < current.size) current[i] else newTarget[i] }
195
+ current = resized
196
+ }
185
197
  target = newTarget
186
198
  lastEpoch = epoch
199
+ scheduleFrameLoop()
200
+ }
187
201
 
188
- animator?.cancel()
189
- animator = ValueAnimator.ofFloat(0f, 1f).apply {
190
- duration = DURATION_MS
191
- interpolator = EASE_OUT_CUBIC
192
- addUpdateListener { a ->
193
- if (isDisposed) return@addUpdateListener
194
- val f = a.animatedFraction
195
- for (i in current.indices) {
196
- val s = if (i < start.size) start[i] else 0.0
197
- val t = if (i < target.size) target[i] else 0.0
198
- current[i] = s + (t - s) * f
199
- }
200
- view.invalidate()
202
+ // MARK: - Continuous easing (Choreographer)
203
+ private fun scheduleFrameLoop() {
204
+ if (frameScheduled || isDisposed) return
205
+ val n = minOf(current.size, target.size)
206
+ var needs = false
207
+ for (i in 0 until n) {
208
+ if (abs(target[i] - current[i]) > 0.001) { needs = true; break }
209
+ }
210
+ if (!needs) { view.invalidate(); return }
211
+ frameScheduled = true
212
+ lastFrameNanos = 0L
213
+ choreographer.postFrameCallback(frameCallback)
214
+ }
215
+
216
+ private fun cancelFrameLoop() {
217
+ if (frameScheduled) {
218
+ choreographer.removeFrameCallback(frameCallback)
219
+ frameScheduled = false
220
+ }
221
+ lastFrameNanos = 0L
222
+ }
223
+
224
+ private fun onFrame(now: Long) {
225
+ if (isDisposed) { frameScheduled = false; return }
226
+ val dt = if (lastFrameNanos > 0L) (now - lastFrameNanos) / 1e9 else 1.0 / 60.0
227
+ lastFrameNanos = now
228
+ val alpha = 1.0 - exp(-maxOf(dt, 0.0) / TAU_SECONDS)
229
+
230
+ var settled = true
231
+ val n = minOf(current.size, target.size)
232
+ for (i in 0 until n) {
233
+ val d = target[i] - current[i]
234
+ if (abs(d) <= 0.001) {
235
+ current[i] = target[i]
236
+ } else {
237
+ current[i] += d * alpha
238
+ settled = false
201
239
  }
202
- start()
240
+ }
241
+ view.invalidate()
242
+
243
+ if (settled) {
244
+ frameScheduled = false
245
+ lastFrameNanos = 0L
246
+ } else {
247
+ choreographer.postFrameCallback(frameCallback)
203
248
  }
204
249
  }
205
250
 
@@ -252,12 +297,15 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
252
297
 
253
298
  override fun dispose() {
254
299
  isDisposed = true
255
- animator?.cancel()
256
- animator = null
300
+ cancelFrameLoop()
257
301
  }
258
302
 
259
303
  companion object {
260
- private const val DURATION_MS = 260L
261
- private val EASE_OUT_CUBIC = PathInterpolator(0.33f, 1f, 0.68f, 1f)
304
+ /**
305
+ * Exponential-smoothing time constant (seconds); mirror of
306
+ * PerpTiming.depthBarSmoothingTauSeconds on iOS. ~63% of any change covered
307
+ * in TAU, ~95% in 3*TAU.
308
+ */
309
+ private const val TAU_SECONDS = 0.10
262
310
  }
263
311
  }
@@ -1,15 +1,22 @@
1
1
  package com.margelo.nitro.perpdepthbar
2
2
 
3
- import android.animation.ValueAnimator
4
3
  import android.graphics.Canvas
5
4
  import android.graphics.Paint
6
5
  import android.graphics.RectF
6
+ import android.view.Choreographer
7
7
  import android.view.View
8
- import android.view.animation.PathInterpolator
9
8
  import com.facebook.proguard.annotations.DoNotStrip
10
9
  import com.facebook.react.uimanager.ThemedReactContext
11
-
12
- /** Two horizontal segments whose widths animate to the bid/ask ratio. */
10
+ import kotlin.math.abs
11
+ import kotlin.math.exp
12
+
13
+ /**
14
+ * Two horizontal segments whose widths ease to the bid/ask ratio.
15
+ *
16
+ * Animation model mirrors the depth bars: a single [Choreographer] frame loop
17
+ * continuously eases the split fraction toward its latest target (exponential
18
+ * smoothing). New data just retargets; the loop stops once settled.
19
+ */
13
20
  @DoNotStrip
14
21
  class HybridPerpSideRatio(val context: ThemedReactContext) : HybridPerpSideRatioSpec() {
15
22
 
@@ -19,11 +26,15 @@ class HybridPerpSideRatio(val context: ThemedReactContext) : HybridPerpSideRatio
19
26
 
20
27
  // Animated split fraction (bid share of the available width), 0..1.
21
28
  private var currentSplit = 0.5
22
- private var startSplit = 0.5
23
29
  private var targetSplit = 0.5
24
30
  private var hasDrawn = false
25
31
  private var isDisposed = false
26
- private var animator: ValueAnimator? = null
32
+
33
+ // Continuous-easing frame loop.
34
+ private val choreographer = Choreographer.getInstance()
35
+ private var frameScheduled = false
36
+ private var lastFrameNanos = 0L
37
+ private val frameCallback = Choreographer.FrameCallback { now -> onFrame(now) }
27
38
 
28
39
  override val view: View = object : View(context) {
29
40
  override fun onDraw(canvas: Canvas) {
@@ -84,26 +95,51 @@ class HybridPerpSideRatio(val context: ThemedReactContext) : HybridPerpSideRatio
84
95
  val split = bid / (bid + ask)
85
96
  val snap = !hasDrawn || reducedMotion
86
97
  if (snap) {
87
- animator?.cancel()
98
+ cancelFrameLoop()
88
99
  currentSplit = split
89
100
  targetSplit = split
90
101
  hasDrawn = true
91
102
  view.invalidate()
92
103
  return
93
104
  }
94
- startSplit = currentSplit
95
105
  targetSplit = split
96
- animator?.cancel()
97
- animator = ValueAnimator.ofFloat(0f, 1f).apply {
98
- duration = DURATION_MS
99
- interpolator = EASE_OUT_CUBIC
100
- addUpdateListener { a ->
101
- if (isDisposed) return@addUpdateListener
102
- currentSplit = startSplit + (targetSplit - startSplit) * a.animatedFraction
103
- view.invalidate()
104
- }
105
- start()
106
+ scheduleFrameLoop()
107
+ }
108
+
109
+ // MARK: - Continuous easing (Choreographer)
110
+ private fun scheduleFrameLoop() {
111
+ if (frameScheduled || isDisposed) return
112
+ if (abs(targetSplit - currentSplit) <= 0.0005) { view.invalidate(); return }
113
+ frameScheduled = true
114
+ lastFrameNanos = 0L
115
+ choreographer.postFrameCallback(frameCallback)
116
+ }
117
+
118
+ private fun cancelFrameLoop() {
119
+ if (frameScheduled) {
120
+ choreographer.removeFrameCallback(frameCallback)
121
+ frameScheduled = false
122
+ }
123
+ lastFrameNanos = 0L
124
+ }
125
+
126
+ private fun onFrame(now: Long) {
127
+ if (isDisposed) { frameScheduled = false; return }
128
+ val dt = if (lastFrameNanos > 0L) (now - lastFrameNanos) / 1e9 else 1.0 / 60.0
129
+ lastFrameNanos = now
130
+ val alpha = 1.0 - exp(-maxOf(dt, 0.0) / TAU_SECONDS)
131
+
132
+ val d = targetSplit - currentSplit
133
+ if (abs(d) <= 0.0005) {
134
+ currentSplit = targetSplit
135
+ view.invalidate()
136
+ frameScheduled = false
137
+ lastFrameNanos = 0L
138
+ return
106
139
  }
140
+ currentSplit += d * alpha
141
+ view.invalidate()
142
+ choreographer.postFrameCallback(frameCallback)
107
143
  }
108
144
 
109
145
  private fun drawSegments(canvas: Canvas) {
@@ -128,12 +164,14 @@ class HybridPerpSideRatio(val context: ThemedReactContext) : HybridPerpSideRatio
128
164
 
129
165
  override fun dispose() {
130
166
  isDisposed = true
131
- animator?.cancel()
132
- animator = null
167
+ cancelFrameLoop()
133
168
  }
134
169
 
135
170
  companion object {
136
- private const val DURATION_MS = 300L
137
- private val EASE_OUT_CUBIC = PathInterpolator(0.33f, 1f, 0.68f, 1f)
171
+ /**
172
+ * Exponential-smoothing time constant (seconds); mirror of
173
+ * PerpTiming.sideRatioSmoothingTauSeconds on iOS.
174
+ */
175
+ private const val TAU_SECONDS = 0.12
138
176
  }
139
177
  }
@@ -2,8 +2,15 @@ import Foundation
2
2
  import UIKit
3
3
 
4
4
  /// Renders an entire column of order-book depth bars for one side (asks/bids).
5
- /// Each row is a CALayer whose horizontal `scaleX` fill is animated on the UI
5
+ /// Each row is a CALayer whose horizontal `scaleX` fill is eased on the UI
6
6
  /// thread, replacing N reanimated `DepthBar` instances.
7
+ ///
8
+ /// Animation model: a single `CADisplayLink` continuously eases every row's
9
+ /// on-screen scale toward its latest target (exponential smoothing). New data
10
+ /// just retargets — the link keeps gliding and only stops once every row has
11
+ /// settled. This makes updates chain into continuous motion regardless of data
12
+ /// cadence, and a fluctuating row count no longer freezes the column (only a
13
+ /// coin/tick switch snaps, via `epoch`).
7
14
  final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
8
15
 
9
16
  // MARK: - HybridView
@@ -17,8 +24,13 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
17
24
  private var sizeLayers: [CATextLayer] = []
18
25
  private let textScale = UIScreen.main.scale
19
26
 
27
+ // MARK: - Continuous-easing state
28
+ private var currentScales: [CGFloat] = [] // on-screen scaleX per row (0...1)
29
+ private var targetScales: [CGFloat] = [] // latest target scaleX per row
30
+ private var displayLink: CADisplayLink?
31
+ private var lastTick: CFTimeInterval = 0
32
+
20
33
  // MARK: - State for animation decisions
21
- private var lastPercents: [Double] = []
22
34
  private var lastEpoch: Double = .nan
23
35
  private var hasLaidOut = false
24
36
 
@@ -75,6 +87,10 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
75
87
  }
76
88
  }
77
89
 
90
+ deinit {
91
+ stopDisplayLink()
92
+ }
93
+
78
94
  private func handleTap(atY y: CGFloat) {
79
95
  let step = CGFloat(rowHeight + rowMarginTop)
80
96
  guard step > 0 else { return }
@@ -89,7 +105,7 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
89
105
  view.setNeedsLayout()
90
106
  }
91
107
 
92
- // MARK: - Layout + animation
108
+ // MARK: - Layout + retarget
93
109
  private func performLayout() {
94
110
  let bounds = view.bounds
95
111
  guard bounds.width > 0 else { return }
@@ -98,10 +114,10 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
98
114
  syncLayerCount(count)
99
115
  syncTextLayerCount(count)
100
116
 
101
- // Decide whether this update should animate or snap.
117
+ // Only a coin/tick switch (or reduced motion / first paint) snaps. A
118
+ // fluctuating row count no longer freezes the whole column — see below.
102
119
  let epochChanged = epoch != lastEpoch
103
- let countChanged = lastPercents.count != count
104
- let snap = !hasLaidOut || reducedMotion || epochChanged || countChanged
120
+ let snap = !hasLaidOut || reducedMotion || epochChanged
105
121
 
106
122
  let rowW = bounds.width
107
123
  let h = CGFloat(rowHeight)
@@ -110,32 +126,41 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
110
126
  let isRight = origin == "right"
111
127
  let anchorX: CGFloat = isRight ? 1 : 0
112
128
 
129
+ // Geometry never animates — set it directly each pass.
113
130
  CATransaction.begin()
114
- CATransaction.setDisableActions(true) // suppress implicit anims for geometry
115
-
131
+ CATransaction.setDisableActions(true)
116
132
  for i in 0..<count {
117
133
  let layer = barLayers[i]
118
134
  let rowTop = CGFloat(rowMarginTop) + CGFloat(i) * (h + CGFloat(rowMarginTop))
119
135
  let rowCenterY = rowTop + h / 2
120
-
121
136
  layer.anchorPoint = CGPoint(x: anchorX, y: 0.5)
122
137
  layer.bounds = CGRect(x: 0, y: 0, width: rowW, height: barH)
123
138
  layer.position = CGPoint(x: isRight ? rowW : 0, y: rowCenterY)
124
139
  layer.backgroundColor = cachedColor
140
+ }
141
+ CATransaction.commit()
125
142
 
126
- let target = clampScale(percents[i])
127
- let changed = i >= lastPercents.count || lastPercents[i] != percents[i]
143
+ var newTargets = [CGFloat](repeating: 0, count: count)
144
+ for i in 0..<count { newTargets[i] = clampScale(percents[i]) }
128
145
 
129
- if snap || !changed {
130
- layer.removeAnimation(forKey: "fill")
131
- layer.transform = CATransform3DMakeScale(target, 1, 1)
132
- } else {
133
- animateScaleX(layer: layer, to: target)
146
+ if snap {
147
+ stopDisplayLink()
148
+ currentScales = newTargets
149
+ targetScales = newTargets
150
+ applyScales()
151
+ } else {
152
+ // Keep existing rows easing; brand-new rows appear at their target (no
153
+ // grow-from-zero flash). Then retarget and let the display link glide.
154
+ if currentScales.count < count {
155
+ for i in currentScales.count..<count { currentScales.append(newTargets[i]) }
156
+ } else if currentScales.count > count {
157
+ currentScales.removeLast(currentScales.count - count)
134
158
  }
159
+ targetScales = newTargets
160
+ applyScales() // render current immediately (geometry/new rows) this frame
161
+ startDisplayLinkIfNeeded()
135
162
  }
136
163
 
137
- CATransaction.commit()
138
-
139
164
  // Text never animates — snap frames/strings each layout pass.
140
165
  CATransaction.begin()
141
166
  CATransaction.setDisableActions(true)
@@ -163,11 +188,78 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
163
188
  }
164
189
  CATransaction.commit()
165
190
 
166
- lastPercents = percents
167
191
  lastEpoch = epoch
168
192
  hasLaidOut = true
169
193
  }
170
194
 
195
+ /// Writes `currentScales` into every bar layer's transform (no implicit anim).
196
+ private func applyScales() {
197
+ let n = min(currentScales.count, barLayers.count)
198
+ guard n > 0 else { return }
199
+ CATransaction.begin()
200
+ CATransaction.setDisableActions(true)
201
+ for i in 0..<n {
202
+ barLayers[i].transform = CATransform3DMakeScale(currentScales[i], 1, 1)
203
+ }
204
+ CATransaction.commit()
205
+ }
206
+
207
+ // MARK: - Continuous easing (display link)
208
+ private func startDisplayLinkIfNeeded() {
209
+ if displayLink != nil { return }
210
+ let n = min(currentScales.count, targetScales.count)
211
+ var needs = false
212
+ for i in 0..<n where abs(targetScales[i] - currentScales[i]) > 0.001 {
213
+ needs = true
214
+ break
215
+ }
216
+ guard needs else { return }
217
+ lastTick = 0
218
+ let proxy = DisplayLinkProxy(self)
219
+ let link = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick(_:)))
220
+ link.add(to: .main, forMode: .common)
221
+ displayLink = link
222
+ }
223
+
224
+ private func stopDisplayLink() {
225
+ displayLink?.invalidate()
226
+ displayLink = nil
227
+ lastTick = 0
228
+ }
229
+
230
+ /// Called by the display link each frame. Eases every row toward its target.
231
+ func handleTick(_ link: CADisplayLink) {
232
+ let now = link.timestamp
233
+ let dt = lastTick > 0 ? now - lastTick : link.duration
234
+ lastTick = now
235
+ let tau = PerpTiming.depthBarSmoothingTauSeconds
236
+ let alpha = CGFloat(1 - exp(-max(dt, 0) / max(tau, 0.0001)))
237
+
238
+ var settled = true
239
+ let n = min(min(currentScales.count, targetScales.count), barLayers.count)
240
+ CATransaction.begin()
241
+ CATransaction.setDisableActions(true)
242
+ for i in 0..<n {
243
+ let t = targetScales[i]
244
+ let c = currentScales[i]
245
+ let d = t - c
246
+ if abs(d) <= 0.001 {
247
+ if c != t {
248
+ currentScales[i] = t
249
+ barLayers[i].transform = CATransform3DMakeScale(t, 1, 1)
250
+ }
251
+ } else {
252
+ let nc = c + d * alpha
253
+ currentScales[i] = nc
254
+ barLayers[i].transform = CATransform3DMakeScale(nc, 1, 1)
255
+ settled = false
256
+ }
257
+ }
258
+ CATransaction.commit()
259
+
260
+ if settled { stopDisplayLink() }
261
+ }
262
+
171
263
  private func makeTextLayer(alignment: CATextLayerAlignmentMode) -> CATextLayer {
172
264
  let l = CATextLayer()
173
265
  l.contentsScale = textScale
@@ -195,28 +287,6 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
195
287
  }
196
288
  }
197
289
 
198
- private func animateScaleX(layer: CALayer, to target: CGFloat) {
199
- // Continue from the on-screen (presentation) value to avoid snap-back when
200
- // a new animation starts every tick (~10Hz).
201
- let current: CGFloat
202
- if let pres = layer.presentation() {
203
- current = pres.value(forKeyPath: "transform.scale.x") as? CGFloat
204
- ?? (layer.value(forKeyPath: "transform.scale.x") as? CGFloat ?? target)
205
- } else {
206
- current = layer.value(forKeyPath: "transform.scale.x") as? CGFloat ?? target
207
- }
208
-
209
- layer.transform = CATransform3DMakeScale(target, 1, 1) // model = final
210
-
211
- let anim = CABasicAnimation(keyPath: "transform.scale.x")
212
- anim.fromValue = current
213
- anim.toValue = target
214
- anim.duration = PerpTiming.depthBarDurationMs / 1000.0
215
- anim.timingFunction = PerpTiming.easeOutCubic()
216
- anim.isRemovedOnCompletion = true
217
- layer.add(anim, forKey: "fill")
218
- }
219
-
220
290
  private func syncLayerCount(_ count: Int) {
221
291
  if barLayers.count < count {
222
292
  for _ in barLayers.count..<count {
@@ -242,3 +312,11 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
242
312
  CGFloat(max(0, min(100, percent)) / 100.0)
243
313
  }
244
314
  }
315
+
316
+ /// Weak forwarder so the `CADisplayLink` (retained by the run loop) does not
317
+ /// retain `HybridPerpDepthBars`, allowing `deinit` to invalidate the link.
318
+ private final class DisplayLinkProxy {
319
+ weak var owner: HybridPerpDepthBars?
320
+ init(_ owner: HybridPerpDepthBars) { self.owner = owner }
321
+ @objc func tick(_ link: CADisplayLink) { owner?.handleTick(link) }
322
+ }
@@ -1,7 +1,11 @@
1
1
  import Foundation
2
2
  import UIKit
3
3
 
4
- /// Two horizontal segments whose widths animate to the bid/ask ratio.
4
+ /// Two horizontal segments whose widths ease to the bid/ask ratio.
5
+ ///
6
+ /// Animation model mirrors the depth bars: a single `CADisplayLink` continuously
7
+ /// eases the split fraction toward its latest target (exponential smoothing).
8
+ /// New data just retargets — the link keeps gliding and stops once settled.
5
9
  final class HybridPerpSideRatio: HybridPerpSideRatioSpec {
6
10
 
7
11
  // MARK: - HybridView
@@ -11,6 +15,12 @@ final class HybridPerpSideRatio: HybridPerpSideRatioSpec {
11
15
  private let askLayer = CALayer()
12
16
  private var hasLaidOut = false
13
17
 
18
+ // MARK: - Continuous-easing state (bid share of the available width, 0...1)
19
+ private var currentSplit: CGFloat = 0.5
20
+ private var targetSplit: CGFloat = 0.5
21
+ private var displayLink: CADisplayLink?
22
+ private var lastTick: CFTimeInterval = 0
23
+
14
24
  // MARK: - Props
15
25
  var bidPercentage: Double = 50 { didSet { scheduleLayout() } }
16
26
  var askPercentage: Double = 50 { didSet { scheduleLayout() } }
@@ -43,6 +53,10 @@ final class HybridPerpSideRatio: HybridPerpSideRatioSpec {
43
53
  }
44
54
  }
45
55
 
56
+ deinit {
57
+ stopDisplayLink()
58
+ }
59
+
46
60
  private func scheduleLayout() {
47
61
  view.setNeedsLayout()
48
62
  }
@@ -52,67 +66,88 @@ final class HybridPerpSideRatio: HybridPerpSideRatioSpec {
52
66
  guard bounds.width > 0 else { return }
53
67
 
54
68
  let h = CGFloat(segmentHeight)
55
- let y = (bounds.height - h) / 2
56
- let g = CGFloat(gap)
57
- let available = max(bounds.width - g, 0)
69
+ CATransaction.begin()
70
+ CATransaction.setDisableActions(true)
71
+ bidLayer.cornerRadius = min(CGFloat(cornerRadius), h / 2)
72
+ askLayer.cornerRadius = min(CGFloat(cornerRadius), h / 2)
73
+ CATransaction.commit()
58
74
 
59
75
  let bid = max(bidPercentage, 1)
60
76
  let ask = max(askPercentage, 1)
61
- let total = bid + ask
62
- let bidW = available * CGFloat(bid / total)
63
- let askW = available - bidW
64
- let radius = CGFloat(cornerRadius)
65
-
66
- let bidFrame = CGRect(x: 0, y: y, width: bidW, height: h)
67
- let askFrame = CGRect(x: bidW + g, y: y, width: askW, height: h)
77
+ let target = CGFloat(bid / (bid + ask))
68
78
 
69
79
  let snap = !hasLaidOut || reducedMotion
70
-
71
- CATransaction.begin()
72
- CATransaction.setDisableActions(true)
73
- bidLayer.cornerRadius = min(radius, h / 2)
74
- askLayer.cornerRadius = min(radius, h / 2)
75
- CATransaction.commit()
76
-
77
80
  if snap {
78
- CATransaction.begin()
79
- CATransaction.setDisableActions(true)
80
- bidLayer.frame = bidFrame
81
- askLayer.frame = askFrame
82
- CATransaction.commit()
81
+ stopDisplayLink()
82
+ currentSplit = target
83
+ targetSplit = target
84
+ layoutSegments(currentSplit)
83
85
  } else {
84
- animateFrame(layer: bidLayer, to: bidFrame)
85
- animateFrame(layer: askLayer, to: askFrame)
86
+ targetSplit = target
87
+ layoutSegments(currentSplit) // apply current + any geometry change now
88
+ startDisplayLinkIfNeeded()
86
89
  }
87
90
  hasLaidOut = true
88
91
  }
89
92
 
90
- private func animateFrame(layer: CALayer, to frame: CGRect) {
91
- let duration = PerpTiming.sideRatioDurationMs / 1000.0
92
- let timing = PerpTiming.easeOutCubic()
93
-
94
- let fromBounds = layer.presentation()?.bounds ?? layer.bounds
95
- let fromPos = layer.presentation()?.position ?? layer.position
96
-
97
- let newBounds = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
98
- let newPos = CGPoint(x: frame.midX, y: frame.midY)
99
-
100
- layer.bounds = newBounds
101
- layer.position = newPos
93
+ /// Lays both segment frames out from a split fraction (no implicit anim).
94
+ private func layoutSegments(_ split: CGFloat) {
95
+ let bounds = view.bounds
96
+ guard bounds.width > 0 else { return }
97
+ let h = CGFloat(segmentHeight)
98
+ let y = (bounds.height - h) / 2
99
+ let g = CGFloat(gap)
100
+ let available = max(bounds.width - g, 0)
101
+ let bidW = available * split
102
+ let askW = max(available - bidW, 0)
103
+ CATransaction.begin()
104
+ CATransaction.setDisableActions(true)
105
+ bidLayer.frame = CGRect(x: 0, y: y, width: bidW, height: h)
106
+ askLayer.frame = CGRect(x: bidW + g, y: y, width: askW, height: h)
107
+ CATransaction.commit()
108
+ }
102
109
 
103
- let boundsAnim = CABasicAnimation(keyPath: "bounds")
104
- boundsAnim.fromValue = NSValue(cgRect: fromBounds)
105
- boundsAnim.toValue = NSValue(cgRect: newBounds)
106
- boundsAnim.duration = duration
107
- boundsAnim.timingFunction = timing
110
+ // MARK: - Continuous easing (display link)
111
+ private func startDisplayLinkIfNeeded() {
112
+ if displayLink != nil { return }
113
+ guard abs(targetSplit - currentSplit) > 0.0005 else { return }
114
+ lastTick = 0
115
+ let proxy = SideRatioLinkProxy(self)
116
+ let link = CADisplayLink(target: proxy, selector: #selector(SideRatioLinkProxy.tick(_:)))
117
+ link.add(to: .main, forMode: .common)
118
+ displayLink = link
119
+ }
108
120
 
109
- let posAnim = CABasicAnimation(keyPath: "position")
110
- posAnim.fromValue = NSValue(cgPoint: fromPos)
111
- posAnim.toValue = NSValue(cgPoint: newPos)
112
- posAnim.duration = duration
113
- posAnim.timingFunction = timing
121
+ private func stopDisplayLink() {
122
+ displayLink?.invalidate()
123
+ displayLink = nil
124
+ lastTick = 0
125
+ }
114
126
 
115
- layer.add(boundsAnim, forKey: "ratioBounds")
116
- layer.add(posAnim, forKey: "ratioPos")
127
+ func handleTick(_ link: CADisplayLink) {
128
+ let now = link.timestamp
129
+ let dt = lastTick > 0 ? now - lastTick : link.duration
130
+ lastTick = now
131
+ let tau = PerpTiming.sideRatioSmoothingTauSeconds
132
+ let alpha = CGFloat(1 - exp(-max(dt, 0) / max(tau, 0.0001)))
133
+
134
+ let d = targetSplit - currentSplit
135
+ if abs(d) <= 0.0005 {
136
+ if currentSplit != targetSplit {
137
+ currentSplit = targetSplit
138
+ layoutSegments(currentSplit)
139
+ }
140
+ stopDisplayLink()
141
+ return
142
+ }
143
+ currentSplit += d * alpha
144
+ layoutSegments(currentSplit)
117
145
  }
118
146
  }
147
+
148
+ /// Weak forwarder so the `CADisplayLink` does not retain `HybridPerpSideRatio`.
149
+ private final class SideRatioLinkProxy {
150
+ weak var owner: HybridPerpSideRatio?
151
+ init(_ owner: HybridPerpSideRatio) { self.owner = owner }
152
+ @objc func tick(_ link: CADisplayLink) { owner?.handleTick(link) }
153
+ }
@@ -75,6 +75,17 @@ enum PerpTiming {
75
75
  static let depthBarDurationMs: Double = 260
76
76
  static let sideRatioDurationMs: Double = 300
77
77
 
78
+ /// Exponential-smoothing time constant (seconds) for the continuous,
79
+ /// frame-driven depth-bar easing. Each frame the on-screen value moves a
80
+ /// fraction `1 - exp(-dt / tau)` toward the latest target, so the bar always
81
+ /// glides toward the newest data instead of restarting a fixed-length anim
82
+ /// per tick. ~63% of any change covered in `tau`, ~95% in `3*tau`.
83
+ static let depthBarSmoothingTauSeconds: Double = 0.10
84
+
85
+ /// Same continuous-easing time constant for the bid/ask side-ratio bar. A
86
+ /// touch larger than the depth bars since it changes more slowly.
87
+ static let sideRatioSmoothingTauSeconds: Double = 0.12
88
+
78
89
  static func easeOutCubic() -> CAMediaTimingFunction {
79
90
  CAMediaTimingFunction(controlPoints: 0.33, 1, 0.68, 1)
80
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-perp-depth-bar",
3
- "version": "3.0.42",
3
+ "version": "3.0.43",
4
4
  "description": "react-native-perp-depth-bar",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",