@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.
- package/android/src/main/java/com/margelo/nitro/perpdepthbar/HybridPerpDepthBars.kt +80 -35
- package/android/src/main/java/com/margelo/nitro/perpdepthbar/HybridPerpSideRatio.kt +60 -22
- package/ios/HybridPerpDepthBars.swift +120 -41
- package/ios/HybridPerpSideRatio.swift +85 -49
- package/ios/PerpColorParser.swift +11 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
animator = null
|
|
298
|
+
cancelFrameLoop()
|
|
257
299
|
}
|
|
258
300
|
|
|
259
301
|
companion object {
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
132
|
-
animator = null
|
|
167
|
+
cancelFrameLoop()
|
|
133
168
|
}
|
|
134
169
|
|
|
135
170
|
companion object {
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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 +
|
|
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
|
|
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)
|
|
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
|
-
|
|
127
|
-
|
|
144
|
+
var newTargets = [CGFloat](repeating: 0, count: count)
|
|
145
|
+
for i in 0..<count { newTargets[i] = clampScale(percents[i]) }
|
|
128
146
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
CATransaction.commit()
|
|
82
|
+
stopDisplayLink()
|
|
83
|
+
currentSplit = target
|
|
84
|
+
targetSplit = target
|
|
85
|
+
layoutSegments(currentSplit)
|
|
83
86
|
} else {
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
let
|
|
93
|
-
|
|
94
|
-
let
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
let
|
|
98
|
-
let
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
122
|
+
private func stopDisplayLink() {
|
|
123
|
+
displayLink?.invalidate()
|
|
124
|
+
displayLink = nil
|
|
125
|
+
lastTick = 0
|
|
126
|
+
}
|
|
114
127
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
}
|