react-native-blur-vibe 0.1.10 → 0.1.11

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.
@@ -12,31 +12,28 @@ import androidx.core.graphics.toColorInt
12
12
  import com.facebook.react.views.view.ReactViewGroup
13
13
 
14
14
  /**
15
- * BlurVibeView — Android API 21–30 backdrop blur (zero external dependencies)
15
+ * BlurVibeView — Android API 21–30 backdrop blur.
16
16
  *
17
- * Replaces QmBlurView with LegacyBlurController a direct RenderScript
18
- * implementation using only the Android SDK. No third-party library needed.
17
+ * Delegates all blur work to LegacyBlurController.
18
+ * Extends ReactViewGroup handles all RN style props (borderRadius,
19
+ * opacity, transforms etc) natively via ReactViewGroup's own draw pipeline.
19
20
  *
20
- * Extends ReactViewGroup so it hosts RN children correctly (Yoga layout,
21
- * touch events, z-ordering all work natively).
22
- *
23
- * For API 31+, BlurVibeViewApi31 is used instead (RenderEffect GPU path).
21
+ * THE STATIC BLUR FIX:
22
+ * draw() is overridden to be a no-op when LegacyBlurController.isCapturing
23
+ * is true. This prevents root.draw() from painting our stale blur output
24
+ * into the capture bitmap. Without this, each frame captures the previous
25
+ * frame's blur output and the blur appears frozen/static.
24
26
  */
25
27
  class BlurVibeView(context: Context) : ReactViewGroup(context) {
26
28
 
27
- // ── State ──────────────────────────────────────────────────────────────────
28
-
29
29
  private var blurController: LegacyBlurController? = null
30
- private var pendingBlurAmount = 10f
31
- private var pendingOverlay = Color.TRANSPARENT
32
- private var cornerRadiusPx = 0f
33
-
34
- // ── Init ───────────────────────────────────────────────────────────────────
30
+ private var pendingBlurAmount = 10f
31
+ private var pendingOverlay = Color.TRANSPARENT
32
+ private var cornerRadiusPx = 0f
35
33
 
36
34
  init {
37
35
  setWillNotDraw(false)
38
36
  super.setBackgroundColor(Color.TRANSPARENT)
39
- clipToOutline = true
40
37
  }
41
38
 
42
39
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -63,15 +60,28 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
63
60
 
64
61
  override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
65
62
  super.onWindowFocusChanged(hasWindowFocus)
66
- // Re-attach to the current ViewTreeObserver after split-screen / PiP transition.
67
- // Android may have killed and replaced the old observer during the mode switch.
68
63
  if (hasWindowFocus) blurController?.reAttach()
69
64
  }
70
65
 
71
- // ── Draw ───────────────────────────────────────────────────────────────────
66
+ // ── draw() — suppress self during root capture ────────────────────────────
67
+ //
68
+ // When LegacyBlurController is actively capturing (root.draw() in progress),
69
+ // skip drawing ourselves. This makes us invisible to the capture canvas so
70
+ // the capture bitmap contains ONLY the content behind us, not our own stale
71
+ // blur output. Without this, the blur appears static/frozen.
72
+
73
+ override fun draw(canvas: Canvas) {
74
+ if (blurController?.isCapturing == true) return
75
+ super.draw(canvas)
76
+ }
77
+
78
+ // ── onDraw ────────────────────────────────────────────────────────────────
72
79
 
73
80
  override fun onDraw(canvas: Canvas) {
81
+ // Draw the blurred background first
74
82
  blurController?.draw(canvas, width.toFloat(), height.toFloat())
83
+ // Let ReactViewGroup draw borders/radius/background on top natively
84
+ super.onDraw(canvas)
75
85
  }
76
86
 
77
87
  // ── Public setters ─────────────────────────────────────────────────────────
@@ -88,24 +98,28 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
88
98
  }
89
99
 
90
100
  fun setBlurRadius(factor: Int) {
91
- // No-op for now LegacyBlurController uses fixed DOWNSAMPLE_FACTOR = 4
92
- // Could expose downsampleFactor setter on controller if needed
101
+ // Exposed as a downsample override for power users not used internally
93
102
  }
94
103
 
95
104
  fun applyBorderRadius(radiusDp: Float) {
96
105
  cornerRadiusPx = TypedValue.applyDimension(
97
106
  TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
98
107
  )
99
- outlineProvider = object : ViewOutlineProvider() {
100
- override fun getOutline(view: View, outline: Outline) {
101
- outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx)
108
+ if (cornerRadiusPx > 0f) {
109
+ outlineProvider = object : ViewOutlineProvider() {
110
+ override fun getOutline(view: View, outline: Outline) {
111
+ outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx)
112
+ }
102
113
  }
114
+ clipToOutline = true
115
+ } else {
116
+ outlineProvider = ViewOutlineProvider.BACKGROUND
117
+ clipToOutline = false
103
118
  }
104
- clipToOutline = cornerRadiusPx > 0f
105
119
  invalidate()
106
120
  }
107
121
 
108
- fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") color: String?) { }
122
+ fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") color: String?) {}
109
123
 
110
124
  fun applyBlurEnabled(enabled: Boolean) {
111
125
  blurController?.enabled = enabled
@@ -118,15 +132,13 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
118
132
 
119
133
  // ── Layout passthrough ─────────────────────────────────────────────────────
120
134
 
121
- override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
122
- // Yoga handles all layout
123
- }
135
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {}
124
136
 
125
137
  // ── Helpers ────────────────────────────────────────────────────────────────
126
138
 
127
139
  private fun mapBlurAmount(amount: Float): Float {
128
140
  val t = amount.coerceIn(0f, 100f) / 100f
129
- return t * t * 25f // quadratic curve, max 25 (RenderScript kernel limit)
141
+ return t * t * 25f
130
142
  }
131
143
 
132
144
  private fun findBlurRoot(): ViewGroup? {
@@ -13,7 +13,6 @@ import android.graphics.Paint
13
13
  import android.graphics.PorterDuff
14
14
  import android.graphics.PorterDuffXfermode
15
15
  import android.graphics.RadialGradient
16
- import android.graphics.Rect
17
16
  import android.graphics.RectF
18
17
  import android.graphics.RenderEffect
19
18
  import android.graphics.RenderNode
@@ -28,41 +27,16 @@ import android.view.ViewTreeObserver
28
27
  import androidx.annotation.RequiresApi
29
28
  import androidx.core.graphics.toColorInt
30
29
  import com.facebook.react.views.view.ReactViewGroup
31
- import kotlin.math.max
32
30
  import kotlin.math.min
33
31
  import kotlin.random.Random
34
32
 
35
- /**
36
- * BlurVibeViewApi31 — Backdrop blur for Android API 31+
37
- *
38
- * Pipeline (adapted from ModernBlurView's RenderEffectBlur approach):
39
- *
40
- * 1. rootView.draw(canvas) → internalBitmap (bitmap capture, main thread)
41
- * 2. renderNode.beginRecording()
42
- * canvas.drawBitmap(internalBitmap) (bitmap → RenderNode — safe on all OEMs)
43
- * renderNode.endRecording()
44
- * 3. renderNode.setRenderEffect(
45
- * createChainEffect(tintEffect, blurEffect) (GPU blur + tint in one pass)
46
- * )
47
- * 4. onDraw: canvas.drawRenderNode(renderNode) (draws GPU result to screen)
48
- * + progressive mask + noise
49
- *
50
- * KEY INSIGHT from ModernBlurView:
51
- * Drawing a flat BITMAP into a RenderNode, then drawRenderNode() is stable
52
- * on all OEM devices (Oppo/OnePlus/Xiaomi/Samsung).
53
- * Drawing a RenderNode INSIDE another RenderNode's recording crashes
54
- * on OEM-patched GPU drivers. We don't do that here.
55
- *
56
- * Choreographer gate: max 1 capture per vsync.
57
- * Bitmap pool + RenderNode reuse: zero GC per frame.
58
- */
59
33
  @RequiresApi(Build.VERSION_CODES.S)
60
34
  class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
61
35
 
62
36
  // ── Blur params ────────────────────────────────────────────────────────────
63
37
 
64
- private var blurAmount = 10f
65
- private var overlayColor = Color.TRANSPARENT
38
+ private var blurAmount = 10f
39
+ private var overlayColor = Color.TRANSPARENT
66
40
  private var cornerRadiusPx = 0f
67
41
 
68
42
  // ── Progressive blur ──────────────────────────────────────────────────────
@@ -77,30 +51,45 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
77
51
  private var noiseBitmap: Bitmap? = null
78
52
  private val noisePaint = Paint()
79
53
 
80
- // ── Bitmap + RenderNode (ModernBlurView pattern) ───────────────────────────
81
- //
82
- // internalBitmap: captured root pixels at view resolution
83
- // renderNode: holds bitmap + RenderEffect (GPU blur + tint chain)
84
- //
85
- // The renderNode is reused every frame — only its content (bitmap) and
86
- // effect (radius/tint) are updated, not recreated.
54
+ // ── Bitmap + RenderNode ────────────────────────────────────────────────────
87
55
 
88
56
  private var internalBitmap: Bitmap? = null
89
57
  private val renderNode = RenderNode("BlurVibeNode")
90
58
 
91
- // ── Draw paint ────────────────────────────────────────────────────────────
59
+ // ── Capture exclusion flag ────────────────────────────────────────────────
60
+ //
61
+ // THE FIX FOR STATIC BLUR:
62
+ //
63
+ // root.draw(canvas) walks the entire view tree including THIS BlurView.
64
+ // When it reaches us during capture, our onDraw draws the PREVIOUS frame's
65
+ // blurred bitmap — so the capture contains our own stale output, not just
66
+ // the content behind us. This makes the blur appear static because each
67
+ // frame captures the previous frame's blur output, not the live content.
68
+ //
69
+ // Fix: set isCapturing = true before root.draw(), override draw() to be
70
+ // a no-op when isCapturing = true. root.draw() then skips us completely,
71
+ // capturing ONLY the content behind us. This is exactly how Dimezis
72
+ // BlurView solves the same problem.
73
+ //
74
+ // This does NOT cause a flash because we are not changing visibility —
75
+ // we are only suppressing our own draw() during the off-screen capture.
76
+ // The view remains visible on screen; we just skip drawing into the
77
+ // off-screen capture canvas.
78
+
79
+ private var isCapturing = false
92
80
 
93
- private val bitmapPaint = Paint(Paint.FILTER_BITMAP_FLAG)
94
- private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
81
+ // ── Draw paints ───────────────────────────────────────────────────────────
82
+
83
+ private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
95
84
  xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
96
85
  }
97
- private val overlayPaint = Paint(Paint.ANTI_ALIAS_FLAG)
86
+ private val noisePaintFinal = Paint()
98
87
 
99
88
  // ── Root view ─────────────────────────────────────────────────────────────
100
89
 
101
90
  private var blurRoot: ViewGroup? = null
102
- private val rootLocation = IntArray(2)
103
- private val blurViewLocation = IntArray(2)
91
+ private val myLocation = IntArray(2)
92
+ private val rootLocation = IntArray(2)
104
93
 
105
94
  // ── State ─────────────────────────────────────────────────────────────────
106
95
 
@@ -129,7 +118,6 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
129
118
  init {
130
119
  setWillNotDraw(false)
131
120
  super.setBackgroundColor(Color.TRANSPARENT)
132
- clipToOutline = true
133
121
  }
134
122
 
135
123
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -147,8 +135,9 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
147
135
  Choreographer.getInstance().removeFrameCallback(frameCallback)
148
136
  frameScheduled = false
149
137
  initialized = false
138
+ isCapturing = false
150
139
  blurRoot = null
151
- noiseBitmap?.recycle(); noiseBitmap = null
140
+ noiseBitmap?.recycle(); noiseBitmap = null
152
141
  internalBitmap?.recycle(); internalBitmap = null
153
142
  renderNode.discardDisplayList()
154
143
  super.onDetachedFromWindow()
@@ -158,118 +147,108 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
158
147
  super.onSizeChanged(w, h, oldw, oldh)
159
148
  if (w > 0 && h > 0) {
160
149
  internalBitmap?.recycle(); internalBitmap = null
150
+ renderNode.discardDisplayList()
151
+ initialized = false
161
152
  initBlur()
162
153
  }
163
154
  }
164
155
 
165
- // ── Multi-window / split-screen / PiP safety ──────────────────────────────
166
- //
167
- // Android can "kill" a ViewTreeObserver when the window enters/exits
168
- // split-screen, PiP, or freeform mode — creating a new one silently.
169
- // If we hold a reference to the old (dead) observer our preDrawListener
170
- // stops firing and blur freezes. We fix this by:
171
- // 1. Always re-attaching via the CURRENT observer (not a cached one)
172
- // 2. Checking isAlive() before adding — safe even if called redundantly
173
- // 3. Re-attaching on window focus gain (fires after every mode transition)
156
+ // ── Multi-window safety ───────────────────────────────────────────────────
174
157
 
175
158
  override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
176
159
  super.onWindowFocusChanged(hasWindowFocus)
177
160
  if (hasWindowFocus && blurEnabled && autoUpdate) {
178
- // Re-attach listener to the current (possibly new) ViewTreeObserver
179
161
  safeAddPreDrawListener()
180
162
  scheduleFrame()
181
163
  }
182
164
  }
183
165
 
184
- /**
185
- * Add preDrawListener to rootView's CURRENT ViewTreeObserver.
186
- * Removes from any stale observer first, then attaches to the live one.
187
- * Safe to call multiple times — isAlive() prevents double-attachment.
188
- */
189
166
  private fun safeAddPreDrawListener() {
190
167
  val root = blurRoot ?: return
191
168
  val vto = root.viewTreeObserver
192
- // Remove first (no-op if not attached) then re-add to current observer
193
169
  vto.removeOnPreDrawListener(preDrawListener)
194
- if (vto.isAlive) {
195
- vto.addOnPreDrawListener(preDrawListener)
196
- }
170
+ if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
197
171
  }
198
172
 
199
- // ── Blur init ─────────────────────────────────────────────────────────────
173
+ // ── Init blur ─────────────────────────────────────────────────────────────
200
174
 
201
175
  private fun initBlur() {
202
176
  val w = measuredWidth; if (w <= 0) return
203
177
  val h = measuredHeight; if (h <= 0) return
204
-
205
178
  internalBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
206
179
  renderNode.setPosition(0, 0, w, h)
207
180
  initialized = true
208
- setWillNotDraw(false)
209
181
  updateBlur()
210
182
  }
211
183
 
212
- // ── Core blur update (ModernBlurView pattern) ─────────────────────────────
184
+ // ── Core: capture + blur + render ─────────────────────────────────────────
213
185
 
214
186
  private fun updateBlur() {
215
187
  if (!blurEnabled || !initialized) return
216
- val root = blurRoot ?: return
217
- val bitmap = internalBitmap ?: return
218
-
219
- // ① Capture root into internalBitmap (same as ModernBlurView's approach)
220
- // Translate canvas so we capture exactly the region behind this view
221
- // getLocationInWindow — correct for ALL Android versions and ALL window modes
222
- // (split-screen, freeform, PiP, DeX).
223
- // rootView.draw() uses window-relative coordinates, so we must also use
224
- // window-relative positions for the offset — not screen-absolute.
225
- // getLocationOnScreen is WRONG in split-screen (Android 7+) because the
226
- // app window doesn't start at screen (0,0) in that mode.
227
- root.getLocationInWindow(rootLocation)
228
- getLocationInWindow(blurViewLocation)
229
-
230
- val scaleW = width.toFloat() / bitmap.width.toFloat()
231
- val scaleH = height.toFloat() / bitmap.height.toFloat()
232
- val left = (blurViewLocation[0] - rootLocation[0])
233
- val top = (blurViewLocation[1] - rootLocation[1])
188
+ val root = blurRoot ?: return
189
+ val bitmap = internalBitmap ?: return
190
+ if (bitmap.isRecycled) return
234
191
 
192
+ // ① Compute this view's offset within the root (window coords — correct
193
+ // for all window modes: split-screen, freeform, PiP, DeX)
194
+ root.getLocationInWindow(rootLocation)
195
+ getLocationInWindow(myLocation)
196
+ val offsetX = (myLocation[0] - rootLocation[0]).toFloat()
197
+ val offsetY = (myLocation[1] - rootLocation[1]).toFloat()
198
+
199
+ // ② Capture root content EXCLUDING this view.
200
+ // isCapturing = true causes our draw() to be a no-op, so root.draw()
201
+ // skips us and captures only the content behind us.
202
+ isCapturing = true
235
203
  val captureCanvas = Canvas(bitmap)
236
204
  captureCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
237
- captureCanvas.translate(-left / scaleW, -top / scaleH)
238
- captureCanvas.scale(1f / scaleW, 1f / scaleH)
205
+ captureCanvas.translate(-offsetX, -offsetY)
239
206
  try {
240
207
  root.draw(captureCanvas)
241
- } catch (_: Exception) { return }
242
-
243
- // ② Record bitmap into RenderNode (ModernBlurView key insight:
244
- // bitmap → RenderNode is stable; RenderNode → RenderNode is not)
245
- if (renderNode.width != bitmap.width || renderNode.height != bitmap.height) {
246
- renderNode.setPosition(0, 0, bitmap.width, bitmap.height)
208
+ } catch (_: Exception) {
209
+ isCapturing = false
210
+ return
247
211
  }
212
+ isCapturing = false
213
+
214
+ // ③ Record bitmap into RenderNode.
215
+ // Drawing a BITMAP into RenderNode is stable on all OEM drivers.
216
+ // Drawing a RenderNode into another RenderNode's recording is NOT.
217
+ renderNode.setPosition(0, 0, bitmap.width, bitmap.height)
248
218
  val nodeCanvas = renderNode.beginRecording()
249
219
  nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
250
220
  renderNode.endRecording()
251
221
 
252
- // Build chained RenderEffect: blur first, then tint on top (one GPU pass)
253
- val radius = blurRadiusFromAmount(blurAmount)
222
+ // Apply GPU blur + tint as a chained RenderEffect (single GPU pass)
223
+ val radius = blurRadiusFromAmount(blurAmount)
254
224
  val blurEffect = RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.MIRROR)
225
+ renderNode.setRenderEffect(
226
+ if (Color.alpha(overlayColor) > 0) {
227
+ RenderEffect.createChainEffect(
228
+ RenderEffect.createColorFilterEffect(
229
+ BlendModeColorFilter(overlayColor, BlendMode.SRC_ATOP)
230
+ ),
231
+ blurEffect
232
+ )
233
+ } else blurEffect
234
+ )
255
235
 
256
- val finalEffect = if (Color.alpha(overlayColor) > 0) {
257
- // Chain: blur → tint in single GPU pass (ModernBlurView's chained approach)
258
- val tintEffect = RenderEffect.createColorFilterEffect(
259
- BlendModeColorFilter(overlayColor, BlendMode.SRC_ATOP)
260
- )
261
- RenderEffect.createChainEffect(tintEffect, blurEffect)
262
- } else {
263
- blurEffect
264
- }
265
-
266
- renderNode.setRenderEffect(finalEffect)
267
-
268
- // ④ Trigger redraw — onDraw will drawRenderNode (GPU-rendered result)
269
236
  invalidate()
270
237
  }
271
238
 
272
- // ── Draw ───────────────────────────────────────────────────────────────────
239
+ // ── draw() override — no-op during capture ────────────────────────────────
240
+ //
241
+ // When isCapturing = true (root.draw() is in progress capturing background),
242
+ // suppress our own draw so we don't paint stale blur into the capture bitmap.
243
+ // This makes us invisible to root.draw() during capture only —
244
+ // NOT to the actual screen renderer.
245
+
246
+ override fun draw(canvas: Canvas) {
247
+ if (isCapturing) return // skip self during root capture
248
+ super.draw(canvas)
249
+ }
250
+
251
+ // ── onDraw ────────────────────────────────────────────────────────────────
273
252
 
274
253
  override fun onDraw(canvas: Canvas) {
275
254
  if (!blurEnabled || !initialized) return
@@ -277,23 +256,15 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
277
256
  val h = height.toFloat(); if (h <= 0f) return
278
257
  if (!renderNode.hasDisplayList()) return
279
258
 
280
- // Step 1: save layer for progressive mask compositing
259
+ // Progressive mask requires a saved layer so DST_IN mask composites correctly
281
260
  val saveCount = if (progressiveDirection != PROGRESSIVE_NONE) {
282
261
  canvas.saveLayer(0f, 0f, w, h, null)
283
262
  } else -1
284
263
 
285
- // Step 2: draw GPU-blurred + tinted result
286
- // Scale from bitmap resolution back to view resolution
287
- val bitmapW = internalBitmap?.width?.toFloat() ?: w
288
- val bitmapH = internalBitmap?.height?.toFloat() ?: h
289
- val scaleX = w / bitmapW
290
- val scaleY = h / bitmapH
291
- canvas.save()
292
- canvas.scale(scaleX, scaleY)
264
+ // Draw GPU-blurred + tinted result from RenderNode
293
265
  canvas.drawRenderNode(renderNode)
294
- canvas.restore()
295
266
 
296
- // Step 3: progressive alpha mask fades the blur
267
+ // Progressive alpha mask fades blur across the view
297
268
  if (progressiveDirection != PROGRESSIVE_NONE && saveCount >= 0) {
298
269
  buildProgressiveShader(w, h)?.let { shader ->
299
270
  maskPaint.shader = shader
@@ -302,12 +273,16 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
302
273
  canvas.restoreToCount(saveCount)
303
274
  }
304
275
 
305
- // Step 4: noise grain overlay
276
+ // Noise grain overlay
306
277
  noiseBitmap?.takeIf { !it.isRecycled && noiseFactor > 0f }?.let { noise ->
307
- noisePaint.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
308
- noisePaint.shader = BitmapShader(noise, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
309
- canvas.drawRect(0f, 0f, w, h, noisePaint)
278
+ noisePaintFinal.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
279
+ noisePaintFinal.shader = BitmapShader(noise, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
280
+ canvas.drawRect(0f, 0f, w, h, noisePaintFinal)
310
281
  }
282
+
283
+ // Let ReactViewGroup draw borders/background on top (handles borderRadius
284
+ // and all other RN style props natively — no conflict with our blur)
285
+ super.onDraw(canvas)
311
286
  }
312
287
 
313
288
  // ── Progressive shader ────────────────────────────────────────────────────
@@ -325,7 +300,7 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
325
300
  }
326
301
  }
327
302
 
328
- // ── Noise bitmap ──────────────────────────────────────────────────────────
303
+ // ── Noise ─────────────────────────────────────────────────────────────────
329
304
 
330
305
  private fun generateNoiseBitmap() {
331
306
  if (noiseBitmap?.isRecycled == false) return
@@ -342,8 +317,7 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
342
317
  // ── Public setters ─────────────────────────────────────────────────────────
343
318
 
344
319
  fun setBlurAmount(amount: Float) {
345
- blurAmount = amount.coerceIn(0f, 100f)
346
- scheduleFrame()
320
+ blurAmount = amount.coerceIn(0f, 100f); scheduleFrame()
347
321
  }
348
322
 
349
323
  fun setOverlayColor(colorString: String?) {
@@ -351,16 +325,24 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
351
325
  scheduleFrame()
352
326
  }
353
327
 
328
+ // borderRadius from JS style prop — handled natively by ReactViewGroup.
329
+ // applyBorderRadius is called by our @ReactProp "borderRadius" binding.
330
+ // We additionally set clipToOutline so the blur content is clipped correctly.
354
331
  fun applyBorderRadius(radiusDp: Float) {
355
332
  cornerRadiusPx = TypedValue.applyDimension(
356
333
  TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
357
334
  )
358
- outlineProvider = object : ViewOutlineProvider() {
359
- override fun getOutline(view: View, outline: Outline) {
360
- outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx)
335
+ if (cornerRadiusPx > 0f) {
336
+ outlineProvider = object : ViewOutlineProvider() {
337
+ override fun getOutline(view: View, outline: Outline) {
338
+ outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx)
339
+ }
361
340
  }
341
+ clipToOutline = true
342
+ } else {
343
+ outlineProvider = ViewOutlineProvider.BACKGROUND
344
+ clipToOutline = false
362
345
  }
363
- clipToOutline = cornerRadiusPx > 0f
364
346
  invalidate()
365
347
  }
366
348
 
@@ -383,10 +365,8 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
383
365
 
384
366
  fun applyBlurEnabled(enabled: Boolean) {
385
367
  blurEnabled = enabled
386
- if (enabled) {
387
- safeAddPreDrawListener()
388
- scheduleFrame()
389
- } else {
368
+ if (enabled) { safeAddPreDrawListener(); scheduleFrame() }
369
+ else {
390
370
  blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
391
371
  Choreographer.getInstance().removeFrameCallback(frameCallback)
392
372
  frameScheduled = false
@@ -414,10 +394,8 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
414
394
  }
415
395
  }
416
396
 
417
- private fun blurRadiusFromAmount(amount: Float): Float {
418
- val t = amount / 100f
419
- return (t * t * 25f).coerceIn(1f, 25f)
420
- }
397
+ private fun blurRadiusFromAmount(amount: Float): Float =
398
+ ((amount / 100f).let { it * it } * 25f).coerceIn(1f, 25f)
421
399
 
422
400
  private fun findBlurRoot(): ViewGroup? {
423
401
  var p = parent
@@ -440,12 +418,12 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
440
418
  val hex = t.removePrefix("#")
441
419
  return try {
442
420
  when (hex.length) {
443
- 3 -> Color.argb(255,hex[0].toString().repeat(2).toInt(16),
444
- hex[1].toString().repeat(2).toInt(16),
445
- hex[2].toString().repeat(2).toInt(16))
446
- 6 -> Color.argb(255,hex.substring(0,2).toInt(16),
447
- hex.substring(2,4).toInt(16),
448
- hex.substring(4,6).toInt(16))
421
+ 3 -> Color.argb(255, hex[0].toString().repeat(2).toInt(16),
422
+ hex[1].toString().repeat(2).toInt(16),
423
+ hex[2].toString().repeat(2).toInt(16))
424
+ 6 -> Color.argb(255, hex.substring(0,2).toInt(16),
425
+ hex.substring(2,4).toInt(16),
426
+ hex.substring(4,6).toInt(16))
449
427
  8 -> Color.argb(hex.substring(6,8).toInt(16),
450
428
  hex.substring(0,2).toInt(16),
451
429
  hex.substring(2,4).toInt(16),
@@ -6,7 +6,6 @@ import android.graphics.Color
6
6
  import android.graphics.Paint
7
7
  import android.graphics.PorterDuff
8
8
  import android.graphics.Rect
9
- import android.os.Build
10
9
  import android.renderscript.Allocation
11
10
  import android.renderscript.Element
12
11
  import android.renderscript.RenderScript
@@ -19,41 +18,30 @@ import android.view.ViewTreeObserver
19
18
  /**
20
19
  * LegacyBlurController — zero-dependency backdrop blur for Android API 21–30.
21
20
  *
22
- * Replaces QmBlurView with a direct RenderScript implementation.
23
- * RenderScript is part of the Android SDK — no external library needed.
21
+ * Uses Android SDK RenderScript (ScriptIntrinsicBlur) no external library.
24
22
  *
25
- * Pipeline per vsync:
26
- * preDrawListener (sets dirty flag only)
27
- * Choreographer.FrameCallback (once per vsync)
28
- * rootView.draw() into captureBitmap (main thread)
29
- * downsample into scaledBitmap
30
- * → ScriptIntrinsicBlur.forEach() (RenderScript, GPU-accelerated)
31
- * → view.invalidate()
32
- * → onDraw: drawBitmap(scaledBitmap) + overlay tint
33
- *
34
- * Key optimisations vs naive implementation:
35
- * - Choreographer gate: max 1 capture per vsync regardless of invalidation count
36
- * - Bitmap pool: captureBitmap + scaledBitmap reused each frame (zero GC)
37
- * - RenderScript Allocation pool: inputAlloc + outputAlloc reused (zero GC)
38
- * - Blur rounds = 2: two passes for smooth spread without pixelation
39
- * - Downsample factor = 4: captures at 1/16 resolution, blur hides pixel detail
23
+ * THE STATIC BLUR FIX:
24
+ * Before root.draw(), BlurVibeView.draw() is made a no-op via isCapturing flag.
25
+ * This means root.draw() skips the BlurView during capture, so we capture
26
+ * ONLY the content behind us — not our own previous blur output.
27
+ * Without this, each frame captures the previous blur blur appears static.
40
28
  */
41
- @Suppress("DEPRECATION") // RenderScript deprecated in API 31 — we only use this on API < 31
29
+ @Suppress("DEPRECATION")
42
30
  internal class LegacyBlurController(
43
- private val view: View,
31
+ private val view: BlurVibeView,
44
32
  private val rootView: ViewGroup
45
33
  ) {
46
34
 
47
35
  companion object {
48
- private const val DOWNSAMPLE_FACTOR = 4f // capture at 1/4 linear resolution (1/16 pixels)
49
- private const val BLUR_RADIUS = 8f // RenderScript Gaussian radius (1–25)
50
- private const val BLUR_ROUNDS = 2 // passes — 2 gives smooth spread
36
+ private const val DOWNSAMPLE_FACTOR = 4f
37
+ private const val BLUR_RADIUS = 8f
38
+ private const val BLUR_ROUNDS = 2
51
39
  }
52
40
 
53
41
  // ── Bitmap pool ────────────────────────────────────────────────────────────
54
42
 
55
- private var captureBitmap: Bitmap? = null // full-res root capture
56
- private var scaledBitmap: Bitmap? = null // downsampled before blur
43
+ private var captureBitmap: Bitmap? = null
44
+ private var scaledBitmap: Bitmap? = null
57
45
  private val capturePaint = Paint(Paint.FILTER_BITMAP_FLAG)
58
46
  private val drawPaint = Paint(Paint.FILTER_BITMAP_FLAG)
59
47
 
@@ -66,7 +54,7 @@ internal class LegacyBlurController(
66
54
 
67
55
  // ── State ──────────────────────────────────────────────────────────────────
68
56
 
69
- var overlayColor: Int = Color.TRANSPARENT
57
+ var overlayColor: Int = Color.TRANSPARENT
70
58
  var blurRadius: Float = BLUR_RADIUS
71
59
  var enabled: Boolean = true
72
60
  set(value) { field = value; if (!value) invalidatePool() }
@@ -77,8 +65,12 @@ internal class LegacyBlurController(
77
65
  else rootView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
78
66
  }
79
67
 
68
+ // isCapturing: set true before root.draw() so BlurVibeView.draw() is a no-op
69
+ // preventing stale self-capture. Accessed by BlurVibeView.draw().
70
+ var isCapturing = false
71
+ private set
72
+
80
73
  private var frameScheduled = false
81
- private var isCapturing = false
82
74
 
83
75
  // ── Choreographer gate ────────────────────────────────────────────────────
84
76
 
@@ -88,7 +80,7 @@ internal class LegacyBlurController(
88
80
  }
89
81
 
90
82
  private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
91
- if (!frameScheduled && enabled) {
83
+ if (!frameScheduled && enabled && autoUpdate) {
92
84
  frameScheduled = true
93
85
  Choreographer.getInstance().postFrameCallback(frameCallback)
94
86
  }
@@ -112,7 +104,6 @@ internal class LegacyBlurController(
112
104
  // ── Capture + blur ─────────────────────────────────────────────────────────
113
105
 
114
106
  private fun captureAndBlur() {
115
- if (isCapturing) return
116
107
  val rw = rootView.width; if (rw <= 0) return
117
108
  val rh = rootView.height; if (rh <= 0) return
118
109
  val vw = view.width; if (vw <= 0) return
@@ -121,85 +112,88 @@ internal class LegacyBlurController(
121
112
  val sw = (vw / DOWNSAMPLE_FACTOR).toInt().coerceAtLeast(1)
122
113
  val sh = (vh / DOWNSAMPLE_FACTOR).toInt().coerceAtLeast(1)
123
114
 
115
+ val myLoc = IntArray(2); view.getLocationInWindow(myLoc)
116
+ val rootLoc = IntArray(2); rootView.getLocationInWindow(rootLoc)
117
+ val offsetX = (myLoc[0] - rootLoc[0]).toFloat()
118
+ val offsetY = (myLoc[1] - rootLoc[1]).toFloat()
119
+
120
+ val capture = reuseBitmap(captureBitmap, vw, vh).also { captureBitmap = it }
121
+ val scaled = reuseBitmap(scaledBitmap, sw, sh).also { scaledBitmap = it }
122
+
123
+ // Set isCapturing BEFORE root.draw() so BlurVibeView.draw() is skipped
124
124
  isCapturing = true
125
+ val c = Canvas(capture)
126
+ c.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
127
+ c.translate(-offsetX, -offsetY)
125
128
  try {
126
- // ① Compute offset of view within root
127
- val myLoc = IntArray(2); view.getLocationInWindow(myLoc)
128
- val rootLoc = IntArray(2); rootView.getLocationInWindow(rootLoc)
129
- val offsetX = myLoc[0] - rootLoc[0]
130
- val offsetY = myLoc[1] - rootLoc[1]
131
-
132
- // ② Allocate bitmaps (reuse if size matches)
133
- val capture = reuseBitmap(captureBitmap, vw, vh).also { captureBitmap = it }
134
- val scaled = reuseBitmap(scaledBitmap, sw, sh).also { scaledBitmap = it }
135
-
136
- // ③ Capture just the region behind this view from root
137
- val c = Canvas(capture)
138
- c.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
139
- c.translate(-offsetX.toFloat(), -offsetY.toFloat())
140
129
  rootView.draw(c)
141
-
142
- // ④ Downsample
143
- val sc = Canvas(scaled)
144
- sc.drawBitmap(capture,
145
- Rect(0, 0, capture.width, capture.height),
146
- Rect(0, 0, scaled.width, scaled.height),
147
- capturePaint)
148
-
149
- // ⑤ Blur (2 rounds for smooth spread)
150
- repeat(BLUR_ROUNDS) { blurBitmap(scaled) }
151
-
152
- // ⑥ Trigger redraw with new bitmap
153
- view.invalidate()
154
-
155
130
  } catch (_: Exception) {
156
- } finally {
157
131
  isCapturing = false
132
+ return
158
133
  }
134
+ isCapturing = false
135
+
136
+ // Downsample
137
+ Canvas(scaled).drawBitmap(
138
+ capture,
139
+ Rect(0, 0, capture.width, capture.height),
140
+ Rect(0, 0, scaled.width, scaled.height),
141
+ capturePaint
142
+ )
143
+
144
+ // Blur (2 rounds)
145
+ repeat(BLUR_ROUNDS) { blurBitmap(scaled) }
146
+
147
+ view.invalidate()
159
148
  }
160
149
 
161
150
  private fun blurBitmap(bitmap: Bitmap) {
162
- val rs = this.rs ?: return softwareBlur(bitmap)
163
- val sc = this.blurScript ?: return softwareBlur(bitmap)
151
+ val r = rs ?: return softwareBlur(bitmap)
152
+ val sc = blurScript ?: return softwareBlur(bitmap)
164
153
  try {
165
- val inA = reuseAlloc(inputAlloc, bitmap, rs).also { inputAlloc = it }
166
- val outA = reuseAlloc(outputAlloc, bitmap, rs).also { outputAlloc = it }
167
- inA.copyFrom(bitmap)
154
+ val iA = reuseAlloc(inputAlloc, bitmap, r).also { inputAlloc = it }
155
+ val oA = reuseAlloc(outputAlloc, bitmap, r).also { outputAlloc = it }
156
+ iA.copyFrom(bitmap)
168
157
  sc.setRadius(blurRadius.coerceIn(1f, 25f))
169
- sc.setInput(inA)
170
- sc.forEach(outA)
171
- outA.copyTo(bitmap)
172
- } catch (_: Exception) {
173
- softwareBlur(bitmap)
174
- }
158
+ sc.setInput(iA)
159
+ sc.forEach(oA)
160
+ oA.copyTo(bitmap)
161
+ } catch (_: Exception) { softwareBlur(bitmap) }
175
162
  }
176
163
 
177
164
  private fun softwareBlur(bitmap: Bitmap) {
178
- // Pure software Gaussian fallback (slower but always works)
179
165
  val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
180
166
  maskFilter = android.graphics.BlurMaskFilter(blurRadius, android.graphics.BlurMaskFilter.Blur.NORMAL)
181
167
  }
182
168
  Canvas(bitmap).drawBitmap(bitmap, 0f, 0f, paint)
183
169
  }
184
170
 
185
- // ── Draw — called from BlurVibeView.onDraw() ──────────────────────────────
171
+ // ── Draw ─────────────────────────────────────────────────────────────────
186
172
 
187
173
  fun draw(canvas: Canvas, viewWidth: Float, viewHeight: Float) {
188
174
  scaledBitmap?.takeIf { !it.isRecycled }?.let { bmp ->
189
175
  canvas.drawBitmap(bmp, null,
190
176
  android.graphics.RectF(0f, 0f, viewWidth, viewHeight), drawPaint)
191
177
  }
192
- if (Color.alpha(overlayColor) > 0) {
193
- canvas.drawColor(overlayColor)
194
- }
178
+ if (Color.alpha(overlayColor) > 0) canvas.drawColor(overlayColor)
195
179
  }
196
180
 
197
- // ── Lifecycle ─────────────────────────────────────────────────────────────
181
+ // ── Multi-window ──────────────────────────────────────────────────────────
198
182
 
199
- fun onSizeChanged() {
200
- invalidatePool()
183
+ fun reAttach() {
184
+ if (enabled && autoUpdate) safeAddPreDrawListener()
201
185
  }
202
186
 
187
+ private fun safeAddPreDrawListener() {
188
+ val vto = rootView.viewTreeObserver
189
+ vto.removeOnPreDrawListener(preDrawListener)
190
+ if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
191
+ }
192
+
193
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
194
+
195
+ fun onSizeChanged() { invalidatePool() }
196
+
203
197
  private fun invalidatePool() {
204
198
  captureBitmap?.recycle(); captureBitmap = null
205
199
  scaledBitmap?.recycle(); scaledBitmap = null
@@ -207,26 +201,6 @@ internal class LegacyBlurController(
207
201
  outputAlloc?.destroy(); outputAlloc = null
208
202
  }
209
203
 
210
- // ── Multi-window / split-screen / PiP safety ──────────────────────────────
211
- //
212
- // Called by BlurVibeView.onWindowFocusChanged(hasFocus=true).
213
- // Re-attaches the preDrawListener to the rootView's current
214
- // (possibly newly created) ViewTreeObserver after a window mode transition.
215
-
216
- fun reAttach() {
217
- if (enabled && autoUpdate) {
218
- safeAddPreDrawListener()
219
- }
220
- }
221
-
222
- private fun safeAddPreDrawListener() {
223
- val vto = rootView.viewTreeObserver
224
- vto.removeOnPreDrawListener(preDrawListener) // no-op if not attached
225
- if (vto.isAlive) {
226
- vto.addOnPreDrawListener(preDrawListener)
227
- }
228
- }
229
-
230
204
  fun destroy() {
231
205
  rootView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
232
206
  Choreographer.getInstance().removeFrameCallback(frameCallback)
@@ -238,7 +212,7 @@ internal class LegacyBlurController(
238
212
  scaledBitmap?.recycle()
239
213
  }
240
214
 
241
- // ── Bitmap / Allocation helpers ───────────────────────────────────────────
215
+ // ── Helpers ───────────────────────────────────────────────────────────────
242
216
 
243
217
  private fun reuseBitmap(existing: Bitmap?, w: Int, h: Int): Bitmap {
244
218
  if (existing != null && !existing.isRecycled
@@ -248,9 +222,8 @@ internal class LegacyBlurController(
248
222
  }
249
223
 
250
224
  private fun reuseAlloc(existing: Allocation?, src: Bitmap, rs: RenderScript): Allocation {
251
- if (existing != null
252
- && existing.type.x == src.width
253
- && existing.type.y == src.height) return existing
225
+ if (existing != null && existing.type.x == src.width && existing.type.y == src.height)
226
+ return existing
254
227
  existing?.destroy()
255
228
  return Allocation.createFromBitmap(rs, src,
256
229
  Allocation.MipmapControl.MIPMAP_NONE, Allocation.USAGE_SCRIPT)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-blur-vibe",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "React Native package implementing Blur View in iOS and Android",
5
5
  "main": "./lib/commonjs/index.js",
6
6
  "module": "./lib/module/index.js",