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

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,24 @@
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 [Choreographer] frame loop continuously eases every row's
18
+ * on-screen fill toward its latest target (short fixed exponential smoothing).
19
+ * New data just retargets — the loop keeps gliding and only stops once every row
20
+ * has settled. Updates chain into continuous motion and a fluctuating row count
21
+ * no longer freezes the column (only a coin/tick switch snaps, via `epoch`).
16
22
  */
17
23
  @DoNotStrip
18
24
  class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBarsSpec() {
@@ -22,14 +28,17 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
22
28
  private val pricePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.LEFT }
23
29
  private val sizePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.RIGHT }
24
30
 
25
- private var current = DoubleArray(0)
26
- private var start = DoubleArray(0)
27
- private var target = DoubleArray(0)
31
+ private var current = DoubleArray(0) // on-screen fill fraction per row (0..1)
32
+ private var target = DoubleArray(0) // latest target fill fraction per row
28
33
 
29
34
  private var lastEpoch = Double.NaN
30
35
  private var hasDrawn = false
31
36
  private var isDisposed = false
32
- private var animator: ValueAnimator? = null
37
+
38
+ private val choreographer = Choreographer.getInstance()
39
+ private var frameScheduled = false
40
+ private var lastFrameNanos = 0L
41
+ private val frameCallback = Choreographer.FrameCallback { onFrame(it) }
33
42
 
34
43
  override val view: View = object : View(context) {
35
44
  override fun onDraw(canvas: Canvas) {
@@ -73,6 +82,7 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
73
82
  get() = field
74
83
  set(value) { if (!isDisposed) { field = value; view.invalidate() } }
75
84
 
85
+ /** Kept for OS "reduce motion" accessibility only — NOT a caller on/off knob. */
76
86
  override var reducedMotion: Boolean = false
77
87
  get() = field
78
88
  set(value) { field = value }
@@ -153,7 +163,7 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
153
163
 
154
164
  /**
155
165
  * 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.
166
+ * (re)start the easing loop here so a multi-prop update retargets once.
157
167
  */
158
168
  override fun afterUpdate() {
159
169
  super.afterUpdate()
@@ -164,13 +174,11 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
164
174
  private fun syncTargets() {
165
175
  val count = percents.size
166
176
  val epochChanged = epoch != lastEpoch
167
- val countChanged = target.size != count
168
- val snap = !hasDrawn || reducedMotion || epochChanged || countChanged
169
-
177
+ val snap = !hasDrawn || reducedMotion || epochChanged
170
178
  val newTarget = DoubleArray(count) { clampFrac(percents[it]) }
171
179
 
172
180
  if (snap) {
173
- animator?.cancel()
181
+ cancelFrameLoop()
174
182
  current = newTarget.copyOf()
175
183
  target = newTarget
176
184
  lastEpoch = epoch
@@ -179,27 +187,62 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
179
187
  return
180
188
  }
181
189
 
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()
190
+ // Keep existing rows easing; brand-new rows appear at their target (no
191
+ // grow-from-zero flash). Then retarget and let the frame loop glide.
192
+ if (current.size != count) {
193
+ current = DoubleArray(count) { i -> if (i < current.size) current[i] else newTarget[i] }
194
+ }
185
195
  target = newTarget
186
196
  lastEpoch = epoch
197
+ scheduleFrameLoop()
198
+ }
187
199
 
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()
200
+ // MARK: - Continuous easing (Choreographer)
201
+ private fun scheduleFrameLoop() {
202
+ if (frameScheduled || isDisposed) return
203
+ val n = minOf(current.size, target.size)
204
+ var needs = false
205
+ for (i in 0 until n) {
206
+ if (abs(target[i] - current[i]) > 0.001) { needs = true; break }
207
+ }
208
+ if (!needs) { view.invalidate(); return }
209
+ frameScheduled = true
210
+ lastFrameNanos = 0L
211
+ choreographer.postFrameCallback(frameCallback)
212
+ }
213
+
214
+ private fun cancelFrameLoop() {
215
+ if (frameScheduled) {
216
+ choreographer.removeFrameCallback(frameCallback)
217
+ frameScheduled = false
218
+ }
219
+ lastFrameNanos = 0L
220
+ }
221
+
222
+ private fun onFrame(now: Long) {
223
+ if (isDisposed) { frameScheduled = false; return }
224
+ val dt = if (lastFrameNanos > 0L) (now - lastFrameNanos) / 1e9 else 1.0 / 60.0
225
+ lastFrameNanos = now
226
+ val alpha = 1.0 - exp(-maxOf(dt, 0.0) / TAU_SECONDS)
227
+
228
+ var settled = true
229
+ val n = minOf(current.size, target.size)
230
+ for (i in 0 until n) {
231
+ val d = target[i] - current[i]
232
+ if (abs(d) <= 0.001) {
233
+ current[i] = target[i]
234
+ } else {
235
+ current[i] += d * alpha
236
+ settled = false
201
237
  }
202
- start()
238
+ }
239
+ view.invalidate()
240
+
241
+ if (settled) {
242
+ frameScheduled = false
243
+ lastFrameNanos = 0L
244
+ } else {
245
+ choreographer.postFrameCallback(frameCallback)
203
246
  }
204
247
  }
205
248
 
@@ -252,12 +295,14 @@ class HybridPerpDepthBars(val context: ThemedReactContext) : HybridPerpDepthBars
252
295
 
253
296
  override fun dispose() {
254
297
  isDisposed = true
255
- animator?.cancel()
256
- animator = null
298
+ cancelFrameLoop()
257
299
  }
258
300
 
259
301
  companion object {
260
- private const val DURATION_MS = 260L
261
- private val EASE_OUT_CUBIC = PathInterpolator(0.33f, 1f, 0.68f, 1f)
302
+ /**
303
+ * Exponential-smoothing time constant (seconds); mirror of
304
+ * PerpTiming.depthBarSmoothingTauSeconds on iOS.
305
+ */
306
+ private const val TAU_SECONDS = 0.10
262
307
  }
263
308
  }
@@ -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 [Choreographer] frame loop
17
+ * continuously eases the split fraction toward its latest target (short fixed
18
+ * exponential 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,14 @@ 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
+ private val choreographer = Choreographer.getInstance()
34
+ private var frameScheduled = false
35
+ private var lastFrameNanos = 0L
36
+ private val frameCallback = Choreographer.FrameCallback { onFrame(it) }
27
37
 
28
38
  override val view: View = object : View(context) {
29
39
  override fun onDraw(canvas: Canvas) {
@@ -54,6 +64,7 @@ class HybridPerpSideRatio(val context: ThemedReactContext) : HybridPerpSideRatio
54
64
  get() = field
55
65
  set(value) { if (!isDisposed) { field = value; view.invalidate() } }
56
66
 
67
+ /** Kept for OS "reduce motion" accessibility only — NOT a caller on/off knob. */
57
68
  override var reducedMotion: Boolean = false
58
69
  get() = field
59
70
  set(value) { field = value }
@@ -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,16 @@ 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 (short fixed exponential smoothing,
10
+ /// `tau ≈ 0.1s`). New data just retargets — the link keeps gliding and only
11
+ /// stops once every row has settled. Updates therefore chain into continuous
12
+ /// motion, and a fluctuating row count no longer freezes the column (only a
13
+ /// coin/tick switch snaps, via `epoch`). Tracks the per-row text closely so the
14
+ /// bar and the price/size labels stay in agreement.
7
15
  final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
8
16
 
9
17
  // MARK: - HybridView
@@ -17,8 +25,13 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
17
25
  private var sizeLayers: [CATextLayer] = []
18
26
  private let textScale = UIScreen.main.scale
19
27
 
28
+ // MARK: - Continuous-easing state
29
+ private var currentScales: [CGFloat] = [] // on-screen scaleX per row (0...1)
30
+ private var targetScales: [CGFloat] = [] // latest target scaleX per row
31
+ private var displayLink: CADisplayLink?
32
+ private var lastTick: CFTimeInterval = 0
33
+
20
34
  // MARK: - State for animation decisions
21
- private var lastPercents: [Double] = []
22
35
  private var lastEpoch: Double = .nan
23
36
  private var hasLaidOut = false
24
37
 
@@ -28,6 +41,8 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
28
41
  var rowMarginTop: Double = 0 { didSet { scheduleLayout() } }
29
42
  var barInset: Double = 0 { didSet { scheduleLayout() } }
30
43
  var origin: String = "left" { didSet { scheduleLayout() } }
44
+ /// Kept for OS "reduce motion" accessibility only — NOT a caller on/off knob.
45
+ /// Depth bars animate by default; callers don't pass anything to enable it.
31
46
  var reducedMotion: Bool = false { didSet { scheduleLayout() } }
32
47
  var epoch: Double = 0 { didSet { scheduleLayout() } }
33
48
 
@@ -75,6 +90,10 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
75
90
  }
76
91
  }
77
92
 
93
+ deinit {
94
+ stopDisplayLink()
95
+ }
96
+
78
97
  private func handleTap(atY y: CGFloat) {
79
98
  let step = CGFloat(rowHeight + rowMarginTop)
80
99
  guard step > 0 else { return }
@@ -89,7 +108,7 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
89
108
  view.setNeedsLayout()
90
109
  }
91
110
 
92
- // MARK: - Layout + animation
111
+ // MARK: - Layout + retarget
93
112
  private func performLayout() {
94
113
  let bounds = view.bounds
95
114
  guard bounds.width > 0 else { return }
@@ -98,10 +117,8 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
98
117
  syncLayerCount(count)
99
118
  syncTextLayerCount(count)
100
119
 
101
- // Decide whether this update should animate or snap.
102
120
  let epochChanged = epoch != lastEpoch
103
- let countChanged = lastPercents.count != count
104
- let snap = !hasLaidOut || reducedMotion || epochChanged || countChanged
121
+ let snap = !hasLaidOut || reducedMotion || epochChanged
105
122
 
106
123
  let rowW = bounds.width
107
124
  let h = CGFloat(rowHeight)
@@ -110,32 +127,41 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
110
127
  let isRight = origin == "right"
111
128
  let anchorX: CGFloat = isRight ? 1 : 0
112
129
 
130
+ // Geometry never animates — set it directly each pass.
113
131
  CATransaction.begin()
114
- CATransaction.setDisableActions(true) // suppress implicit anims for geometry
115
-
132
+ CATransaction.setDisableActions(true)
116
133
  for i in 0..<count {
117
134
  let layer = barLayers[i]
118
135
  let rowTop = CGFloat(rowMarginTop) + CGFloat(i) * (h + CGFloat(rowMarginTop))
119
136
  let rowCenterY = rowTop + h / 2
120
-
121
137
  layer.anchorPoint = CGPoint(x: anchorX, y: 0.5)
122
138
  layer.bounds = CGRect(x: 0, y: 0, width: rowW, height: barH)
123
139
  layer.position = CGPoint(x: isRight ? rowW : 0, y: rowCenterY)
124
140
  layer.backgroundColor = cachedColor
141
+ }
142
+ CATransaction.commit()
125
143
 
126
- let target = clampScale(percents[i])
127
- let changed = i >= lastPercents.count || lastPercents[i] != percents[i]
144
+ var newTargets = [CGFloat](repeating: 0, count: count)
145
+ for i in 0..<count { newTargets[i] = clampScale(percents[i]) }
128
146
 
129
- if snap || !changed {
130
- layer.removeAnimation(forKey: "fill")
131
- layer.transform = CATransform3DMakeScale(target, 1, 1)
132
- } else {
133
- animateScaleX(layer: layer, to: target)
147
+ if snap {
148
+ stopDisplayLink()
149
+ currentScales = newTargets
150
+ targetScales = newTargets
151
+ applyScales()
152
+ } else {
153
+ // Keep existing rows easing; brand-new rows appear at their target (no
154
+ // grow-from-zero flash). Then retarget and let the display link glide.
155
+ if currentScales.count < count {
156
+ for i in currentScales.count..<count { currentScales.append(newTargets[i]) }
157
+ } else if currentScales.count > count {
158
+ currentScales.removeLast(currentScales.count - count)
134
159
  }
160
+ targetScales = newTargets
161
+ applyScales() // render current immediately (geometry/new rows) this frame
162
+ startDisplayLinkIfNeeded()
135
163
  }
136
164
 
137
- CATransaction.commit()
138
-
139
165
  // Text never animates — snap frames/strings each layout pass.
140
166
  CATransaction.begin()
141
167
  CATransaction.setDisableActions(true)
@@ -163,11 +189,78 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
163
189
  }
164
190
  CATransaction.commit()
165
191
 
166
- lastPercents = percents
167
192
  lastEpoch = epoch
168
193
  hasLaidOut = true
169
194
  }
170
195
 
196
+ /// Writes `currentScales` into every bar layer's transform (no implicit anim).
197
+ private func applyScales() {
198
+ let n = min(currentScales.count, barLayers.count)
199
+ guard n > 0 else { return }
200
+ CATransaction.begin()
201
+ CATransaction.setDisableActions(true)
202
+ for i in 0..<n {
203
+ barLayers[i].transform = CATransform3DMakeScale(currentScales[i], 1, 1)
204
+ }
205
+ CATransaction.commit()
206
+ }
207
+
208
+ // MARK: - Continuous easing (display link)
209
+ private func startDisplayLinkIfNeeded() {
210
+ if displayLink != nil { return }
211
+ let n = min(currentScales.count, targetScales.count)
212
+ var needs = false
213
+ for i in 0..<n where abs(targetScales[i] - currentScales[i]) > 0.001 {
214
+ needs = true
215
+ break
216
+ }
217
+ guard needs else { return }
218
+ lastTick = 0
219
+ let proxy = DisplayLinkProxy(self)
220
+ let link = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick(_:)))
221
+ link.add(to: .main, forMode: .common)
222
+ displayLink = link
223
+ }
224
+
225
+ private func stopDisplayLink() {
226
+ displayLink?.invalidate()
227
+ displayLink = nil
228
+ lastTick = 0
229
+ }
230
+
231
+ /// Called by the display link each frame. Eases every row toward its target.
232
+ func handleTick(_ link: CADisplayLink) {
233
+ let now = link.timestamp
234
+ let dt = lastTick > 0 ? now - lastTick : link.duration
235
+ lastTick = now
236
+ let tau = PerpTiming.depthBarSmoothingTauSeconds
237
+ let alpha = CGFloat(1 - exp(-max(dt, 0) / max(tau, 0.0001)))
238
+
239
+ var settled = true
240
+ let n = min(min(currentScales.count, targetScales.count), barLayers.count)
241
+ CATransaction.begin()
242
+ CATransaction.setDisableActions(true)
243
+ for i in 0..<n {
244
+ let t = targetScales[i]
245
+ let c = currentScales[i]
246
+ let d = t - c
247
+ if abs(d) <= 0.001 {
248
+ if c != t {
249
+ currentScales[i] = t
250
+ barLayers[i].transform = CATransform3DMakeScale(t, 1, 1)
251
+ }
252
+ } else {
253
+ let nc = c + d * alpha
254
+ currentScales[i] = nc
255
+ barLayers[i].transform = CATransform3DMakeScale(nc, 1, 1)
256
+ settled = false
257
+ }
258
+ }
259
+ CATransaction.commit()
260
+
261
+ if settled { stopDisplayLink() }
262
+ }
263
+
171
264
  private func makeTextLayer(alignment: CATextLayerAlignmentMode) -> CATextLayer {
172
265
  let l = CATextLayer()
173
266
  l.contentsScale = textScale
@@ -195,28 +288,6 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
195
288
  }
196
289
  }
197
290
 
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
291
  private func syncLayerCount(_ count: Int) {
221
292
  if barLayers.count < count {
222
293
  for _ in barLayers.count..<count {
@@ -242,3 +313,11 @@ final class HybridPerpDepthBars: HybridPerpDepthBarsSpec {
242
313
  CGFloat(max(0, min(100, percent)) / 100.0)
243
314
  }
244
315
  }
316
+
317
+ /// Weak forwarder so the `CADisplayLink` (retained by the run loop) does not
318
+ /// retain `HybridPerpDepthBars`, allowing `deinit` to invalidate the link.
319
+ private final class DisplayLinkProxy {
320
+ weak var owner: HybridPerpDepthBars?
321
+ init(_ owner: HybridPerpDepthBars) { self.owner = owner }
322
+ @objc func tick(_ link: CADisplayLink) { owner?.handleTick(link) }
323
+ }
@@ -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 `CADisplayLink` continuously eases
7
+ /// the split fraction toward its latest target (short fixed exponential
8
+ /// smoothing). New data just retargets; the link stops once settled.
5
9
  final class HybridPerpSideRatio: HybridPerpSideRatioSpec {
6
10
 
7
11
  // MARK: - HybridView
@@ -11,12 +15,19 @@ 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() } }
17
27
  var segmentHeight: Double = 4 { didSet { scheduleLayout() } }
18
28
  var cornerRadius: Double = 999 { didSet { scheduleLayout() } }
19
29
  var gap: Double = 2 { didSet { scheduleLayout() } }
30
+ /// Kept for OS "reduce motion" accessibility only — NOT a caller on/off knob.
20
31
  var reducedMotion: Bool = false { didSet { scheduleLayout() } }
21
32
 
22
33
  var longColor: String = "" {
@@ -43,6 +54,10 @@ final class HybridPerpSideRatio: HybridPerpSideRatioSpec {
43
54
  }
44
55
  }
45
56
 
57
+ deinit {
58
+ stopDisplayLink()
59
+ }
60
+
46
61
  private func scheduleLayout() {
47
62
  view.setNeedsLayout()
48
63
  }
@@ -52,67 +67,88 @@ final class HybridPerpSideRatio: HybridPerpSideRatioSpec {
52
67
  guard bounds.width > 0 else { return }
53
68
 
54
69
  let h = CGFloat(segmentHeight)
55
- let y = (bounds.height - h) / 2
56
- let g = CGFloat(gap)
57
- let available = max(bounds.width - g, 0)
70
+ CATransaction.begin()
71
+ CATransaction.setDisableActions(true)
72
+ bidLayer.cornerRadius = min(CGFloat(cornerRadius), h / 2)
73
+ askLayer.cornerRadius = min(CGFloat(cornerRadius), h / 2)
74
+ CATransaction.commit()
58
75
 
59
76
  let bid = max(bidPercentage, 1)
60
77
  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)
78
+ let target = CGFloat(bid / (bid + ask))
68
79
 
69
80
  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
81
  if snap {
78
- CATransaction.begin()
79
- CATransaction.setDisableActions(true)
80
- bidLayer.frame = bidFrame
81
- askLayer.frame = askFrame
82
- CATransaction.commit()
82
+ stopDisplayLink()
83
+ currentSplit = target
84
+ targetSplit = target
85
+ layoutSegments(currentSplit)
83
86
  } else {
84
- animateFrame(layer: bidLayer, to: bidFrame)
85
- animateFrame(layer: askLayer, to: askFrame)
87
+ targetSplit = target
88
+ layoutSegments(currentSplit) // apply current + any geometry change now
89
+ startDisplayLinkIfNeeded()
86
90
  }
87
91
  hasLaidOut = true
88
92
  }
89
93
 
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
94
+ /// Lays both segment frames out from a split fraction (no implicit anim).
95
+ private func layoutSegments(_ split: CGFloat) {
96
+ let bounds = view.bounds
97
+ guard bounds.width > 0 else { return }
98
+ let h = CGFloat(segmentHeight)
99
+ let y = (bounds.height - h) / 2
100
+ let g = CGFloat(gap)
101
+ let available = max(bounds.width - g, 0)
102
+ let bidW = available * split
103
+ let askW = max(available - bidW, 0)
104
+ CATransaction.begin()
105
+ CATransaction.setDisableActions(true)
106
+ bidLayer.frame = CGRect(x: 0, y: y, width: bidW, height: h)
107
+ askLayer.frame = CGRect(x: bidW + g, y: y, width: askW, height: h)
108
+ CATransaction.commit()
109
+ }
102
110
 
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
111
+ // MARK: - Continuous easing (display link)
112
+ private func startDisplayLinkIfNeeded() {
113
+ if displayLink != nil { return }
114
+ guard abs(targetSplit - currentSplit) > 0.0005 else { return }
115
+ lastTick = 0
116
+ let proxy = SideRatioLinkProxy(self)
117
+ let link = CADisplayLink(target: proxy, selector: #selector(SideRatioLinkProxy.tick(_:)))
118
+ link.add(to: .main, forMode: .common)
119
+ displayLink = link
120
+ }
108
121
 
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
122
+ private func stopDisplayLink() {
123
+ displayLink?.invalidate()
124
+ displayLink = nil
125
+ lastTick = 0
126
+ }
114
127
 
115
- layer.add(boundsAnim, forKey: "ratioBounds")
116
- layer.add(posAnim, forKey: "ratioPos")
128
+ func handleTick(_ link: CADisplayLink) {
129
+ let now = link.timestamp
130
+ let dt = lastTick > 0 ? now - lastTick : link.duration
131
+ lastTick = now
132
+ let tau = PerpTiming.sideRatioSmoothingTauSeconds
133
+ let alpha = CGFloat(1 - exp(-max(dt, 0) / max(tau, 0.0001)))
134
+
135
+ let d = targetSplit - currentSplit
136
+ if abs(d) <= 0.0005 {
137
+ if currentSplit != targetSplit {
138
+ currentSplit = targetSplit
139
+ layoutSegments(currentSplit)
140
+ }
141
+ stopDisplayLink()
142
+ return
143
+ }
144
+ currentSplit += d * alpha
145
+ layoutSegments(currentSplit)
117
146
  }
118
147
  }
148
+
149
+ /// Weak forwarder so the `CADisplayLink` does not retain `HybridPerpSideRatio`.
150
+ private final class SideRatioLinkProxy {
151
+ weak var owner: HybridPerpSideRatio?
152
+ init(_ owner: HybridPerpSideRatio) { self.owner = owner }
153
+ @objc func tick(_ link: CADisplayLink) { owner?.handleTick(link) }
154
+ }
@@ -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.45",
4
4
  "description": "react-native-perp-depth-bar",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",