react-native-gleam 1.0.0-beta.4.2 → 1.0.0-beta.6

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/Gleam.podspec CHANGED
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
11
11
  s.authors = package["author"]
12
12
 
13
13
  s.platforms = { :ios => min_ios_version_supported }
14
- s.source = { :git => "https://github.com/AppAndFlow/react-native-gleam.git", :tag => "#{s.version}" }
14
+ s.source = { :git => "https://github.com/RamboWasReal/react-native-gleam.git", :tag => "#{s.version}" }
15
15
 
16
16
  s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
17
17
  s.private_header_files = "ios/**/*.h"
package/README.md CHANGED
@@ -89,7 +89,7 @@ When `loading={true}`, children are hidden and a shimmer animation plays. When `
89
89
  | `loading` | `boolean` | `true` | Toggle shimmer on/off |
90
90
  | `speed` | `number` | `1000` | Duration of one shimmer cycle (ms) |
91
91
  | `direction` | `GleamDirection` | `LeftToRight` | Animation direction |
92
- | `delay` | `number` | `0` | Delay before animation starts (ms) — useful for stagger |
92
+ | `delay` | `number` | `0` | Phase offset (ms) — offsets the shimmer cycle for stagger effects |
93
93
  | `transitionDuration` | `number` | `300` | Duration of the transition from shimmer to content (ms). `0` = instant |
94
94
  | `transitionType` | `GleamTransition` | `Fade` | Transition style when loading ends |
95
95
  | `intensity` | `number` | `1` | Highlight strength (0-1). Lower = more subtle shimmer |
@@ -97,7 +97,7 @@ When `loading={true}`, children are hidden and a shimmer animation plays. When `
97
97
  | `highlightColor` | `string` | `#F5F5F5` | Color of the moving highlight |
98
98
  | `onTransitionEnd` | `function` | — | Called when the fade transition completes. Receives `{ nativeEvent: { finished: boolean } }` |
99
99
 
100
- All standard `View` props are also supported (`style`, `testID`, etc.).
100
+ All standard `View` props are also supported (`style`, `testID`, etc.). Note: the shimmer overlay supports uniform `borderRadius` only — per-corner radii are not applied to the shimmer.
101
101
 
102
102
  ### GleamDirection
103
103
 
@@ -143,7 +143,7 @@ When `loading` switches to `false`:
143
143
 
144
144
  All shimmer instances sharing the same `speed` are automatically synchronized via a shared clock.
145
145
 
146
- The shimmer respects `borderRadius` and all standard view styles.
146
+ The shimmer respects uniform `borderRadius` and standard view styles.
147
147
 
148
148
  ## License
149
149
 
@@ -5,8 +5,8 @@ import android.content.Context
5
5
  import android.graphics.Canvas
6
6
  import android.graphics.Color
7
7
  import android.graphics.LinearGradient
8
+ import android.graphics.Matrix
8
9
  import android.graphics.Paint
9
- import android.graphics.Path
10
10
  import android.graphics.RectF
11
11
  import android.graphics.Shader
12
12
  import android.os.SystemClock
@@ -27,19 +27,24 @@ class GleamView(context: Context) : ReactViewGroup(context) {
27
27
  enum class Direction { LTR, RTL, TTB }
28
28
  enum class TransitionType { FADE, SHRINK, COLLAPSE }
29
29
 
30
+ private var didAttach = false
31
+ private var transitionGeneration = 0
32
+
30
33
  var loading: Boolean = true
31
34
  set(value) {
32
35
  if (field != value) {
33
36
  val wasLoading = field
34
37
  field = value
35
- applyLoadingState(wasLoading)
38
+ if (didAttach) {
39
+ applyLoadingState(wasLoading)
40
+ }
36
41
  }
37
42
  }
38
43
 
39
44
  var speed: Float = 1000f
40
45
  set(value) {
41
46
  if (field != value) {
42
- field = value
47
+ field = value.coerceAtLeast(1f)
43
48
  }
44
49
  }
45
50
 
@@ -60,7 +65,7 @@ class GleamView(context: Context) : ReactViewGroup(context) {
60
65
  var transitionDuration: Float = 300f
61
66
  set(value) {
62
67
  if (field != value) {
63
- field = value
68
+ field = value.coerceAtLeast(0f)
64
69
  }
65
70
  }
66
71
 
@@ -75,6 +80,7 @@ class GleamView(context: Context) : ReactViewGroup(context) {
75
80
  set(value) {
76
81
  if (field != value) {
77
82
  field = value.coerceIn(0f, 1f)
83
+ invalidateGradientCache()
78
84
  invalidate()
79
85
  }
80
86
  }
@@ -83,6 +89,7 @@ class GleamView(context: Context) : ReactViewGroup(context) {
83
89
  set(value) {
84
90
  if (field != value) {
85
91
  field = value
92
+ invalidateGradientCache()
86
93
  invalidate()
87
94
  }
88
95
  }
@@ -91,17 +98,14 @@ class GleamView(context: Context) : ReactViewGroup(context) {
91
98
  set(value) {
92
99
  if (field != value) {
93
100
  field = value
101
+ invalidateGradientCache()
94
102
  invalidate()
95
103
  }
96
104
  }
97
105
 
106
+ // Drawing objects — pre-allocated, reused every frame
98
107
  private val shimmerPaint = Paint()
99
- private val gradientColors = IntArray(3)
100
- private val gradientPositions = floatArrayOf(0f, 0.5f, 1f)
101
- private val gradientCoords = FloatArray(4)
102
- private val clipPath = Path()
103
- private val clipRect = RectF()
104
- internal var cornerRadius: Float = 0f
108
+ private val drawRect = RectF()
105
109
  private var transitionAnimator: ValueAnimator? = null
106
110
  private var shimmerOpacity: Float = 1f
107
111
  private var contentOpacity: Float = 0f
@@ -109,23 +113,53 @@ class GleamView(context: Context) : ReactViewGroup(context) {
109
113
  private var transitionProgress: Float = 0f
110
114
  private var isRegistered: Boolean = false
111
115
 
116
+ // Cached gradient — only recreated when colors change
117
+ private var cachedGradient: LinearGradient? = null
118
+ private val shaderMatrix = Matrix()
119
+ private var lastCachedBaseColor: Int = 0
120
+ private var lastCachedHighlight: Int = 0
121
+
122
+ // Cached corner radius in pixels — only recomputed when prop changes
123
+ internal var cornerRadius: Float = 0f
124
+ set(value) {
125
+ if (field != value) {
126
+ field = value
127
+ cornerRadiusPx = PixelUtil.toPixelFromDIP(value)
128
+ }
129
+ }
130
+ private var cornerRadiusPx: Float = 0f
131
+
112
132
  init {
113
133
  setWillNotDraw(false)
114
134
  }
115
135
 
116
136
  override fun onAttachedToWindow() {
117
137
  super.onAttachedToWindow()
118
- if (loading) {
119
- registerClock()
138
+ if (!didAttach) {
139
+ didAttach = true
140
+ if (loading) {
141
+ registerClock()
142
+ } else {
143
+ contentOpacity = 1f
144
+ shimmerOpacity = 0f
145
+ }
146
+ } else {
147
+ // Re-attachment: restore correct state
148
+ if (loading) {
149
+ registerClock()
150
+ } else if (!isTransitioning) {
151
+ contentOpacity = 1f
152
+ shimmerOpacity = 0f
153
+ }
120
154
  }
121
155
  }
122
156
 
123
157
  override fun onDetachedFromWindow() {
124
158
  super.onDetachedFromWindow()
125
159
  unregisterClock()
160
+ isTransitioning = false
126
161
  transitionAnimator?.cancel()
127
162
  transitionAnimator = null
128
- isTransitioning = false
129
163
  }
130
164
 
131
165
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@@ -135,6 +169,14 @@ class GleamView(context: Context) : ReactViewGroup(context) {
135
169
  }
136
170
  }
137
171
 
172
+ /** Called by ViewManager when the view is dropped */
173
+ fun cleanup() {
174
+ unregisterClock()
175
+ isTransitioning = false
176
+ transitionAnimator?.cancel()
177
+ transitionAnimator = null
178
+ }
179
+
138
180
  private fun registerClock() {
139
181
  if (!isRegistered) {
140
182
  isRegistered = true
@@ -149,24 +191,39 @@ class GleamView(context: Context) : ReactViewGroup(context) {
149
191
  }
150
192
  }
151
193
 
152
- /** Called by SharedClock every frame */
153
- fun onFrame() {
194
+ /** Called by SharedClock every frame with current timestamp */
195
+ internal var frameTimeMs: Float = 0f
196
+
197
+ internal fun onFrame(timeMs: Float) {
198
+ frameTimeMs = timeMs
154
199
  invalidate()
155
200
  }
156
201
 
157
- /**
158
- * Compute progress from global time.
159
- * Uses cosine easing: progress = (1 - cos(phase)) / 2
160
- * This matches AccelerateDecelerateInterpolator and ensures smooth looping.
161
- */
202
+ private fun invalidateGradientCache() {
203
+ cachedGradient = null
204
+ }
205
+
162
206
  private fun computeProgress(): Float {
163
- val timeMs = SystemClock.uptimeMillis().toFloat()
207
+ val timeMs = if (frameTimeMs > 0f) frameTimeMs else SystemClock.uptimeMillis().toFloat()
164
208
  val effectiveTime = (timeMs - delay).coerceAtLeast(0f)
165
209
  val rawProgress = (effectiveTime % speed) / speed
166
- // AccelerateDecelerateInterpolator equivalent: (cos((x + 1) * PI) / 2) + 0.5
167
210
  return ((cos((rawProgress + 1.0) * PI) / 2.0) + 0.5).toFloat()
168
211
  }
169
212
 
213
+ private fun ensureGradient(effectiveHighlight: Int) {
214
+ if (cachedGradient != null && lastCachedBaseColor == baseColor && lastCachedHighlight == effectiveHighlight) {
215
+ return
216
+ }
217
+ cachedGradient = LinearGradient(
218
+ 0f, 0f, 1f, 0f,
219
+ intArrayOf(baseColor, effectiveHighlight, baseColor),
220
+ floatArrayOf(0f, 0.5f, 1f),
221
+ Shader.TileMode.CLAMP
222
+ )
223
+ lastCachedBaseColor = baseColor
224
+ lastCachedHighlight = effectiveHighlight
225
+ }
226
+
170
227
  override fun dispatchDraw(canvas: Canvas) {
171
228
  val w = width.toFloat()
172
229
  val h = height.toFloat()
@@ -193,40 +250,37 @@ class GleamView(context: Context) : ReactViewGroup(context) {
193
250
  highlightColor
194
251
  }
195
252
 
196
- gradientColors[0] = baseColor
197
- gradientColors[1] = effectiveHighlight
198
- gradientColors[2] = baseColor
253
+ ensureGradient(effectiveHighlight)
254
+ val gradient = cachedGradient ?: return
199
255
 
256
+ // Position the gradient using a matrix instead of recreating it
200
257
  when (direction) {
201
258
  Direction.LTR -> {
202
259
  val size = w
203
260
  val s = -size + (animationProgress * (w + 2 * size))
204
- gradientCoords[0] = s; gradientCoords[1] = 0f
205
- gradientCoords[2] = s + size; gradientCoords[3] = 0f
261
+ shaderMatrix.setScale(size, h)
262
+ shaderMatrix.postTranslate(s, 0f)
206
263
  }
207
264
  Direction.RTL -> {
208
265
  val size = w
209
266
  val s = w + size - (animationProgress * (w + 2 * size))
210
- gradientCoords[0] = s; gradientCoords[1] = 0f
211
- gradientCoords[2] = s - size; gradientCoords[3] = 0f
267
+ shaderMatrix.setScale(-size, h)
268
+ shaderMatrix.postTranslate(s, 0f)
212
269
  }
213
270
  Direction.TTB -> {
214
271
  val size = h
215
272
  val s = -size + (animationProgress * (h + 2 * size))
216
- gradientCoords[0] = 0f; gradientCoords[1] = s
217
- gradientCoords[2] = 0f; gradientCoords[3] = s + size
273
+ shaderMatrix.setRotate(90f)
274
+ shaderMatrix.postScale(w, size)
275
+ shaderMatrix.postTranslate(0f, s)
218
276
  }
219
277
  }
220
278
 
221
- shimmerPaint.shader = LinearGradient(
222
- gradientCoords[0], gradientCoords[1],
223
- gradientCoords[2], gradientCoords[3],
224
- gradientColors, gradientPositions,
225
- Shader.TileMode.CLAMP
226
- )
279
+ gradient.setLocalMatrix(shaderMatrix)
280
+ shimmerPaint.shader = gradient
227
281
  shimmerPaint.alpha = (shimmerOpacity * 255).toInt()
228
282
 
229
- // Shrink: scale down, fade starts at 30%
283
+ // Shrink: scale down, fast opacity fade
230
284
  if (isTransitioning && transitionType == TransitionType.SHRINK) {
231
285
  val scale = 1f - transitionProgress * 0.5f
232
286
  val shrinkOpacity = (1f - transitionProgress * 2.5f).coerceAtLeast(0f)
@@ -235,7 +289,7 @@ class GleamView(context: Context) : ReactViewGroup(context) {
235
289
  canvas.scale(scale, scale, w / 2f, h / 2f)
236
290
  }
237
291
 
238
- // Collapse: vertically then horizontally, with opacity fade
292
+ // Collapse: vertically then horizontally, with fast opacity fade
239
293
  if (isTransitioning && transitionType == TransitionType.COLLAPSE) {
240
294
  val p = transitionProgress
241
295
  val scaleY = if (p < 0.6f) 1f - (p / 0.6f) * 0.98f else 0.02f
@@ -246,20 +300,15 @@ class GleamView(context: Context) : ReactViewGroup(context) {
246
300
  canvas.scale(scaleX, scaleY, w / 2f, h / 2f)
247
301
  }
248
302
 
249
- if (cornerRadius > 0f) {
250
- val r = PixelUtil.toPixelFromDIP(cornerRadius)
251
- val count = canvas.save()
252
- clipPath.reset()
253
- clipRect.set(0f, 0f, w, h)
254
- clipPath.addRoundRect(clipRect, r, r, Path.Direction.CW)
255
- canvas.clipPath(clipPath)
256
- canvas.drawRect(0f, 0f, w, h, shimmerPaint)
257
- canvas.restoreToCount(count)
303
+ // Draw shimmer use drawRoundRect instead of clipPath for hardware acceleration
304
+ drawRect.set(0f, 0f, w, h)
305
+ if (cornerRadiusPx > 0f) {
306
+ canvas.drawRoundRect(drawRect, cornerRadiusPx, cornerRadiusPx, shimmerPaint)
258
307
  } else {
259
- canvas.drawRect(0f, 0f, w, h, shimmerPaint)
308
+ canvas.drawRect(drawRect, shimmerPaint)
260
309
  }
261
310
 
262
- // Restore scale/CRT transform
311
+ // Restore scale transform
263
312
  if (isTransitioning && (transitionType == TransitionType.SHRINK || transitionType == TransitionType.COLLAPSE)) {
264
313
  canvas.restore()
265
314
  }
@@ -276,9 +325,12 @@ class GleamView(context: Context) : ReactViewGroup(context) {
276
325
 
277
326
  private fun applyLoadingState(wasLoading: Boolean) {
278
327
  if (loading) {
328
+ // Set isTransitioning=false BEFORE cancel to prevent stale onAnimationEnd
329
+ isTransitioning = false
330
+ transitionGeneration++
279
331
  transitionAnimator?.cancel()
280
332
  transitionAnimator = null
281
- isTransitioning = false
333
+ transitionProgress = 0f
282
334
  contentOpacity = 0f
283
335
  shimmerOpacity = 1f
284
336
  registerClock()
@@ -286,12 +338,16 @@ class GleamView(context: Context) : ReactViewGroup(context) {
286
338
  } else {
287
339
  if (wasLoading && transitionDuration > 0f) {
288
340
  isTransitioning = true
341
+ // Unregister from shared clock — the ValueAnimator drives redraws during transition
342
+ unregisterClock()
343
+ val currentGen = ++transitionGeneration
289
344
  transitionAnimator?.cancel()
290
345
 
291
346
  transitionAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
292
347
  duration = transitionDuration.toLong()
293
- interpolator = DecelerateInterpolator()
348
+ interpolator = DecelerateInterpolator(2f)
294
349
  addUpdateListener { anim ->
350
+ if (transitionGeneration != currentGen) return@addUpdateListener
295
351
  val p = anim.animatedValue as Float
296
352
  transitionProgress = p
297
353
  contentOpacity = p
@@ -300,6 +356,7 @@ class GleamView(context: Context) : ReactViewGroup(context) {
300
356
  }
301
357
  addListener(object : android.animation.AnimatorListenerAdapter() {
302
358
  override fun onAnimationEnd(animation: android.animation.Animator) {
359
+ if (transitionGeneration != currentGen) return
303
360
  finishTransition()
304
361
  }
305
362
  })
@@ -316,6 +373,7 @@ class GleamView(context: Context) : ReactViewGroup(context) {
316
373
  }
317
374
 
318
375
  private fun finishTransition() {
376
+ if (!isTransitioning) return
319
377
  isTransitioning = false
320
378
  unregisterClock()
321
379
  contentOpacity = 1f
@@ -350,11 +408,14 @@ class GleamView(context: Context) : ReactViewGroup(context) {
350
408
  * they compute progress from the same global timestamp.
351
409
  */
352
410
  companion object SharedClock {
353
- private val views = mutableSetOf<GleamView>()
411
+ private val views = mutableListOf<GleamView>()
412
+ private val iterationSnapshot = mutableListOf<GleamView>()
354
413
  private var frameCallback: Choreographer.FrameCallback? = null
355
414
 
356
415
  fun register(view: GleamView) {
357
- views.add(view)
416
+ if (!views.contains(view)) {
417
+ views.add(view)
418
+ }
358
419
  if (views.size == 1) start()
359
420
  }
360
421
 
@@ -364,8 +425,11 @@ class GleamView(context: Context) : ReactViewGroup(context) {
364
425
  }
365
426
 
366
427
  private fun start() {
367
- frameCallback = Choreographer.FrameCallback { _ ->
368
- views.toList().forEach { it.onFrame() }
428
+ frameCallback = Choreographer.FrameCallback { frameTimeNanos ->
429
+ val timeMs = frameTimeNanos / 1_000_000f
430
+ iterationSnapshot.clear()
431
+ iterationSnapshot.addAll(views)
432
+ iterationSnapshot.forEach { it.onFrame(timeMs) }
369
433
  frameCallback?.let { Choreographer.getInstance().postFrameCallback(it) }
370
434
  }
371
435
  Choreographer.getInstance().postFrameCallback(frameCallback!!)
@@ -81,6 +81,11 @@ class GleamViewManager : ViewGroupManager<GleamView>(),
81
81
  super.setBorderRadius(view, borderRadius)
82
82
  }
83
83
 
84
+ override fun onDropViewInstance(view: GleamView) {
85
+ view.cleanup()
86
+ super.onDropViewInstance(view)
87
+ }
88
+
84
89
  override fun needsCustomLayoutForChildren(): Boolean = false
85
90
 
86
91
  companion object {
package/ios/GleamView.mm CHANGED
@@ -20,35 +20,62 @@ typedef NS_ENUM(NSInteger, GleamDirection) {
20
20
 
21
21
  #pragma mark - Shared Display Link Clock
22
22
 
23
- static NSMutableSet<GleamView *> *_registeredViews;
23
+ // Plain C array for zero-alloc per-frame iteration.
24
+ // Cleanup is guaranteed via removeFromSuperview, prepareForRecycle, and dealloc.
25
+ static GleamView * __strong *_views = NULL;
26
+ static NSUInteger _viewCount = 0;
27
+ static NSUInteger _viewCapacity = 0;
24
28
  static CADisplayLink *_displayLink;
25
29
 
26
- static void _ensureDisplayLink(void) {
27
- if (!_registeredViews) {
28
- _registeredViews = [NSMutableSet new];
29
- }
30
- }
31
-
32
30
  static void _startDisplayLinkIfNeeded(void) {
33
31
  if (_displayLink) return;
34
32
  _displayLink = [CADisplayLink displayLinkWithTarget:[GleamView class] selector:@selector(_onFrame:)];
33
+ if (@available(iOS 15.0, *)) {
34
+ _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(30, 60, 60);
35
+ }
35
36
  [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
36
37
  }
37
38
 
38
39
  static void _stopDisplayLinkIfNeeded(void) {
39
- if (_registeredViews.count > 0) return;
40
+ if (_viewCount > 0) return;
40
41
  [_displayLink invalidate];
41
42
  _displayLink = nil;
42
43
  }
43
44
 
44
45
  static void _registerView(GleamView *view) {
45
- _ensureDisplayLink();
46
- [_registeredViews addObject:view];
46
+ // Check duplicate
47
+ for (NSUInteger i = 0; i < _viewCount; i++) {
48
+ if (_views[i] == view) return;
49
+ }
50
+ // Grow if needed
51
+ if (_viewCount >= _viewCapacity) {
52
+ NSUInteger newCap = _viewCapacity == 0 ? 16 : _viewCapacity * 2;
53
+ GleamView * __strong *newBuf = (GleamView * __strong *)calloc(newCap, sizeof(GleamView *));
54
+ if (_views) {
55
+ for (NSUInteger i = 0; i < _viewCount; i++) {
56
+ newBuf[i] = _views[i];
57
+ _views[i] = nil;
58
+ }
59
+ free(_views);
60
+ }
61
+ _views = newBuf;
62
+ _viewCapacity = newCap;
63
+ }
64
+ _views[_viewCount++] = view;
47
65
  _startDisplayLinkIfNeeded();
48
66
  }
49
67
 
50
68
  static void _unregisterView(GleamView *view) {
51
- [_registeredViews removeObject:view];
69
+ for (NSUInteger i = 0; i < _viewCount; i++) {
70
+ if (_views[i] == view) {
71
+ _views[i] = nil;
72
+ // Swap with last
73
+ _views[i] = _views[_viewCount - 1];
74
+ _views[_viewCount - 1] = nil;
75
+ _viewCount--;
76
+ break;
77
+ }
78
+ }
52
79
  _stopDisplayLinkIfNeeded();
53
80
  }
54
81
 
@@ -62,7 +89,7 @@ static void _unregisterView(GleamView *view) {
62
89
  CGFloat _speed;
63
90
  CGFloat _delay;
64
91
  CGFloat _transitionDuration;
65
- NSInteger _transitionTypeValue; // 0=fade, 1=slide, 2=dissolve, 3=scale
92
+ NSInteger _transitionTypeValue; // 0=fade, 1=shrink, 2=collapse
66
93
  CGFloat _intensity;
67
94
  GleamDirection _direction;
68
95
  UIColor *_baseColor;
@@ -73,6 +100,8 @@ static void _unregisterView(GleamView *view) {
73
100
  CGFloat _transitionElapsed;
74
101
  CGFloat _shimmerOpacity;
75
102
  CGFloat _contentAlpha;
103
+ CGFloat _lastSetChildrenAlpha;
104
+ BOOL _didInitialSetup;
76
105
  }
77
106
 
78
107
  + (ComponentDescriptorProvider)componentDescriptorProvider
@@ -82,9 +111,15 @@ static void _unregisterView(GleamView *view) {
82
111
 
83
112
  + (void)_onFrame:(CADisplayLink *)link
84
113
  {
85
- NSArray *views = [_registeredViews allObjects];
86
- for (GleamView *view in views) {
87
- [view _tick];
114
+ NSUInteger count = _viewCount;
115
+ if (count == 0) return;
116
+ CFTimeInterval now = CACurrentMediaTime();
117
+ // Iterate by index — safe even if _tick triggers unregister (swap-remove)
118
+ // Process in reverse to handle swap-remove correctly
119
+ for (NSUInteger i = count; i > 0; i--) {
120
+ if (i - 1 < _viewCount) {
121
+ [_views[i - 1] _tickWithTime:now];
122
+ }
88
123
  }
89
124
  }
90
125
 
@@ -109,9 +144,10 @@ static void _unregisterView(GleamView *view) {
109
144
  _isTransitioning = NO;
110
145
  _shimmerOpacity = 1.0;
111
146
  _contentAlpha = 0.0;
147
+ _lastSetChildrenAlpha = -1.0;
148
+ _didInitialSetup = NO;
112
149
 
113
150
  _shimmerLayer = [CAGradientLayer layer];
114
- // Disable implicit animations on the gradient layer
115
151
  _shimmerLayer.actions = @{
116
152
  @"startPoint": [NSNull null],
117
153
  @"endPoint": [NSNull null],
@@ -120,7 +156,7 @@ static void _unregisterView(GleamView *view) {
120
156
  @"position": [NSNull null],
121
157
  @"transform": [NSNull null],
122
158
  };
123
-
159
+ _shimmerLayer.locations = @[@0.0, @0.5, @1.0];
124
160
  }
125
161
  return self;
126
162
  }
@@ -151,13 +187,54 @@ static void _unregisterView(GleamView *view) {
151
187
  - (void)removeFromSuperview
152
188
  {
153
189
  [self _unregisterClock];
190
+ if (_isTransitioning) {
191
+ _isTransitioning = NO;
192
+ }
193
+ // Reset to clean state based on loading
194
+ CGFloat targetAlpha = _loading ? 0.0 : 1.0;
195
+ _contentAlpha = targetAlpha;
196
+ _lastSetChildrenAlpha = -1.0;
197
+ for (UIView *subview in self.subviews) {
198
+ subview.alpha = targetAlpha;
199
+ }
200
+ _lastSetChildrenAlpha = targetAlpha;
201
+ _shimmerOpacity = _loading ? 1.0 : 0.0;
202
+ _shimmerLayer.opacity = _shimmerOpacity;
154
203
  _shimmerLayer.mask = nil;
204
+ _shimmerLayer.transform = CATransform3DIdentity;
205
+ [_shimmerLayer removeFromSuperlayer];
155
206
  [super removeFromSuperview];
156
207
  }
157
208
 
158
- - (void)dealloc
209
+ - (void)prepareForRecycle
159
210
  {
211
+ [super prepareForRecycle];
160
212
  [self _unregisterClock];
213
+ _isTransitioning = NO;
214
+ _shimmerLayer.mask = nil;
215
+ _shimmerLayer.transform = CATransform3DIdentity;
216
+ _shimmerLayer.opacity = 0.0;
217
+ [_shimmerLayer removeFromSuperlayer];
218
+ _contentAlpha = 0.0;
219
+ _lastSetChildrenAlpha = -1.0;
220
+ _shimmerOpacity = 1.0;
221
+ _loading = YES;
222
+ _wasLoading = YES;
223
+ _didInitialSetup = NO;
224
+ }
225
+
226
+ - (void)dealloc
227
+ {
228
+ if (_isRegistered) {
229
+ _isRegistered = NO;
230
+ if ([NSThread isMainThread]) {
231
+ _unregisterView(self);
232
+ } else {
233
+ dispatch_async(dispatch_get_main_queue(), ^{
234
+ _stopDisplayLinkIfNeeded();
235
+ });
236
+ }
237
+ }
161
238
  }
162
239
 
163
240
  - (void)_registerClock
@@ -182,7 +259,7 @@ static void _unregisterView(GleamView *view) {
182
259
  const auto &newViewProps = *std::static_pointer_cast<GleamViewProps const>(props);
183
260
 
184
261
  if (oldViewProps.speed != newViewProps.speed) {
185
- _speed = newViewProps.speed / 1000.0;
262
+ _speed = fmax(newViewProps.speed / 1000.0, 0.001);
186
263
  }
187
264
 
188
265
  if (oldViewProps.delay != newViewProps.delay) {
@@ -200,10 +277,6 @@ static void _unregisterView(GleamView *view) {
200
277
  else _transitionTypeValue = 0;
201
278
  }
202
279
 
203
- if (oldViewProps.intensity != newViewProps.intensity) {
204
- _intensity = fmin(fmax(newViewProps.intensity, 0.0), 1.0);
205
- }
206
-
207
280
  if (oldViewProps.direction != newViewProps.direction) {
208
281
  auto dir = newViewProps.direction;
209
282
  if (dir == GleamViewDirection::Rtl) {
@@ -215,54 +288,77 @@ static void _unregisterView(GleamView *view) {
215
288
  }
216
289
  }
217
290
 
291
+ BOOL colorsChanged = NO;
292
+ if (oldViewProps.intensity != newViewProps.intensity) {
293
+ _intensity = fmin(fmax(newViewProps.intensity, 0.0), 1.0);
294
+ colorsChanged = YES;
295
+ }
296
+
218
297
  if (oldViewProps.baseColor != newViewProps.baseColor) {
219
298
  UIColor *color = RCTUIColorFromSharedColor(newViewProps.baseColor);
220
299
  _baseColor = color ?: [UIColor colorWithRed:0.878 green:0.878 blue:0.878 alpha:1.0];
300
+ colorsChanged = YES;
221
301
  }
222
302
 
223
303
  if (oldViewProps.highlightColor != newViewProps.highlightColor) {
224
304
  UIColor *color = RCTUIColorFromSharedColor(newViewProps.highlightColor);
225
305
  _highlightColor = color ?: [UIColor colorWithRed:0.961 green:0.961 blue:0.961 alpha:1.0];
306
+ colorsChanged = YES;
226
307
  }
227
308
 
228
309
  if (oldViewProps.loading != newViewProps.loading) {
229
310
  _loading = newViewProps.loading;
230
311
  }
231
312
 
232
- [self _updateShimmerColors];
233
- [self _applyLoadingState];
313
+ if (!_didInitialSetup) {
314
+ _didInitialSetup = YES;
315
+ [self _updateShimmerColors];
316
+ if (_loading) {
317
+ _wasLoading = YES;
318
+ [self _applyLoadingState];
319
+ } else {
320
+ _wasLoading = NO;
321
+ _contentAlpha = 1.0;
322
+ _shimmerOpacity = 0.0;
323
+ _lastSetChildrenAlpha = -1.0;
324
+ for (UIView *subview in self.subviews) {
325
+ subview.alpha = 1.0;
326
+ }
327
+ _lastSetChildrenAlpha = 1.0;
328
+ }
329
+ } else {
330
+ if (colorsChanged) {
331
+ [self _updateShimmerColors];
332
+ }
333
+ if (oldViewProps.loading != newViewProps.loading) {
334
+ [self _applyLoadingState];
335
+ }
336
+ }
234
337
 
235
338
  [super updateProps:props oldProps:oldProps];
236
339
  }
237
340
 
238
341
  #pragma mark - Private
239
342
 
240
- /**
241
- * Compute shimmer progress from global time.
242
- * All views with same speed/delay share the same progress value.
243
- * Uses cosine easing for smooth looping (matches AccelerateDecelerateInterpolator).
244
- */
245
- - (CGFloat)_computeProgress
343
+ - (CGFloat)_computeProgressWithTime:(CFTimeInterval)now
246
344
  {
247
- CFTimeInterval time = CACurrentMediaTime();
248
- CGFloat effectiveTime = fmax(time - _delay, 0.0);
345
+ CGFloat effectiveTime = fmax(now - _delay, 0.0);
249
346
  CGFloat rawProgress = fmod(effectiveTime, _speed) / _speed;
250
- // Cosine easing: (1 - cos(rawProgress * PI)) / 2
251
347
  return (1.0 - cos(rawProgress * M_PI)) / 2.0;
252
348
  }
253
349
 
254
- - (void)_tick
350
+ - (void)_tickWithTime:(CFTimeInterval)now
255
351
  {
352
+ if (!self.window) return;
353
+
256
354
  if (_isTransitioning) {
257
355
  _transitionElapsed += _displayLink.duration;
258
- CGFloat t = fmin(_transitionElapsed / _transitionDuration, 1.0);
259
- // Ease out
356
+ CGFloat t = _transitionDuration > 0 ? fmin(_transitionElapsed / _transitionDuration, 1.0) : 1.0;
260
357
  CGFloat eased = 1.0 - (1.0 - t) * (1.0 - t);
261
358
 
262
359
  switch (_transitionTypeValue) {
263
- case 1: { // Shrink — scale down with mask clipping
264
- _contentAlpha = eased;
265
- [self _setChildrenAlpha:_contentAlpha];
360
+ case 1: { // Shrink
361
+ [self _setChildrenAlphaIfNeeded:eased];
266
362
  CGFloat shrinkOpacity = 1.0 - fmin(eased * 2.5, 1.0);
267
363
  _shimmerLayer.opacity = shrinkOpacity;
268
364
  CGFloat scale = 1.0 - eased * 0.5;
@@ -278,12 +374,17 @@ static void _unregisterView(GleamView *view) {
278
374
  mask.actions = @{@"path": [NSNull null]};
279
375
  _shimmerLayer.mask = mask;
280
376
  }
281
- mask.path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(x, y, fmax(w, 0.1), fmax(h, 0.1)) cornerRadius:self.layer.cornerRadius * scale].CGPath;
377
+ CGPathRef path = CGPathCreateWithRoundedRect(
378
+ CGRectMake(x, y, fmax(w, 0.1), fmax(h, 0.1)),
379
+ self.layer.cornerRadius * scale,
380
+ self.layer.cornerRadius * scale,
381
+ NULL);
382
+ mask.path = path;
383
+ CGPathRelease(path);
282
384
  break;
283
385
  }
284
- case 2: { // Collapse — vertically then horizontally via clip rect
285
- _contentAlpha = eased;
286
- [self _setChildrenAlpha:_contentAlpha];
386
+ case 2: { // Collapse
387
+ [self _setChildrenAlphaIfNeeded:eased];
287
388
  CGFloat collapseOpacity = 1.0 - fmin(eased * 2.5, 1.0);
288
389
  _shimmerLayer.opacity = collapseOpacity;
289
390
  CGRect bounds = self.bounds;
@@ -294,44 +395,51 @@ static void _unregisterView(GleamView *view) {
294
395
  CGFloat x = (bounds.size.width - w) / 2.0;
295
396
  CGFloat y = (bounds.size.height - h) / 2.0;
296
397
 
297
- // Use a mask layer to clip the shimmer to the collapsing rect
298
398
  CAShapeLayer *mask = (CAShapeLayer *)_shimmerLayer.mask;
299
399
  if (!mask) {
300
400
  mask = [CAShapeLayer layer];
301
401
  mask.actions = @{@"path": [NSNull null]};
302
402
  _shimmerLayer.mask = mask;
303
403
  }
304
- mask.path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(x, y, fmax(w, 0.1), fmax(h, 0.1)) cornerRadius:self.layer.cornerRadius].CGPath;
404
+ CGPathRef path = CGPathCreateWithRoundedRect(
405
+ CGRectMake(x, y, fmax(w, 0.1), fmax(h, 0.1)),
406
+ self.layer.cornerRadius,
407
+ self.layer.cornerRadius,
408
+ NULL);
409
+ mask.path = path;
410
+ CGPathRelease(path);
305
411
  break;
306
412
  }
307
413
  default: // Fade
308
- _contentAlpha = eased;
309
- [self _setChildrenAlpha:_contentAlpha];
414
+ [self _setChildrenAlphaIfNeeded:eased];
310
415
  _shimmerOpacity = 1.0 - eased;
311
416
  _shimmerLayer.opacity = _shimmerOpacity;
312
417
  break;
313
418
  }
314
419
 
315
- [self _updateGradientPosition];
420
+ [self _updateGradientPositionWithTime:now];
316
421
 
317
422
  if (t >= 1.0) {
318
423
  [self _finishTransition];
319
424
  }
320
425
  } else if (_loading) {
321
- [self _updateGradientPosition];
426
+ [self _updateGradientPositionWithTime:now];
322
427
  }
323
428
  }
324
429
 
325
- - (void)_setChildrenAlpha:(CGFloat)alpha
430
+ - (void)_setChildrenAlphaIfNeeded:(CGFloat)alpha
326
431
  {
432
+ if (fabs(alpha - _lastSetChildrenAlpha) < 0.005) return;
433
+ _lastSetChildrenAlpha = alpha;
434
+ _contentAlpha = alpha;
327
435
  for (UIView *subview in self.subviews) {
328
436
  subview.alpha = alpha;
329
437
  }
330
438
  }
331
439
 
332
- - (void)_updateGradientPosition
440
+ - (void)_updateGradientPositionWithTime:(CFTimeInterval)now
333
441
  {
334
- CGFloat progress = [self _computeProgress];
442
+ CGFloat progress = [self _computeProgressWithTime:now];
335
443
 
336
444
  CGPoint startPoint, endPoint;
337
445
 
@@ -365,8 +473,8 @@ static void _unregisterView(GleamView *view) {
365
473
  {
366
474
  UIColor *effectiveHighlight = _highlightColor;
367
475
  if (_intensity < 1.0) {
368
- CGFloat br, bg, bb, ba;
369
- CGFloat hr, hg, hb, ha;
476
+ CGFloat br = 0, bg = 0, bb = 0, ba = 1;
477
+ CGFloat hr = 0, hg = 0, hb = 0, ha = 1;
370
478
  [_baseColor getRed:&br green:&bg blue:&bb alpha:&ba];
371
479
  [_highlightColor getRed:&hr green:&hg blue:&hb alpha:&ha];
372
480
  CGFloat r = br + (hr - br) * _intensity;
@@ -381,7 +489,6 @@ static void _unregisterView(GleamView *view) {
381
489
  (id)effectiveHighlight.CGColor,
382
490
  (id)_baseColor.CGColor,
383
491
  ];
384
- _shimmerLayer.locations = @[@0.0, @0.5, @1.0];
385
492
  }
386
493
 
387
494
  - (void)_applyLoadingState
@@ -389,13 +496,20 @@ static void _unregisterView(GleamView *view) {
389
496
  if (_loading) {
390
497
  _isTransitioning = NO;
391
498
  _contentAlpha = 0.0;
499
+ _lastSetChildrenAlpha = -1.0;
392
500
  _shimmerOpacity = 1.0;
393
- [self _setChildrenAlpha:0.0];
501
+ for (UIView *subview in self.subviews) {
502
+ subview.alpha = 0.0;
503
+ }
504
+ _lastSetChildrenAlpha = 0.0;
394
505
  _shimmerLayer.opacity = 1.0;
506
+ _shimmerLayer.transform = CATransform3DIdentity;
507
+ _shimmerLayer.mask = nil;
395
508
  _shimmerLayer.frame = self.bounds;
396
509
  if (_shimmerLayer.superlayer != self.layer) {
397
510
  [self.layer addSublayer:_shimmerLayer];
398
511
  }
512
+ [self _updateShimmerColors];
399
513
  [self _registerClock];
400
514
  _wasLoading = YES;
401
515
  } else {
@@ -408,7 +522,12 @@ static void _unregisterView(GleamView *view) {
408
522
  [self _registerClock];
409
523
  } else {
410
524
  [self _unregisterClock];
411
- [self _setChildrenAlpha:1.0];
525
+ _lastSetChildrenAlpha = -1.0;
526
+ for (UIView *subview in self.subviews) {
527
+ subview.alpha = 1.0;
528
+ }
529
+ _lastSetChildrenAlpha = 1.0;
530
+ _contentAlpha = 1.0;
412
531
  _shimmerLayer.opacity = 0.0;
413
532
  [_shimmerLayer removeFromSuperlayer];
414
533
  [self _emitTransitionEnd:YES];
@@ -420,13 +539,17 @@ static void _unregisterView(GleamView *view) {
420
539
  {
421
540
  _isTransitioning = NO;
422
541
  [self _unregisterClock];
423
- [self _setChildrenAlpha:1.0];
542
+ _lastSetChildrenAlpha = -1.0;
543
+ for (UIView *subview in self.subviews) {
544
+ subview.alpha = 1.0;
545
+ }
546
+ _lastSetChildrenAlpha = 1.0;
547
+ _contentAlpha = 1.0;
424
548
  _shimmerLayer.opacity = 0.0;
425
549
  _shimmerLayer.transform = CATransform3DIdentity;
426
550
  _shimmerLayer.mask = nil;
427
551
  _shimmerLayer.frame = self.bounds;
428
552
  [_shimmerLayer removeFromSuperlayer];
429
- [self _updateShimmerColors]; // Reset colors after dissolve
430
553
  [self _emitTransitionEnd:YES];
431
554
  }
432
555
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-gleam",
3
- "version": "1.0.0-beta.4.2",
3
+ "version": "1.0.0-beta.6",
4
4
  "description": "Native-powered shimmer loading effect for React Native",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -92,8 +92,8 @@
92
92
  "typescript": "^5.9.2"
93
93
  },
94
94
  "peerDependencies": {
95
- "react": "*",
96
- "react-native": "*"
95
+ "react": ">=18.0.0",
96
+ "react-native": ">=0.76.0"
97
97
  },
98
98
  "workspaces": [
99
99
  "example"