@onekeyfe/react-native-skeleton 3.0.95 → 3.0.98

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.
Files changed (19) hide show
  1. package/android/src/main/java/com/margelo/nitro/skeleton/OneKeySkeletonRenderer.kt +173 -0
  2. package/android/src/main/java/com/margelo/nitro/skeleton/Skeleton.kt +63 -194
  3. package/ios/Skeleton.swift +58 -272
  4. package/ios/SkeletonRenderer.swift +146 -0
  5. package/lib/nitrogen/generated/android/c++/views/JHybridSkeletonStateUpdater.cpp +42 -23
  6. package/lib/nitrogen/generated/android/c++/views/JHybridSkeletonStateUpdater.hpp +6 -1
  7. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/skeleton/views/HybridSkeletonManager.kt +26 -10
  8. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/skeleton/views/HybridSkeletonStateUpdater.kt +2 -2
  9. package/lib/nitrogen/generated/ios/c++/views/HybridSkeletonComponent.mm +50 -27
  10. package/lib/nitrogen/generated/shared/c++/views/HybridSkeletonComponent.cpp +7 -65
  11. package/lib/nitrogen/generated/shared/c++/views/HybridSkeletonComponent.hpp +26 -50
  12. package/nitrogen/generated/android/c++/views/JHybridSkeletonStateUpdater.cpp +42 -23
  13. package/nitrogen/generated/android/c++/views/JHybridSkeletonStateUpdater.hpp +6 -1
  14. package/nitrogen/generated/android/kotlin/com/margelo/nitro/skeleton/views/HybridSkeletonManager.kt +26 -10
  15. package/nitrogen/generated/android/kotlin/com/margelo/nitro/skeleton/views/HybridSkeletonStateUpdater.kt +2 -2
  16. package/nitrogen/generated/ios/c++/views/HybridSkeletonComponent.mm +50 -27
  17. package/nitrogen/generated/shared/c++/views/HybridSkeletonComponent.cpp +7 -65
  18. package/nitrogen/generated/shared/c++/views/HybridSkeletonComponent.hpp +26 -50
  19. package/package.json +4 -4
@@ -0,0 +1,173 @@
1
+ package com.margelo.nitro.skeleton
2
+
3
+ import android.graphics.Canvas
4
+ import android.graphics.Color
5
+ import android.graphics.LinearGradient
6
+ import android.graphics.Matrix
7
+ import android.graphics.Paint
8
+ import android.graphics.RectF
9
+ import android.graphics.Shader
10
+ import android.view.Choreographer
11
+ import java.lang.ref.WeakReference
12
+
13
+ public val ONEKEY_SKELETON_DEFAULT_COLORS: IntArray = intArrayOf(
14
+ Color.rgb(210, 210, 210),
15
+ Color.rgb(235, 235, 235),
16
+ )
17
+
18
+ /**
19
+ * One process-wide frame clock for every active skeleton renderer. The clock
20
+ * owns no Views and keeps listeners weak, avoiding one ValueAnimator per image.
21
+ */
22
+ private object OneKeySkeletonClock : Choreographer.FrameCallback {
23
+ interface Listener {
24
+ fun onSkeletonFrame(frameTimeNanos: Long)
25
+ }
26
+
27
+ private val listeners = ArrayList<WeakReference<Listener>>()
28
+ private var scheduled = false
29
+
30
+ fun register(listener: Listener) {
31
+ var index = listeners.size - 1
32
+ while (index >= 0) {
33
+ val current = listeners[index].get()
34
+ if (current == null) {
35
+ listeners.removeAt(index)
36
+ } else if (current === listener) {
37
+ return
38
+ }
39
+ index--
40
+ }
41
+ listeners.add(WeakReference(listener))
42
+ if (!scheduled) {
43
+ scheduled = true
44
+ Choreographer.getInstance().postFrameCallback(this)
45
+ }
46
+ }
47
+
48
+ fun unregister(listener: Listener) {
49
+ var index = listeners.size - 1
50
+ while (index >= 0) {
51
+ val current = listeners[index].get()
52
+ if (current == null || current === listener) listeners.removeAt(index)
53
+ index--
54
+ }
55
+ }
56
+
57
+ override fun doFrame(frameTimeNanos: Long) {
58
+ scheduled = false
59
+ if (listeners.isEmpty()) return
60
+ var index = 0
61
+ while (index < listeners.size) {
62
+ val listener = listeners[index].get()
63
+ if (listener == null) {
64
+ listeners.removeAt(index)
65
+ } else {
66
+ listener.onSkeletonFrame(frameTimeNanos)
67
+ index++
68
+ }
69
+ }
70
+ if (listeners.isNotEmpty()) {
71
+ scheduled = true
72
+ Choreographer.getInstance().postFrameCallback(this)
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * View-independent shimmer renderer shared by Skeleton and OneKeyImage.
79
+ * Paint, Matrix and LinearGradient are retained and only rebuilt when bounds or
80
+ * colors change; draw() performs no shader/paint/matrix allocation.
81
+ */
82
+ public class OneKeySkeletonRenderer(
83
+ private val invalidate: () -> Unit,
84
+ ) {
85
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
86
+ private val shaderMatrix = Matrix()
87
+ private val drawBounds = RectF()
88
+ private var colors = ONEKEY_SKELETON_DEFAULT_COLORS.copyOf()
89
+ private var shader: LinearGradient? = null
90
+ private var shaderWidth = -1f
91
+ private var durationNanos = 3_000_000_000L
92
+ private var startedAtNanos = 0L
93
+ private var phase = 0f
94
+ private var running = false
95
+ private val frameListener = object : OneKeySkeletonClock.Listener {
96
+ override fun onSkeletonFrame(frameTimeNanos: Long) {
97
+ handleFrame(frameTimeNanos)
98
+ }
99
+ }
100
+
101
+ public fun updateStyle(colors: IntArray?, durationSeconds: Double?) {
102
+ val nextColors = colors?.takeIf { it.size >= 2 }?.copyOfRange(0, 2)
103
+ ?: ONEKEY_SKELETON_DEFAULT_COLORS.copyOf()
104
+ val colorsChanged = !this.colors.contentEquals(nextColors)
105
+ this.colors = nextColors
106
+ durationNanos = ((durationSeconds ?: 3.0).coerceAtLeast(0.1) * 1_000_000_000L).toLong()
107
+ if (colorsChanged) invalidateShader()
108
+ }
109
+
110
+ public fun updateBounds(width: Int, height: Int) {
111
+ val nextWidth = width.coerceAtLeast(0).toFloat()
112
+ val nextHeight = height.coerceAtLeast(0).toFloat()
113
+ if (drawBounds.width() != nextWidth || drawBounds.height() != nextHeight) {
114
+ drawBounds.set(0f, 0f, nextWidth, nextHeight)
115
+ invalidateShader()
116
+ }
117
+ }
118
+
119
+ public fun start() {
120
+ if (running) return
121
+ running = true
122
+ startedAtNanos = 0L
123
+ OneKeySkeletonClock.register(frameListener)
124
+ }
125
+
126
+ public fun stop() {
127
+ if (!running) return
128
+ running = false
129
+ OneKeySkeletonClock.unregister(frameListener)
130
+ }
131
+
132
+ public fun draw(canvas: Canvas) {
133
+ if (drawBounds.isEmpty) return
134
+ ensureShader()
135
+ val width = drawBounds.width()
136
+ shaderMatrix.setTranslate(-width + (phase * width * 3f), 0f)
137
+ shader?.setLocalMatrix(shaderMatrix)
138
+ canvas.drawRect(drawBounds, paint)
139
+ }
140
+
141
+ private fun handleFrame(frameTimeNanos: Long) {
142
+ if (!running) return
143
+ if (startedAtNanos == 0L) startedAtNanos = frameTimeNanos
144
+ phase = ((frameTimeNanos - startedAtNanos) % durationNanos).toFloat() / durationNanos.toFloat()
145
+ invalidate()
146
+ }
147
+
148
+ private fun ensureShader() {
149
+ val width = drawBounds.width()
150
+ if (shader != null && shaderWidth == width) return
151
+ shader = LinearGradient(
152
+ 0f,
153
+ 0f,
154
+ width,
155
+ 0f,
156
+ intArrayOf(colors[0], colors[1], colors[0]),
157
+ SHADER_STOPS,
158
+ Shader.TileMode.CLAMP,
159
+ )
160
+ shaderWidth = width
161
+ paint.shader = shader
162
+ }
163
+
164
+ private fun invalidateShader() {
165
+ shader = null
166
+ shaderWidth = -1f
167
+ paint.shader = null
168
+ }
169
+
170
+ private companion object {
171
+ val SHADER_STOPS = floatArrayOf(0f, 0.5f, 1f)
172
+ }
173
+ }
@@ -1,232 +1,101 @@
1
1
  package com.margelo.nitro.skeleton
2
2
 
3
- import android.animation.ValueAnimator
4
3
  import android.graphics.Canvas
5
4
  import android.graphics.Color
6
- import android.graphics.LinearGradient
7
- import android.graphics.Paint
8
- import android.graphics.Shader
9
5
  import android.view.View
10
- import android.view.animation.LinearInterpolator
11
6
  import com.facebook.proguard.annotations.DoNotStrip
12
7
  import com.facebook.react.uimanager.ThemedReactContext
13
- import androidx.core.graphics.toColorInt
14
8
 
15
- // Animation constants
16
- val DEFAULT_GRADIENT_COLORS = intArrayOf(
17
- Color.rgb(210, 210, 210),
18
- Color.rgb(235, 235, 235)
19
- )
9
+ private class SkeletonHostView(context: ThemedReactContext) : View(context) {
10
+ private val renderer = OneKeySkeletonRenderer { postInvalidateOnAnimation() }
11
+ var disposed = false
12
+ private var aggregatedVisible = true
20
13
 
21
- @DoNotStrip
22
- class HybridSkeleton(val context: ThemedReactContext) : HybridSkeletonSpec() {
23
-
24
- // Shimmer animation
25
- private var shimmerAnimator: ValueAnimator? = null
26
- private var shimmerPaint: Paint = Paint()
27
- private var translateX: Float = 0f
28
-
29
- // Animation properties
30
- private var customGradientColors: IntArray? = null
31
- private var animationSpeed: Long = 3000L
32
-
33
- // Memory safety flags
34
- private var isDisposed: Boolean = false
35
- private var retryCount: Int = 0
36
- private val maxRetryCount: Int = 10
37
- private var pendingStartRunnable: Runnable? = null
38
-
39
- // Guard against high-frequency afterUpdate() ValueAnimator churn (REACT-NATIVE-40K ANR).
40
- // Fabric calls afterUpdate() on every prop commit, and list/order-book screens update
41
- // very frequently. Unconditionally restarting the shimmer cancels/rebuilds the
42
- // ValueAnimator each time (ValueAnimator.cancel -> AnimationHandler.removeCallback ->
43
- // ArrayList.indexOf), blocking the main thread. We cache a signature of the inputs that
44
- // actually drive the animation (speed + gradient colors + view size) and only restart
45
- // when that signature changes. If nothing relevant changed and the shimmer is already
46
- // running, we leave the existing animator untouched (no cancel, no rebuild).
47
- private var lastShimmerSignature: String? = null
48
- private var isShimmerRunning: Boolean = false
49
-
50
- // Build a signature from every input that decides the shimmer animation:
51
- // - animationSpeed -> ValueAnimator.duration
52
- // - gradient colors -> shimmer gradient / draw output
53
- // - view width/height -> animation travel range (-width..width*2) and draw bounds
54
- private fun currentShimmerSignature(): String {
55
- val colors = customGradientColors ?: DEFAULT_GRADIENT_COLORS
56
- val colorsKey = colors.joinToString(",")
57
- return "$animationSpeed|$colorsKey|${view.width}x${view.height}"
14
+ fun updateRenderer(colors: IntArray?, durationSeconds: Double) {
15
+ setBackgroundColor(colors?.firstOrNull() ?: ONEKEY_SKELETON_DEFAULT_COLORS[0])
16
+ renderer.updateStyle(colors, durationSeconds)
17
+ renderer.updateBounds(width, height)
18
+ syncAnimationState()
58
19
  }
59
20
 
60
- // View with shimmer effect
61
- override val view: View = object : View(context) {
62
- override fun onDraw(canvas: Canvas) {
63
- super.onDraw(canvas)
64
-
65
- if (width > 0 && height > 0) {
66
- val colors = customGradientColors ?: DEFAULT_GRADIENT_COLORS
67
- val backgroundColor = colors[0]
68
- val highlightColor = colors[1]
69
-
70
- val gradient = LinearGradient(
71
- translateX - width,
72
- 0f,
73
- translateX,
74
- 0f,
75
- intArrayOf(backgroundColor, highlightColor, backgroundColor),
76
- floatArrayOf(0f, 0.5f, 1f),
77
- Shader.TileMode.CLAMP
78
- )
79
-
80
- shimmerPaint.shader = gradient
81
- canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), shimmerPaint)
82
- }
83
- }
21
+ fun stopRenderer() {
22
+ renderer.stop()
84
23
  }
85
24
 
86
- override var shimmerSpeed: Double?
87
- get() = animationSpeed.toDouble() / 1000.0
88
- set(value) {
89
- if (isDisposed) return
90
- animationSpeed = ((value ?: 3.0) * 1000).toLong()
91
- restartShimmer()
92
- }
25
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
26
+ super.onSizeChanged(w, h, oldw, oldh)
27
+ renderer.updateBounds(w, h)
28
+ }
93
29
 
94
- override var shimmerGradientColors: Array<String>?
95
- get() = customGradientColors?.map { String.format("#%06X", 0xFFFFFF and it) }?.toTypedArray()
96
- set(value) {
97
- if (isDisposed) return
98
- if (value != null) {
99
- customGradientColors = value.map { hexStringToColor(it) }.toIntArray()
100
- restartShimmer()
101
- }
102
- }
30
+ override fun onDraw(canvas: Canvas) {
31
+ super.onDraw(canvas)
32
+ renderer.draw(canvas)
33
+ }
103
34
 
104
- init {
105
- setupView()
35
+ override fun onAttachedToWindow() {
36
+ super.onAttachedToWindow()
37
+ syncAnimationState()
106
38
  }
107
39
 
108
- private fun setupView() {
109
- view.post {
110
- startShimmer()
111
- }
40
+ override fun onDetachedFromWindow() {
41
+ super.onDetachedFromWindow()
42
+ syncAnimationState()
112
43
  }
113
44
 
114
- private fun startShimmer() {
115
- if (isDisposed) return
45
+ override fun onVisibilityAggregated(isVisible: Boolean) {
46
+ super.onVisibilityAggregated(isVisible)
47
+ aggregatedVisible = isVisible
48
+ syncAnimationState()
49
+ }
116
50
 
117
- stopShimmer()
118
- retryCount = 0 // Reset retry count
119
- startShimmerInternal()
51
+ private fun syncAnimationState() {
52
+ if (!disposed && isAttachedToWindow && aggregatedVisible) renderer.start() else renderer.stop()
120
53
  }
54
+ }
121
55
 
122
- private fun startShimmerInternal() {
123
- if (isDisposed) return
124
-
125
- if (view.width == 0 || view.height == 0) {
126
- // Check if we have exceeded max retry count
127
- if (retryCount >= maxRetryCount) {
128
- android.util.Log.w("HybridSkeleton", "Max retry count reached. View bounds are still empty.")
129
- return
130
- }
131
-
132
- retryCount++
133
- // Clean up previous pending runnable
134
- pendingStartRunnable?.let { view.removeCallbacks(it) }
135
-
136
- // Create new runnable and schedule
137
- val runnable = Runnable {
138
- if (!isDisposed) {
139
- startShimmerInternal()
140
- }
141
- }
142
- pendingStartRunnable = runnable
143
- view.postDelayed(runnable, 100)
144
- return
145
- }
56
+ /** Thin Nitro adapter around the reusable view-independent renderer. */
57
+ @DoNotStrip
58
+ class HybridSkeleton(context: ThemedReactContext) : HybridSkeletonSpec() {
59
+ private val hostView = SkeletonHostView(context)
60
+ private var colors: IntArray? = null
61
+ private var durationSeconds = 3.0
146
62
 
147
- val width = view.width.toFloat()
148
-
149
- shimmerAnimator = ValueAnimator.ofFloat(-width, width * 2).apply {
150
- duration = animationSpeed
151
- repeatCount = ValueAnimator.INFINITE
152
- interpolator = LinearInterpolator()
153
- addUpdateListener { animation ->
154
- if (!isDisposed) {
155
- translateX = animation.animatedValue as Float
156
- view.invalidate()
157
- }
158
- }
159
- start()
160
- }
63
+ override val view: View = hostView
161
64
 
162
- // Record the inputs this running animator was built from, so afterUpdate() can skip
163
- // restarting while none of them change.
164
- lastShimmerSignature = currentShimmerSignature()
165
- isShimmerRunning = true
166
- }
167
-
168
- private fun stopShimmer() {
169
- // Clean up pending runnable
170
- pendingStartRunnable?.let {
171
- view.removeCallbacks(it)
172
- pendingStartRunnable = null
65
+ override var shimmerSpeed: Double?
66
+ get() = durationSeconds
67
+ set(value) {
68
+ if (hostView.disposed) return
69
+ durationSeconds = (value ?: 3.0).coerceAtLeast(0.1)
70
+ updateRenderer()
173
71
  }
174
72
 
175
- // Clean up animator and its listeners
176
- shimmerAnimator?.apply {
177
- removeAllUpdateListeners()
178
- cancel()
73
+ override var shimmerGradientColors: Array<String>?
74
+ get() = colors?.map { String.format("#%06X", 0xFFFFFF and it) }?.toTypedArray()
75
+ set(value) {
76
+ if (hostView.disposed) return
77
+ colors = value?.takeIf { it.size >= 2 }?.take(2)?.map(::parseColor)?.toIntArray()
78
+ updateRenderer()
179
79
  }
180
- shimmerAnimator = null
181
- isShimmerRunning = false
182
- }
183
80
 
184
81
  override fun afterUpdate() {
185
82
  super.afterUpdate()
186
- if (isDisposed) return
187
-
188
- // Defensive guard against ValueAnimator churn (REACT-NATIVE-40K ANR):
189
- // afterUpdate() runs on every Fabric prop commit. Only restart the shimmer when an
190
- // input that actually drives the animation changed (speed / colors / view size).
191
- // If the shimmer is already running and nothing relevant changed, leave the existing
192
- // ValueAnimator alone to avoid main-thread cancel/rebuild churn.
193
- val signature = currentShimmerSignature()
194
- if (isShimmerRunning && shimmerAnimator != null && signature == lastShimmerSignature) {
195
- return
196
- }
197
-
198
- restartShimmer()
83
+ if (!hostView.disposed) updateRenderer()
199
84
  }
200
85
 
201
- private fun restartShimmer() {
202
- if (isDisposed) return
203
- stopShimmer()
204
- retryCount = 0 // Reset retry count on restart
205
- startShimmerInternal()
86
+ override fun dispose() {
87
+ hostView.disposed = true
88
+ hostView.stopRenderer()
89
+ super.dispose()
206
90
  }
207
91
 
208
- private fun hexStringToColor(hexColor: String): Int {
209
- var hexSanitized = hexColor.trim()
210
-
211
- if (hexSanitized.startsWith("#")) {
212
- hexSanitized = hexSanitized.substring(1)
213
- }
214
-
215
- return try {
216
- Color.parseColor("#$hexSanitized")
217
- } catch (e: Exception) {
218
- DEFAULT_GRADIENT_COLORS[0]
219
- }
92
+ private fun updateRenderer() {
93
+ hostView.updateRenderer(colors, durationSeconds)
220
94
  }
221
95
 
222
- override fun dispose() {
223
- // Mark as disposed to prevent any new operations
224
- isDisposed = true
225
-
226
- // Stop all animations and clean up
227
- stopShimmer()
228
-
229
- // Clear view references to prevent memory leaks
230
- view.invalidate()
96
+ private fun parseColor(value: String): Int = try {
97
+ Color.parseColor(value)
98
+ } catch (_: IllegalArgumentException) {
99
+ ONEKEY_SKELETON_DEFAULT_COLORS[0]
231
100
  }
232
101
  }