react-native-blur-vibe 0.1.9 → 0.1.10

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.
@@ -61,6 +61,13 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
61
61
  blurController?.onSizeChanged()
62
62
  }
63
63
 
64
+ override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
65
+ 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
+ if (hasWindowFocus) blurController?.reAttach()
69
+ }
70
+
64
71
  // ── Draw ───────────────────────────────────────────────────────────────────
65
72
 
66
73
  override fun onDraw(canvas: Canvas) {
@@ -3,6 +3,8 @@ package com.blurvibe
3
3
  import android.content.Context
4
4
  import android.graphics.Bitmap
5
5
  import android.graphics.BitmapShader
6
+ import android.graphics.BlendMode
7
+ import android.graphics.BlendModeColorFilter
6
8
  import android.graphics.Canvas
7
9
  import android.graphics.Color
8
10
  import android.graphics.LinearGradient
@@ -11,6 +13,7 @@ import android.graphics.Paint
11
13
  import android.graphics.PorterDuff
12
14
  import android.graphics.PorterDuffXfermode
13
15
  import android.graphics.RadialGradient
16
+ import android.graphics.Rect
14
17
  import android.graphics.RectF
15
18
  import android.graphics.RenderEffect
16
19
  import android.graphics.RenderNode
@@ -25,81 +28,96 @@ import android.view.ViewTreeObserver
25
28
  import androidx.annotation.RequiresApi
26
29
  import androidx.core.graphics.toColorInt
27
30
  import com.facebook.react.views.view.ReactViewGroup
31
+ import kotlin.math.max
28
32
  import kotlin.math.min
29
33
  import kotlin.random.Random
30
34
 
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
+ */
31
59
  @RequiresApi(Build.VERSION_CODES.S)
32
60
  class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
33
61
 
34
62
  // ── Blur params ────────────────────────────────────────────────────────────
35
63
 
36
- private var blurRadiusX = DEFAULT_BLUR_RADIUS
37
- private var blurRadiusY = DEFAULT_BLUR_RADIUS
38
- private var overlayColor = Color.TRANSPARENT
64
+ private var blurAmount = 10f
65
+ private var overlayColor = Color.TRANSPARENT
39
66
  private var cornerRadiusPx = 0f
40
67
 
41
- // ── Progressive blur params ────────────────────────────────────────────────
68
+ // ── Progressive blur ──────────────────────────────────────────────────────
42
69
 
43
70
  private var progressiveDirection = PROGRESSIVE_NONE
44
71
  private var progressiveStartIntensity = 1f
45
72
  private var progressiveEndIntensity = 0f
46
73
 
47
- // ── Noise params ──────────────────────────────────────────────────────────
74
+ // ── Noise ─────────────────────────────────────────────────────────────────
48
75
 
49
76
  private var noiseFactor = 0.08f
50
77
  private var noiseBitmap: Bitmap? = null
51
78
  private val noisePaint = Paint()
52
79
 
53
- // ── RenderNodes ───────────────────────────────────────────────────────────
80
+ // ── Bitmap + RenderNode (ModernBlurView pattern) ───────────────────────────
54
81
  //
55
- // contentNode: records root-view draw commands ("what's behind me")
56
- // blurNode: crops + translates contentNode to this view's position,
57
- // with RenderEffect blur applied
82
+ // internalBitmap: captured root pixels at view resolution
83
+ // renderNode: holds bitmap + RenderEffect (GPU blur + tint chain)
58
84
  //
59
- // IMPORTANTNO setUseCompositingLayer(true) on either node.
60
- // Compositing layer on a re-recorded RenderNode causes GPU memory
61
- // thrashing and SIGSEGV on some API 31 drivers.
62
- //
63
- // IMPORTANT — NO LAYER_TYPE_HARDWARE on the view itself.
64
- // canvas.drawRenderNode() is only valid on a hardware-accelerated canvas
65
- // that is NOT itself a hardware layer — mixing them causes SIGSEGV
66
- // in RenderThread (the exact crash we saw in logcat).
67
-
68
- private val contentNode = RenderNode("BlurVibeContent")
69
- private val blurNode = RenderNode("BlurVibeBlur")
70
-
71
- // ── Recording guard — prevents double-beginRecording crashes ─────────────
72
- //
73
- // If captureRootIntoNode fires twice in the same frame (e.g. during
74
- // layout + invalidate), a second beginRecording() on an active recording
75
- // crashes the RenderThread. This flag gates it.
85
+ // The renderNode is reused every frame only its content (bitmap) and
86
+ // effect (radius/tint) are updated, not recreated.
76
87
 
77
- private var isCapturing = false
88
+ private var internalBitmap: Bitmap? = null
89
+ private val renderNode = RenderNode("BlurVibeNode")
78
90
 
79
- // ── Paint objects ──────────────────────────────────────────────────────────
91
+ // ── Draw paint ────────────────────────────────────────────────────────────
80
92
 
81
- private val overlayPaint = Paint(Paint.ANTI_ALIAS_FLAG)
93
+ private val bitmapPaint = Paint(Paint.FILTER_BITMAP_FLAG)
82
94
  private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
83
95
  xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
84
96
  }
97
+ private val overlayPaint = Paint(Paint.ANTI_ALIAS_FLAG)
85
98
 
86
99
  // ── Root view ─────────────────────────────────────────────────────────────
87
100
 
88
101
  private var blurRoot: ViewGroup? = null
102
+ private val rootLocation = IntArray(2)
103
+ private val blurViewLocation = IntArray(2)
89
104
 
90
- // ── Choreographer gate ────────────────────────────────────────────────────
105
+ // ── State ─────────────────────────────────────────────────────────────────
91
106
 
107
+ private var blurEnabled = true
108
+ private var autoUpdate = true
92
109
  private var frameScheduled = false
110
+ private var initialized = false
111
+
112
+ // ── Choreographer gate ────────────────────────────────────────────────────
113
+
93
114
  private val frameCallback = Choreographer.FrameCallback {
94
115
  frameScheduled = false
95
- if (isAttachedToWindow) {
96
- captureRootIntoNode()
97
- invalidate()
98
- }
116
+ if (isAttachedToWindow && blurEnabled) updateBlur()
99
117
  }
100
118
 
101
119
  private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
102
- if (!frameScheduled) {
120
+ if (!frameScheduled && blurEnabled && autoUpdate) {
103
121
  frameScheduled = true
104
122
  Choreographer.getInstance().postFrameCallback(frameCallback)
105
123
  }
@@ -112,10 +130,6 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
112
130
  setWillNotDraw(false)
113
131
  super.setBackgroundColor(Color.TRANSPARENT)
114
132
  clipToOutline = true
115
- // DO NOT call setLayerType(LAYER_TYPE_HARDWARE) here —
116
- // it conflicts with canvas.drawRenderNode() and causes SIGSEGV in RenderThread.
117
- // The view uses the default layer type (LAYER_TYPE_NONE) so its canvas is
118
- // the hardware-accelerated display list canvas — which supports drawRenderNode.
119
133
  }
120
134
 
121
135
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -123,107 +137,163 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
123
137
  override fun onAttachedToWindow() {
124
138
  super.onAttachedToWindow()
125
139
  blurRoot = findBlurRoot()
126
- blurRoot?.viewTreeObserver?.addOnPreDrawListener(preDrawListener)
140
+ safeAddPreDrawListener()
127
141
  generateNoiseBitmap()
142
+ if (measuredWidth > 0 && measuredHeight > 0) initBlur()
128
143
  }
129
144
 
130
145
  override fun onDetachedFromWindow() {
131
146
  blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
132
147
  Choreographer.getInstance().removeFrameCallback(frameCallback)
133
148
  frameScheduled = false
134
- isCapturing = false
149
+ initialized = false
135
150
  blurRoot = null
136
- noiseBitmap?.recycle()
137
- noiseBitmap = null
138
- // Discard RenderNode display lists to free GPU memory
139
- contentNode.discardDisplayList()
140
- blurNode.discardDisplayList()
151
+ noiseBitmap?.recycle(); noiseBitmap = null
152
+ internalBitmap?.recycle(); internalBitmap = null
153
+ renderNode.discardDisplayList()
141
154
  super.onDetachedFromWindow()
142
155
  }
143
156
 
144
157
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
145
158
  super.onSizeChanged(w, h, oldw, oldh)
146
- // Update blurNode bounds — contentNode bounds are set in captureRootIntoNode
147
159
  if (w > 0 && h > 0) {
148
- blurNode.setPosition(0, 0, w, h)
149
- applyBlurRenderEffect()
160
+ internalBitmap?.recycle(); internalBitmap = null
161
+ initBlur()
150
162
  }
151
163
  }
152
164
 
153
- // ── Capture pipeline ───────────────────────────────────────────────────────
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)
174
+
175
+ override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
176
+ super.onWindowFocusChanged(hasWindowFocus)
177
+ if (hasWindowFocus && blurEnabled && autoUpdate) {
178
+ // Re-attach listener to the current (possibly new) ViewTreeObserver
179
+ safeAddPreDrawListener()
180
+ scheduleFrame()
181
+ }
182
+ }
154
183
 
155
- private fun captureRootIntoNode() {
156
- if (isCapturing) return // guard against re-entrant / double recording crash
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
+ private fun safeAddPreDrawListener() {
157
190
  val root = blurRoot ?: return
158
- val rw = root.width; if (rw <= 0) return
159
- val rh = root.height; if (rh <= 0) return
160
- val vw = width; if (vw <= 0) return
161
- val vh = height; if (vh <= 0) return
191
+ val vto = root.viewTreeObserver
192
+ // Remove first (no-op if not attached) then re-add to current observer
193
+ vto.removeOnPreDrawListener(preDrawListener)
194
+ if (vto.isAlive) {
195
+ vto.addOnPreDrawListener(preDrawListener)
196
+ }
197
+ }
162
198
 
163
- isCapturing = true
164
- try {
165
- // Step 1: record root-view draw into contentNode
166
- contentNode.setPosition(0, 0, rw, rh)
167
- val contentCanvas = contentNode.beginRecording()
168
- try {
169
- contentCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
170
- root.draw(contentCanvas)
171
- } finally {
172
- contentNode.endRecording() // always end — even if draw() throws
173
- }
199
+ // ── Blur init ─────────────────────────────────────────────────────────────
174
200
 
175
- // Step 2: contentNode recording is FINISHED before we reference it
176
- // in blurNode. This is critical drawing an actively-recording
177
- // RenderNode into another canvas is undefined behaviour.
178
- val myLoc = IntArray(2); getLocationInWindow(myLoc)
179
- val rootLoc = IntArray(2); root.getLocationInWindow(rootLoc)
180
- val offsetX = (myLoc[0] - rootLoc[0]).toFloat()
181
- val offsetY = (myLoc[1] - rootLoc[1]).toFloat()
182
-
183
- blurNode.setPosition(0, 0, vw, vh)
184
- applyBlurRenderEffect()
185
-
186
- val blurCanvas = blurNode.beginRecording()
187
- try {
188
- blurCanvas.translate(-offsetX, -offsetY)
189
- blurCanvas.drawRenderNode(contentNode) // safe: contentNode recording is done
190
- } finally {
191
- blurNode.endRecording()
192
- }
201
+ private fun initBlur() {
202
+ val w = measuredWidth; if (w <= 0) return
203
+ val h = measuredHeight; if (h <= 0) return
193
204
 
194
- } finally {
195
- isCapturing = false
196
- }
205
+ internalBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
206
+ renderNode.setPosition(0, 0, w, h)
207
+ initialized = true
208
+ setWillNotDraw(false)
209
+ updateBlur()
197
210
  }
198
211
 
199
- private fun applyBlurRenderEffect() {
200
- if (blurRadiusX < 0.5f && blurRadiusY < 0.5f) {
201
- blurNode.setRenderEffect(null)
202
- return
212
+ // ── Core blur update (ModernBlurView pattern) ─────────────────────────────
213
+
214
+ private fun updateBlur() {
215
+ 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])
234
+
235
+ val captureCanvas = Canvas(bitmap)
236
+ captureCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
237
+ captureCanvas.translate(-left / scaleW, -top / scaleH)
238
+ captureCanvas.scale(1f / scaleW, 1f / scaleH)
239
+ try {
240
+ 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)
203
247
  }
204
- blurNode.setRenderEffect(
205
- RenderEffect.createBlurEffect(blurRadiusX, blurRadiusY, Shader.TileMode.CLAMP)
206
- )
248
+ val nodeCanvas = renderNode.beginRecording()
249
+ nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
250
+ renderNode.endRecording()
251
+
252
+ // ③ Build chained RenderEffect: blur first, then tint on top (one GPU pass)
253
+ val radius = blurRadiusFromAmount(blurAmount)
254
+ val blurEffect = RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.MIRROR)
255
+
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
+ invalidate()
207
270
  }
208
271
 
209
272
  // ── Draw ───────────────────────────────────────────────────────────────────
210
273
 
211
274
  override fun onDraw(canvas: Canvas) {
275
+ if (!blurEnabled || !initialized) return
212
276
  val w = width.toFloat(); if (w <= 0f) return
213
277
  val h = height.toFloat(); if (h <= 0f) return
214
-
215
- // Guard: only draw if blurNode has a valid recorded display list
216
- if (!blurNode.hasDisplayList()) return
278
+ if (!renderNode.hasDisplayList()) return
217
279
 
218
280
  // Step 1: save layer for progressive mask compositing
219
281
  val saveCount = if (progressiveDirection != PROGRESSIVE_NONE) {
220
282
  canvas.saveLayer(0f, 0f, w, h, null)
221
283
  } else -1
222
284
 
223
- // Step 2: draw the blurred backdrop
224
- canvas.drawRenderNode(blurNode)
225
-
226
- // Step 3: progressive alpha mask
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)
293
+ canvas.drawRenderNode(renderNode)
294
+ canvas.restore()
295
+
296
+ // Step 3: progressive alpha mask fades the blur
227
297
  if (progressiveDirection != PROGRESSIVE_NONE && saveCount >= 0) {
228
298
  buildProgressiveShader(w, h)?.let { shader ->
229
299
  maskPaint.shader = shader
@@ -232,38 +302,26 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
232
302
  canvas.restoreToCount(saveCount)
233
303
  }
234
304
 
235
- // Step 4: overlay tint
236
- if (Color.alpha(overlayColor) > 0) {
237
- overlayPaint.color = overlayColor
238
- if (cornerRadiusPx > 0f) {
239
- canvas.drawRoundRect(RectF(0f, 0f, w, h), cornerRadiusPx, cornerRadiusPx, overlayPaint)
240
- } else {
241
- canvas.drawRect(0f, 0f, w, h, overlayPaint)
242
- }
243
- }
244
-
245
- // Step 5: noise grain
246
- noiseBitmap?.takeIf { !it.isRecycled }?.let { bmp ->
247
- if (noiseFactor > 0f) {
248
- noisePaint.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
249
- noisePaint.shader = BitmapShader(bmp, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
250
- canvas.drawRect(0f, 0f, w, h, noisePaint)
251
- }
305
+ // Step 4: noise grain overlay
306
+ 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)
252
310
  }
253
311
  }
254
312
 
255
313
  // ── Progressive shader ────────────────────────────────────────────────────
256
314
 
257
315
  private fun buildProgressiveShader(w: Float, h: Float): Shader? {
258
- val startColor = Color.argb((progressiveStartIntensity.coerceIn(0f,1f) * 255).toInt(), 0,0,0)
259
- val endColor = Color.argb((progressiveEndIntensity.coerceIn(0f,1f) * 255).toInt(), 0,0,0)
316
+ val sc = Color.argb((progressiveStartIntensity.coerceIn(0f,1f)*255).toInt(),0,0,0)
317
+ val ec = Color.argb((progressiveEndIntensity.coerceIn(0f,1f)*255).toInt(),0,0,0)
260
318
  return when (progressiveDirection) {
261
- PROGRESSIVE_TOP_TO_BOTTOM -> LinearGradient(0f,0f,0f,h, startColor,endColor, Shader.TileMode.CLAMP)
262
- PROGRESSIVE_BOTTOM_TO_TOP -> LinearGradient(0f,h,0f,0f, startColor,endColor, Shader.TileMode.CLAMP)
263
- PROGRESSIVE_LEFT_TO_RIGHT -> LinearGradient(0f,0f,w,0f, startColor,endColor, Shader.TileMode.CLAMP)
264
- PROGRESSIVE_RIGHT_TO_LEFT -> LinearGradient(w,0f,0f,0f, startColor,endColor, Shader.TileMode.CLAMP)
265
- PROGRESSIVE_RADIAL -> RadialGradient(w/2f,h/2f, min(w,h)/2f, startColor,endColor, Shader.TileMode.CLAMP)
266
- else -> null
319
+ PROGRESSIVE_TOP_TO_BOTTOM -> LinearGradient(0f,0f,0f,h,sc,ec,Shader.TileMode.CLAMP)
320
+ PROGRESSIVE_BOTTOM_TO_TOP -> LinearGradient(0f,h,0f,0f,sc,ec,Shader.TileMode.CLAMP)
321
+ PROGRESSIVE_LEFT_TO_RIGHT -> LinearGradient(0f,0f,w,0f,sc,ec,Shader.TileMode.CLAMP)
322
+ PROGRESSIVE_RIGHT_TO_LEFT -> LinearGradient(w,0f,0f,0f,sc,ec,Shader.TileMode.CLAMP)
323
+ PROGRESSIVE_RADIAL -> RadialGradient(w/2f,h/2f,min(w,h)/2f,sc,ec,Shader.TileMode.CLAMP)
324
+ else -> null
267
325
  }
268
326
  }
269
327
 
@@ -274,11 +332,9 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
274
332
  val size = 64
275
333
  val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
276
334
  val rng = Random(42)
277
- for (x in 0 until size) {
278
- for (y in 0 until size) {
279
- val v = rng.nextInt(256)
280
- bmp.setPixel(x, y, Color.argb(255, v, v, v))
281
- }
335
+ for (x in 0 until size) for (y in 0 until size) {
336
+ val v = rng.nextInt(256)
337
+ bmp.setPixel(x, y, Color.argb(255, v, v, v))
282
338
  }
283
339
  noiseBitmap = bmp
284
340
  }
@@ -286,16 +342,13 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
286
342
  // ── Public setters ─────────────────────────────────────────────────────────
287
343
 
288
344
  fun setBlurAmount(amount: Float) {
289
- val t = amount.coerceIn(0f, 100f) / 100f
290
- blurRadiusX = t * t * MAX_BLUR_RADIUS
291
- blurRadiusY = blurRadiusX
292
- applyBlurRenderEffect()
345
+ blurAmount = amount.coerceIn(0f, 100f)
293
346
  scheduleFrame()
294
347
  }
295
348
 
296
349
  fun setOverlayColor(colorString: String?) {
297
350
  overlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
298
- invalidate()
351
+ scheduleFrame()
299
352
  }
300
353
 
301
354
  fun applyBorderRadius(radiusDp: Float) {
@@ -311,50 +364,41 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
311
364
  invalidate()
312
365
  }
313
366
 
314
- fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") color: String?) { }
367
+ fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") c: String?) {}
315
368
 
316
- fun setProgressiveBlurDirection(direction: String?) {
317
- progressiveDirection = when (direction) {
369
+ fun setProgressiveBlurDirection(d: String?) {
370
+ progressiveDirection = when (d) {
318
371
  "topToBottom" -> PROGRESSIVE_TOP_TO_BOTTOM
319
372
  "bottomToTop" -> PROGRESSIVE_BOTTOM_TO_TOP
320
373
  "leftToRight" -> PROGRESSIVE_LEFT_TO_RIGHT
321
374
  "rightToLeft" -> PROGRESSIVE_RIGHT_TO_LEFT
322
375
  "radial" -> PROGRESSIVE_RADIAL
323
376
  else -> PROGRESSIVE_NONE
324
- }
325
- invalidate()
326
- }
327
-
328
- fun setProgressiveStartIntensity(intensity: Float) {
329
- progressiveStartIntensity = intensity.coerceIn(0f, 1f); invalidate()
377
+ }; invalidate()
330
378
  }
331
379
 
332
- fun setProgressiveEndIntensity(intensity: Float) {
333
- progressiveEndIntensity = intensity.coerceIn(0f, 1f); invalidate()
334
- }
335
-
336
- fun setNoiseFactor(factor: Float) {
337
- noiseFactor = factor.coerceIn(0f, 1f); invalidate()
338
- }
380
+ fun setProgressiveStartIntensity(v: Float) { progressiveStartIntensity = v.coerceIn(0f,1f); invalidate() }
381
+ fun setProgressiveEndIntensity(v: Float) { progressiveEndIntensity = v.coerceIn(0f,1f); invalidate() }
382
+ fun setNoiseFactor(v: Float) { noiseFactor = v.coerceIn(0f,1f); invalidate() }
339
383
 
340
384
  fun applyBlurEnabled(enabled: Boolean) {
341
- if (!enabled) {
385
+ blurEnabled = enabled
386
+ if (enabled) {
387
+ safeAddPreDrawListener()
388
+ scheduleFrame()
389
+ } else {
342
390
  blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
343
391
  Choreographer.getInstance().removeFrameCallback(frameCallback)
344
392
  frameScheduled = false
345
- blurNode.discardDisplayList()
346
- contentNode.discardDisplayList()
393
+ renderNode.discardDisplayList()
347
394
  invalidate()
348
- } else {
349
- blurRoot?.viewTreeObserver?.addOnPreDrawListener(preDrawListener)
350
- scheduleFrame()
351
395
  }
352
396
  }
353
397
 
354
- fun setAutoUpdate(autoUpdate: Boolean) {
355
- if (autoUpdate) {
356
- blurRoot?.viewTreeObserver?.addOnPreDrawListener(preDrawListener)
357
- } else {
398
+ fun setAutoUpdate(update: Boolean) {
399
+ autoUpdate = update
400
+ if (update) safeAddPreDrawListener()
401
+ else {
358
402
  blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
359
403
  Choreographer.getInstance().removeFrameCallback(frameCallback)
360
404
  frameScheduled = false
@@ -364,12 +408,17 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
364
408
  // ── Helpers ────────────────────────────────────────────────────────────────
365
409
 
366
410
  private fun scheduleFrame() {
367
- if (!frameScheduled) {
411
+ if (!frameScheduled && blurEnabled) {
368
412
  frameScheduled = true
369
413
  Choreographer.getInstance().postFrameCallback(frameCallback)
370
414
  }
371
415
  }
372
416
 
417
+ private fun blurRadiusFromAmount(amount: Float): Float {
418
+ val t = amount / 100f
419
+ return (t * t * 25f).coerceIn(1f, 25f)
420
+ }
421
+
373
422
  private fun findBlurRoot(): ViewGroup? {
374
423
  var p = parent
375
424
  while (p != null) {
@@ -391,12 +440,12 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
391
440
  val hex = t.removePrefix("#")
392
441
  return try {
393
442
  when (hex.length) {
394
- 3 -> Color.argb(255, hex[0].toString().repeat(2).toInt(16),
395
- hex[1].toString().repeat(2).toInt(16),
396
- hex[2].toString().repeat(2).toInt(16))
397
- 6 -> Color.argb(255, hex.substring(0,2).toInt(16),
398
- hex.substring(2,4).toInt(16),
399
- hex.substring(4,6).toInt(16))
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))
400
449
  8 -> Color.argb(hex.substring(6,8).toInt(16),
401
450
  hex.substring(0,2).toInt(16),
402
451
  hex.substring(2,4).toInt(16),
@@ -406,16 +455,14 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
406
455
  } catch (_: NumberFormatException) { null }
407
456
  }
408
457
 
409
- override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { }
458
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
410
459
 
411
460
  companion object {
412
- private const val MAX_BLUR_RADIUS = 25f
413
- private const val DEFAULT_BLUR_RADIUS = 2.5f
414
- const val PROGRESSIVE_NONE = 0
415
- const val PROGRESSIVE_TOP_TO_BOTTOM = 1
416
- const val PROGRESSIVE_BOTTOM_TO_TOP = 2
417
- const val PROGRESSIVE_LEFT_TO_RIGHT = 3
418
- const val PROGRESSIVE_RIGHT_TO_LEFT = 4
419
- const val PROGRESSIVE_RADIAL = 5
461
+ const val PROGRESSIVE_NONE = 0
462
+ const val PROGRESSIVE_TOP_TO_BOTTOM = 1
463
+ const val PROGRESSIVE_BOTTOM_TO_TOP = 2
464
+ const val PROGRESSIVE_LEFT_TO_RIGHT = 3
465
+ const val PROGRESSIVE_RIGHT_TO_LEFT = 4
466
+ const val PROGRESSIVE_RADIAL = 5
420
467
  }
421
468
  }
@@ -73,7 +73,7 @@ internal class LegacyBlurController(
73
73
  var autoUpdate: Boolean = true
74
74
  set(value) {
75
75
  field = value
76
- if (value) rootView.viewTreeObserver.addOnPreDrawListener(preDrawListener)
76
+ if (value) safeAddPreDrawListener()
77
77
  else rootView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
78
78
  }
79
79
 
@@ -99,7 +99,7 @@ internal class LegacyBlurController(
99
99
 
100
100
  init {
101
101
  initRenderScript()
102
- rootView.viewTreeObserver.addOnPreDrawListener(preDrawListener)
102
+ safeAddPreDrawListener()
103
103
  }
104
104
 
105
105
  private fun initRenderScript() {
@@ -207,6 +207,26 @@ internal class LegacyBlurController(
207
207
  outputAlloc?.destroy(); outputAlloc = null
208
208
  }
209
209
 
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
+
210
230
  fun destroy() {
211
231
  rootView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
212
232
  Choreographer.getInstance().removeFrameCallback(frameCallback)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-blur-vibe",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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",