react-native-blur-vibe 0.1.10 → 0.1.12

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
- super.setBackgroundColor(Color.TRANSPARENT)
39
- clipToOutline = true
36
+ // DO NOT call setBackgroundColor — see BlurVibeViewApi31 for explanation.
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,15 @@ 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 {
140
+ // Linear 0→1, 100→25 (RenderScript max kernel is 25)
141
+ // With 3 blur rounds this gives equivalent spread to Api31's 120px single-pass
128
142
  val t = amount.coerceIn(0f, 100f) / 100f
129
- return t * t * 25f // quadratic curve, max 25 (RenderScript kernel limit)
143
+ return (1f + t * 24f) // 1–25 linear, rounds=3 gives wide spread
130
144
  }
131
145
 
132
146
  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
 
@@ -128,8 +117,12 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
128
117
 
129
118
  init {
130
119
  setWillNotDraw(false)
131
- super.setBackgroundColor(Color.TRANSPARENT)
132
- clipToOutline = true
120
+ // DO NOT call setBackgroundColor here.
121
+ // ReactViewGroup manages its own ReactViewBackgroundDrawable which handles
122
+ // all RN style props: borderRadius, borderColor, borderWidth, opacity,
123
+ // backgroundColor, shadow, elevation etc.
124
+ // Calling super.setBackgroundColor() replaces that drawable with a plain
125
+ // ColorDrawable — destroying all style prop handling.
133
126
  }
134
127
 
135
128
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -147,8 +140,9 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
147
140
  Choreographer.getInstance().removeFrameCallback(frameCallback)
148
141
  frameScheduled = false
149
142
  initialized = false
143
+ isCapturing = false
150
144
  blurRoot = null
151
- noiseBitmap?.recycle(); noiseBitmap = null
145
+ noiseBitmap?.recycle(); noiseBitmap = null
152
146
  internalBitmap?.recycle(); internalBitmap = null
153
147
  renderNode.discardDisplayList()
154
148
  super.onDetachedFromWindow()
@@ -158,118 +152,114 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
158
152
  super.onSizeChanged(w, h, oldw, oldh)
159
153
  if (w > 0 && h > 0) {
160
154
  internalBitmap?.recycle(); internalBitmap = null
155
+ renderNode.discardDisplayList()
156
+ initialized = false
161
157
  initBlur()
162
158
  }
163
159
  }
164
160
 
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)
161
+ // ── Multi-window safety ───────────────────────────────────────────────────
174
162
 
175
163
  override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
176
164
  super.onWindowFocusChanged(hasWindowFocus)
177
165
  if (hasWindowFocus && blurEnabled && autoUpdate) {
178
- // Re-attach listener to the current (possibly new) ViewTreeObserver
179
166
  safeAddPreDrawListener()
180
167
  scheduleFrame()
181
168
  }
182
169
  }
183
170
 
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
171
  private fun safeAddPreDrawListener() {
190
172
  val root = blurRoot ?: return
191
173
  val vto = root.viewTreeObserver
192
- // Remove first (no-op if not attached) then re-add to current observer
193
174
  vto.removeOnPreDrawListener(preDrawListener)
194
- if (vto.isAlive) {
195
- vto.addOnPreDrawListener(preDrawListener)
196
- }
175
+ if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
197
176
  }
198
177
 
199
- // ── Blur init ─────────────────────────────────────────────────────────────
178
+ // ── Init blur ─────────────────────────────────────────────────────────────
200
179
 
201
180
  private fun initBlur() {
202
181
  val w = measuredWidth; if (w <= 0) return
203
182
  val h = measuredHeight; if (h <= 0) return
204
-
205
183
  internalBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
206
184
  renderNode.setPosition(0, 0, w, h)
207
185
  initialized = true
208
- setWillNotDraw(false)
209
186
  updateBlur()
210
187
  }
211
188
 
212
- // ── Core blur update (ModernBlurView pattern) ─────────────────────────────
189
+ // ── Core: capture + blur + render ─────────────────────────────────────────
213
190
 
214
191
  private fun updateBlur() {
215
192
  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])
193
+ val root = blurRoot ?: return
194
+ val bitmap = internalBitmap ?: return
195
+ if (bitmap.isRecycled) return
234
196
 
197
+ // ① Compute this view's offset within the root (window coords — correct
198
+ // for all window modes: split-screen, freeform, PiP, DeX)
199
+ root.getLocationInWindow(rootLocation)
200
+ getLocationInWindow(myLocation)
201
+ val offsetX = (myLocation[0] - rootLocation[0]).toFloat()
202
+ val offsetY = (myLocation[1] - rootLocation[1]).toFloat()
203
+
204
+ // ② Capture root content EXCLUDING this view.
205
+ // isCapturing = true causes our draw() to be a no-op, so root.draw()
206
+ // skips us and captures only the content behind us.
207
+ isCapturing = true
235
208
  val captureCanvas = Canvas(bitmap)
236
209
  captureCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
237
- captureCanvas.translate(-left / scaleW, -top / scaleH)
238
- captureCanvas.scale(1f / scaleW, 1f / scaleH)
210
+ captureCanvas.translate(-offsetX, -offsetY)
239
211
  try {
240
212
  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)
213
+ } catch (_: Exception) {
214
+ isCapturing = false
215
+ return
247
216
  }
217
+ isCapturing = false
218
+
219
+ // ③ Record bitmap into RenderNode.
220
+ // Drawing a BITMAP into RenderNode is stable on all OEM drivers.
221
+ // Drawing a RenderNode into another RenderNode's recording is NOT.
222
+ renderNode.setPosition(0, 0, bitmap.width, bitmap.height)
248
223
  val nodeCanvas = renderNode.beginRecording()
249
224
  nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
250
225
  renderNode.endRecording()
251
226
 
252
- // Build chained RenderEffect: blur first, then tint on top (one GPU pass)
227
+ // Apply GPU blur + tint as chained RenderEffects
228
+ // Double-pass blur: two Gaussian passes = wider spread kernel
229
+ // Equivalent to sqrt(2) wider sigma — gives frosted-glass light diffusion
230
+ // CLAMP tile mode: no edge reflection artifacts
253
231
  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)
232
+ val pass1 = RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)
233
+ val pass2 = RenderEffect.createBlurEffect(radius * 0.5f, radius * 0.5f, Shader.TileMode.CLAMP)
234
+ val doubleBlur = RenderEffect.createChainEffect(pass2, pass1) // pass1 first, then pass2
235
+
236
+ renderNode.setRenderEffect(
237
+ if (Color.alpha(overlayColor) > 0) {
238
+ RenderEffect.createChainEffect(
239
+ RenderEffect.createColorFilterEffect(
240
+ BlendModeColorFilter(overlayColor, BlendMode.SRC_ATOP)
241
+ ),
242
+ doubleBlur
243
+ )
244
+ } else doubleBlur
245
+ )
267
246
 
268
- // ④ Trigger redraw — onDraw will drawRenderNode (GPU-rendered result)
269
247
  invalidate()
270
248
  }
271
249
 
272
- // ── Draw ───────────────────────────────────────────────────────────────────
250
+ // ── draw() override — no-op during capture ────────────────────────────────
251
+ //
252
+ // When isCapturing = true (root.draw() is in progress capturing background),
253
+ // suppress our own draw so we don't paint stale blur into the capture bitmap.
254
+ // This makes us invisible to root.draw() during capture only —
255
+ // NOT to the actual screen renderer.
256
+
257
+ override fun draw(canvas: Canvas) {
258
+ if (isCapturing) return // skip self during root capture
259
+ super.draw(canvas)
260
+ }
261
+
262
+ // ── onDraw ────────────────────────────────────────────────────────────────
273
263
 
274
264
  override fun onDraw(canvas: Canvas) {
275
265
  if (!blurEnabled || !initialized) return
@@ -277,23 +267,15 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
277
267
  val h = height.toFloat(); if (h <= 0f) return
278
268
  if (!renderNode.hasDisplayList()) return
279
269
 
280
- // Step 1: save layer for progressive mask compositing
270
+ // Progressive mask requires a saved layer so DST_IN mask composites correctly
281
271
  val saveCount = if (progressiveDirection != PROGRESSIVE_NONE) {
282
272
  canvas.saveLayer(0f, 0f, w, h, null)
283
273
  } else -1
284
274
 
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)
275
+ // Draw GPU-blurred + tinted result from RenderNode
293
276
  canvas.drawRenderNode(renderNode)
294
- canvas.restore()
295
277
 
296
- // Step 3: progressive alpha mask fades the blur
278
+ // Progressive alpha mask fades blur across the view
297
279
  if (progressiveDirection != PROGRESSIVE_NONE && saveCount >= 0) {
298
280
  buildProgressiveShader(w, h)?.let { shader ->
299
281
  maskPaint.shader = shader
@@ -302,12 +284,16 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
302
284
  canvas.restoreToCount(saveCount)
303
285
  }
304
286
 
305
- // Step 4: noise grain overlay
287
+ // Noise grain overlay
306
288
  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)
289
+ noisePaintFinal.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
290
+ noisePaintFinal.shader = BitmapShader(noise, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
291
+ canvas.drawRect(0f, 0f, w, h, noisePaintFinal)
310
292
  }
293
+
294
+ // Let ReactViewGroup draw borders/background on top (handles borderRadius
295
+ // and all other RN style props natively — no conflict with our blur)
296
+ super.onDraw(canvas)
311
297
  }
312
298
 
313
299
  // ── Progressive shader ────────────────────────────────────────────────────
@@ -325,7 +311,7 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
325
311
  }
326
312
  }
327
313
 
328
- // ── Noise bitmap ──────────────────────────────────────────────────────────
314
+ // ── Noise ─────────────────────────────────────────────────────────────────
329
315
 
330
316
  private fun generateNoiseBitmap() {
331
317
  if (noiseBitmap?.isRecycled == false) return
@@ -342,8 +328,7 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
342
328
  // ── Public setters ─────────────────────────────────────────────────────────
343
329
 
344
330
  fun setBlurAmount(amount: Float) {
345
- blurAmount = amount.coerceIn(0f, 100f)
346
- scheduleFrame()
331
+ blurAmount = amount.coerceIn(0f, 100f); scheduleFrame()
347
332
  }
348
333
 
349
334
  fun setOverlayColor(colorString: String?) {
@@ -351,16 +336,24 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
351
336
  scheduleFrame()
352
337
  }
353
338
 
339
+ // borderRadius from JS style prop — handled natively by ReactViewGroup.
340
+ // applyBorderRadius is called by our @ReactProp "borderRadius" binding.
341
+ // We additionally set clipToOutline so the blur content is clipped correctly.
354
342
  fun applyBorderRadius(radiusDp: Float) {
355
343
  cornerRadiusPx = TypedValue.applyDimension(
356
344
  TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
357
345
  )
358
- outlineProvider = object : ViewOutlineProvider() {
359
- override fun getOutline(view: View, outline: Outline) {
360
- outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx)
346
+ if (cornerRadiusPx > 0f) {
347
+ outlineProvider = object : ViewOutlineProvider() {
348
+ override fun getOutline(view: View, outline: Outline) {
349
+ outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx)
350
+ }
361
351
  }
352
+ clipToOutline = true
353
+ } else {
354
+ outlineProvider = ViewOutlineProvider.BACKGROUND
355
+ clipToOutline = false
362
356
  }
363
- clipToOutline = cornerRadiusPx > 0f
364
357
  invalidate()
365
358
  }
366
359
 
@@ -383,10 +376,8 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
383
376
 
384
377
  fun applyBlurEnabled(enabled: Boolean) {
385
378
  blurEnabled = enabled
386
- if (enabled) {
387
- safeAddPreDrawListener()
388
- scheduleFrame()
389
- } else {
379
+ if (enabled) { safeAddPreDrawListener(); scheduleFrame() }
380
+ else {
390
381
  blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
391
382
  Choreographer.getInstance().removeFrameCallback(frameCallback)
392
383
  frameScheduled = false
@@ -415,8 +406,14 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
415
406
  }
416
407
 
417
408
  private fun blurRadiusFromAmount(amount: Float): Float {
418
- val t = amount / 100f
419
- return (t * t * 25f).coerceIn(1f, 25f)
409
+ // Linear mapping: 0→1px, 10→13px, 25→31px, 50→61px, 75→91px, 100→120px
410
+ // These values match CSS backdrop-filter feel:
411
+ // blurAmount=10 ≈ backdrop-blur-sm (4px CSS = ~13px GPU after downsample)
412
+ // blurAmount=25 ≈ backdrop-blur-md (12px CSS ≈ 31px GPU)
413
+ // blurAmount=50 ≈ backdrop-blur-xl (24px CSS ≈ 61px GPU)
414
+ // blurAmount=100 ≈ backdrop-blur-3xl (64px CSS = fully frosted glass)
415
+ val t = amount.coerceIn(0f, 100f) / 100f
416
+ return (1f + t * 119f) // 1–120 linear
420
417
  }
421
418
 
422
419
  private fun findBlurRoot(): ViewGroup? {
@@ -440,12 +437,12 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
440
437
  val hex = t.removePrefix("#")
441
438
  return try {
442
439
  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))
440
+ 3 -> Color.argb(255, hex[0].toString().repeat(2).toInt(16),
441
+ hex[1].toString().repeat(2).toInt(16),
442
+ hex[2].toString().repeat(2).toInt(16))
443
+ 6 -> Color.argb(255, hex.substring(0,2).toInt(16),
444
+ hex.substring(2,4).toInt(16),
445
+ hex.substring(4,6).toInt(16))
449
446
  8 -> Color.argb(hex.substring(6,8).toInt(16),
450
447
  hex.substring(0,2).toInt(16),
451
448
  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,38 @@ 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
+ // DOWNSAMPLE_FACTOR = 2: capture at 1/4 pixels (less than before).
37
+ // Less downsampling = higher quality capture = crisper blur result.
38
+ // The blur hides pixel detail so 1/4 is the sweet spot.
39
+ private const val DOWNSAMPLE_FACTOR = 2f
40
+
41
+ // Default radius when blurAmount maps here.
42
+ // The actual radius per frame comes from view.blurRadius set by setBlurAmount().
43
+ private const val BLUR_RADIUS = 25f // max RenderScript kernel
44
+
45
+ // 3 rounds: more passes = wider spread = true frosted glass feel
46
+ private const val BLUR_ROUNDS = 3
51
47
  }
52
48
 
53
49
  // ── Bitmap pool ────────────────────────────────────────────────────────────
54
50
 
55
- private var captureBitmap: Bitmap? = null // full-res root capture
56
- private var scaledBitmap: Bitmap? = null // downsampled before blur
51
+ private var captureBitmap: Bitmap? = null
52
+ private var scaledBitmap: Bitmap? = null
57
53
  private val capturePaint = Paint(Paint.FILTER_BITMAP_FLAG)
58
54
  private val drawPaint = Paint(Paint.FILTER_BITMAP_FLAG)
59
55
 
@@ -66,7 +62,7 @@ internal class LegacyBlurController(
66
62
 
67
63
  // ── State ──────────────────────────────────────────────────────────────────
68
64
 
69
- var overlayColor: Int = Color.TRANSPARENT
65
+ var overlayColor: Int = Color.TRANSPARENT
70
66
  var blurRadius: Float = BLUR_RADIUS
71
67
  var enabled: Boolean = true
72
68
  set(value) { field = value; if (!value) invalidatePool() }
@@ -77,8 +73,12 @@ internal class LegacyBlurController(
77
73
  else rootView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
78
74
  }
79
75
 
76
+ // isCapturing: set true before root.draw() so BlurVibeView.draw() is a no-op
77
+ // preventing stale self-capture. Accessed by BlurVibeView.draw().
78
+ var isCapturing = false
79
+ private set
80
+
80
81
  private var frameScheduled = false
81
- private var isCapturing = false
82
82
 
83
83
  // ── Choreographer gate ────────────────────────────────────────────────────
84
84
 
@@ -88,7 +88,7 @@ internal class LegacyBlurController(
88
88
  }
89
89
 
90
90
  private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
91
- if (!frameScheduled && enabled) {
91
+ if (!frameScheduled && enabled && autoUpdate) {
92
92
  frameScheduled = true
93
93
  Choreographer.getInstance().postFrameCallback(frameCallback)
94
94
  }
@@ -112,7 +112,6 @@ internal class LegacyBlurController(
112
112
  // ── Capture + blur ─────────────────────────────────────────────────────────
113
113
 
114
114
  private fun captureAndBlur() {
115
- if (isCapturing) return
116
115
  val rw = rootView.width; if (rw <= 0) return
117
116
  val rh = rootView.height; if (rh <= 0) return
118
117
  val vw = view.width; if (vw <= 0) return
@@ -121,85 +120,88 @@ internal class LegacyBlurController(
121
120
  val sw = (vw / DOWNSAMPLE_FACTOR).toInt().coerceAtLeast(1)
122
121
  val sh = (vh / DOWNSAMPLE_FACTOR).toInt().coerceAtLeast(1)
123
122
 
123
+ val myLoc = IntArray(2); view.getLocationInWindow(myLoc)
124
+ val rootLoc = IntArray(2); rootView.getLocationInWindow(rootLoc)
125
+ val offsetX = (myLoc[0] - rootLoc[0]).toFloat()
126
+ val offsetY = (myLoc[1] - rootLoc[1]).toFloat()
127
+
128
+ val capture = reuseBitmap(captureBitmap, vw, vh).also { captureBitmap = it }
129
+ val scaled = reuseBitmap(scaledBitmap, sw, sh).also { scaledBitmap = it }
130
+
131
+ // Set isCapturing BEFORE root.draw() so BlurVibeView.draw() is skipped
124
132
  isCapturing = true
133
+ val c = Canvas(capture)
134
+ c.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
135
+ c.translate(-offsetX, -offsetY)
125
136
  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
137
  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
138
  } catch (_: Exception) {
156
- } finally {
157
139
  isCapturing = false
140
+ return
158
141
  }
142
+ isCapturing = false
143
+
144
+ // Downsample
145
+ Canvas(scaled).drawBitmap(
146
+ capture,
147
+ Rect(0, 0, capture.width, capture.height),
148
+ Rect(0, 0, scaled.width, scaled.height),
149
+ capturePaint
150
+ )
151
+
152
+ // Blur (2 rounds)
153
+ repeat(BLUR_ROUNDS) { blurBitmap(scaled) }
154
+
155
+ view.invalidate()
159
156
  }
160
157
 
161
158
  private fun blurBitmap(bitmap: Bitmap) {
162
- val rs = this.rs ?: return softwareBlur(bitmap)
163
- val sc = this.blurScript ?: return softwareBlur(bitmap)
159
+ val r = rs ?: return softwareBlur(bitmap)
160
+ val sc = blurScript ?: return softwareBlur(bitmap)
164
161
  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)
162
+ val iA = reuseAlloc(inputAlloc, bitmap, r).also { inputAlloc = it }
163
+ val oA = reuseAlloc(outputAlloc, bitmap, r).also { outputAlloc = it }
164
+ iA.copyFrom(bitmap)
168
165
  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
- }
166
+ sc.setInput(iA)
167
+ sc.forEach(oA)
168
+ oA.copyTo(bitmap)
169
+ } catch (_: Exception) { softwareBlur(bitmap) }
175
170
  }
176
171
 
177
172
  private fun softwareBlur(bitmap: Bitmap) {
178
- // Pure software Gaussian fallback (slower but always works)
179
173
  val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
180
174
  maskFilter = android.graphics.BlurMaskFilter(blurRadius, android.graphics.BlurMaskFilter.Blur.NORMAL)
181
175
  }
182
176
  Canvas(bitmap).drawBitmap(bitmap, 0f, 0f, paint)
183
177
  }
184
178
 
185
- // ── Draw — called from BlurVibeView.onDraw() ──────────────────────────────
179
+ // ── Draw ─────────────────────────────────────────────────────────────────
186
180
 
187
181
  fun draw(canvas: Canvas, viewWidth: Float, viewHeight: Float) {
188
182
  scaledBitmap?.takeIf { !it.isRecycled }?.let { bmp ->
189
183
  canvas.drawBitmap(bmp, null,
190
184
  android.graphics.RectF(0f, 0f, viewWidth, viewHeight), drawPaint)
191
185
  }
192
- if (Color.alpha(overlayColor) > 0) {
193
- canvas.drawColor(overlayColor)
194
- }
186
+ if (Color.alpha(overlayColor) > 0) canvas.drawColor(overlayColor)
195
187
  }
196
188
 
197
- // ── Lifecycle ─────────────────────────────────────────────────────────────
189
+ // ── Multi-window ──────────────────────────────────────────────────────────
198
190
 
199
- fun onSizeChanged() {
200
- invalidatePool()
191
+ fun reAttach() {
192
+ if (enabled && autoUpdate) safeAddPreDrawListener()
201
193
  }
202
194
 
195
+ private fun safeAddPreDrawListener() {
196
+ val vto = rootView.viewTreeObserver
197
+ vto.removeOnPreDrawListener(preDrawListener)
198
+ if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
199
+ }
200
+
201
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
202
+
203
+ fun onSizeChanged() { invalidatePool() }
204
+
203
205
  private fun invalidatePool() {
204
206
  captureBitmap?.recycle(); captureBitmap = null
205
207
  scaledBitmap?.recycle(); scaledBitmap = null
@@ -207,26 +209,6 @@ internal class LegacyBlurController(
207
209
  outputAlloc?.destroy(); outputAlloc = null
208
210
  }
209
211
 
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
212
  fun destroy() {
231
213
  rootView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
232
214
  Choreographer.getInstance().removeFrameCallback(frameCallback)
@@ -238,7 +220,7 @@ internal class LegacyBlurController(
238
220
  scaledBitmap?.recycle()
239
221
  }
240
222
 
241
- // ── Bitmap / Allocation helpers ───────────────────────────────────────────
223
+ // ── Helpers ───────────────────────────────────────────────────────────────
242
224
 
243
225
  private fun reuseBitmap(existing: Bitmap?, w: Int, h: Int): Bitmap {
244
226
  if (existing != null && !existing.isRecycled
@@ -248,9 +230,8 @@ internal class LegacyBlurController(
248
230
  }
249
231
 
250
232
  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
233
+ if (existing != null && existing.type.x == src.width && existing.type.y == src.height)
234
+ return existing
254
235
  existing?.destroy()
255
236
  return Allocation.createFromBitmap(rs, src,
256
237
  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.12",
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",