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