react-native-blur-vibe 0.1.8 → 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.
Files changed (31) hide show
  1. package/android/build.gradle +2 -2
  2. package/android/src/main/java/com/blurvibe/BlurVibeView.kt +101 -192
  3. package/android/src/main/java/com/blurvibe/BlurVibeViewApi31.kt +261 -241
  4. package/android/src/main/java/com/blurvibe/BlurVibeViewManager.kt +77 -28
  5. package/android/src/main/java/com/blurvibe/LegacyBlurController.kt +258 -0
  6. package/ios/BlurVibeView.swift +2 -0
  7. package/ios/BlurVibeViewFabric.mm +112 -0
  8. package/ios/BlurVibeViewManager.m +12 -2
  9. package/ios/BlurVibeViewManager.swift +9 -9
  10. package/lib/commonjs/BlurVibeViewNativeComponent.ts +14 -25
  11. package/lib/commonjs/BlurView.js +9 -30
  12. package/lib/commonjs/BlurView.js.map +1 -1
  13. package/lib/module/BlurVibeViewNativeComponent.ts +14 -25
  14. package/lib/module/BlurView.js +9 -30
  15. package/lib/module/BlurView.js.map +1 -1
  16. package/lib/typescript/commonjs/src/BlurVibeViewNativeComponent.d.ts +11 -9
  17. package/lib/typescript/commonjs/src/BlurVibeViewNativeComponent.d.ts.map +1 -1
  18. package/lib/typescript/commonjs/src/BlurView.d.ts +6 -31
  19. package/lib/typescript/commonjs/src/BlurView.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/src/types.d.ts +26 -1
  21. package/lib/typescript/commonjs/src/types.d.ts.map +1 -1
  22. package/lib/typescript/module/src/BlurVibeViewNativeComponent.d.ts +11 -9
  23. package/lib/typescript/module/src/BlurVibeViewNativeComponent.d.ts.map +1 -1
  24. package/lib/typescript/module/src/BlurView.d.ts +6 -31
  25. package/lib/typescript/module/src/BlurView.d.ts.map +1 -1
  26. package/lib/typescript/module/src/types.d.ts +26 -1
  27. package/lib/typescript/module/src/types.d.ts.map +1 -1
  28. package/package.json +11 -2
  29. package/src/BlurVibeViewNativeComponent.ts +14 -25
  30. package/src/BlurView.tsx +10 -33
  31. package/src/types.ts +30 -1
@@ -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,89 +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
 
31
35
  /**
32
- * BlurVibeViewApi31 — GPU backdrop blur for Android API 31+
36
+ * BlurVibeViewApi31 — Backdrop blur for Android API 31+
33
37
  *
34
- * Features:
35
- * • Dual-RenderNode blur (backdrop-filter CSS semantics)
36
- * • Progressive / gradient blur (vertical, horizontal, radial)
37
- * • Noise texture overlay (tactile frosted-glass feel, like Haze)
38
- * • Overlay tint with full RGBA support
39
- * • Corner radius with hardware clipping
40
- * • Choreographer-gated updates (max 1 capture per vsync)
38
+ * Pipeline (adapted from ModernBlurView's RenderEffectBlur approach):
41
39
  *
42
- * Progressive blur technique (from Haze docs):
43
- * Uses a mask approach — a LinearGradient/RadialGradient is drawn as an
44
- * alpha mask over the blur output using PorterDuff.DST_IN.
45
- * This fades the blur from full-strength to zero across the view.
46
- * Per Haze docs: "masks are much faster with negligible performance cost"
47
- * vs true per-pixel radius variation which costs ~25% more on API 33+.
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
48
49
  *
49
- * Noise texture:
50
- * Haze uses noise at 15% opacity by default for tactility.
51
- * We generate a small tileable noise bitmap once and draw it with low alpha.
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.
52
58
  */
53
59
  @RequiresApi(Build.VERSION_CODES.S)
54
60
  class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
55
61
 
56
62
  // ── Blur params ────────────────────────────────────────────────────────────
57
63
 
58
- private var blurRadiusX = DEFAULT_BLUR_RADIUS
59
- private var blurRadiusY = DEFAULT_BLUR_RADIUS
60
- private var overlayColor = Color.TRANSPARENT
64
+ private var blurAmount = 10f
65
+ private var overlayColor = Color.TRANSPARENT
61
66
  private var cornerRadiusPx = 0f
62
67
 
63
- // ── Progressive blur params ────────────────────────────────────────────────
68
+ // ── Progressive blur ──────────────────────────────────────────────────────
64
69
 
65
- private var progressiveDirection = PROGRESSIVE_NONE
66
- private var progressiveStartIntensity = 1f // 0.0–1.0, full blur at start
67
- private var progressiveEndIntensity = 0f // 0.0–1.0, no blur at end
70
+ private var progressiveDirection = PROGRESSIVE_NONE
71
+ private var progressiveStartIntensity = 1f
72
+ private var progressiveEndIntensity = 0f
68
73
 
69
- // ── Noise params ──────────────────────────────────────────────────────────
74
+ // ── Noise ─────────────────────────────────────────────────────────────────
70
75
 
71
- private var noiseFactor = 0.08f // Haze default is 0.15 — we use 0.08 as default (subtler)
76
+ private var noiseFactor = 0.08f
72
77
  private var noiseBitmap: Bitmap? = null
73
- private val noisePaint = Paint().apply { alpha = (noiseFactor * 255).toInt() }
74
-
75
- // ── RenderNodes ───────────────────────────────────────────────────────────
78
+ private val noisePaint = Paint()
76
79
 
77
- /** Records the root-view content "what's behind me" */
78
- private val contentNode = RenderNode("BlurVibeContent").apply {
79
- setUseCompositingLayer(true, null) // caches as GPU texture repeated reads are free
80
- }
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.
81
87
 
82
- /** Holds contentNode cropped to this view's position, with RenderEffect blur applied */
83
- private val blurNode = RenderNode("BlurVibeBlur")
88
+ private var internalBitmap: Bitmap? = null
89
+ private val renderNode = RenderNode("BlurVibeNode")
84
90
 
85
- // ── Paint objects (reused, no per-frame allocation) ───────────────────────
91
+ // ── Draw paint ────────────────────────────────────────────────────────────
86
92
 
87
- private val overlayPaint = Paint(Paint.ANTI_ALIAS_FLAG)
88
- private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
93
+ private val bitmapPaint = Paint(Paint.FILTER_BITMAP_FLAG)
94
+ private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
89
95
  xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
90
96
  }
91
- private val clearPaint = Paint().apply {
92
- xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
93
- }
97
+ private val overlayPaint = Paint(Paint.ANTI_ALIAS_FLAG)
94
98
 
95
99
  // ── Root view ─────────────────────────────────────────────────────────────
96
100
 
97
101
  private var blurRoot: ViewGroup? = null
102
+ private val rootLocation = IntArray(2)
103
+ private val blurViewLocation = IntArray(2)
98
104
 
99
- // ── Choreographer gate ────────────────────────────────────────────────────
105
+ // ── State ─────────────────────────────────────────────────────────────────
100
106
 
107
+ private var blurEnabled = true
108
+ private var autoUpdate = true
101
109
  private var frameScheduled = false
110
+ private var initialized = false
111
+
112
+ // ── Choreographer gate ────────────────────────────────────────────────────
113
+
102
114
  private val frameCallback = Choreographer.FrameCallback {
103
115
  frameScheduled = false
104
- if (isAttachedToWindow) {
105
- captureRootIntoNode()
106
- invalidate()
107
- }
116
+ if (isAttachedToWindow && blurEnabled) updateBlur()
108
117
  }
118
+
109
119
  private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
110
- if (!frameScheduled) {
120
+ if (!frameScheduled && blurEnabled && autoUpdate) {
111
121
  frameScheduled = true
112
122
  Choreographer.getInstance().postFrameCallback(frameCallback)
113
123
  }
@@ -120,8 +130,6 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
120
130
  setWillNotDraw(false)
121
131
  super.setBackgroundColor(Color.TRANSPARENT)
122
132
  clipToOutline = true
123
- // Enable hardware layer so onDraw() runs on GPU
124
- setLayerType(LAYER_TYPE_HARDWARE, null)
125
133
  }
126
134
 
127
135
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -129,173 +137,204 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
129
137
  override fun onAttachedToWindow() {
130
138
  super.onAttachedToWindow()
131
139
  blurRoot = findBlurRoot()
132
- blurRoot?.viewTreeObserver?.addOnPreDrawListener(preDrawListener)
140
+ safeAddPreDrawListener()
133
141
  generateNoiseBitmap()
142
+ if (measuredWidth > 0 && measuredHeight > 0) initBlur()
134
143
  }
135
144
 
136
145
  override fun onDetachedFromWindow() {
137
146
  blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
138
147
  Choreographer.getInstance().removeFrameCallback(frameCallback)
139
148
  frameScheduled = false
140
- blurRoot = null
141
- noiseBitmap?.recycle()
142
- noiseBitmap = null
149
+ initialized = false
150
+ blurRoot = null
151
+ noiseBitmap?.recycle(); noiseBitmap = null
152
+ internalBitmap?.recycle(); internalBitmap = null
153
+ renderNode.discardDisplayList()
143
154
  super.onDetachedFromWindow()
144
155
  }
145
156
 
146
157
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
147
158
  super.onSizeChanged(w, h, oldw, oldh)
148
- blurNode.setPosition(0, 0, w, h)
149
- applyBlurRenderEffect()
159
+ if (w > 0 && h > 0) {
160
+ internalBitmap?.recycle(); internalBitmap = null
161
+ initBlur()
162
+ }
150
163
  }
151
164
 
152
- // ── Capture ────────────────────────────────────────────────────────────────
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
+ }
153
183
 
154
- private fun captureRootIntoNode() {
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() {
155
190
  val root = blurRoot ?: return
156
- if (root.width <= 0 || root.height <= 0) return
157
-
158
- contentNode.setPosition(0, 0, root.width, root.height)
159
-
160
- val canvas = contentNode.beginRecording()
161
- try {
162
- canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
163
- root.draw(canvas)
164
- } finally {
165
- contentNode.endRecording()
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)
166
196
  }
167
-
168
- rebuildBlurNode()
169
197
  }
170
198
 
171
- private fun rebuildBlurNode() {
172
- val root = blurRoot ?: return
173
- if (width <= 0 || height <= 0) return
199
+ // ── Blur init ─────────────────────────────────────────────────────────────
174
200
 
175
- val myLoc = IntArray(2); getLocationInWindow(myLoc)
176
- val rootLoc = IntArray(2); root.getLocationInWindow(rootLoc)
177
- val offsetX = (myLoc[0] - rootLoc[0]).toFloat()
178
- val offsetY = (myLoc[1] - rootLoc[1]).toFloat()
201
+ private fun initBlur() {
202
+ val w = measuredWidth; if (w <= 0) return
203
+ val h = measuredHeight; if (h <= 0) return
179
204
 
180
- blurNode.setPosition(0, 0, width, height)
181
- applyBlurRenderEffect()
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()
210
+ }
182
211
 
183
- val canvas = blurNode.beginRecording()
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)
184
239
  try {
185
- canvas.translate(-offsetX, -offsetY)
186
- canvas.drawRenderNode(contentNode)
187
- } finally {
188
- blurNode.endRecording()
189
- }
190
- }
240
+ root.draw(captureCanvas)
241
+ } catch (_: Exception) { return }
191
242
 
192
- private fun applyBlurRenderEffect() {
193
- if (blurRadiusX < 0.5f && blurRadiusY < 0.5f) {
194
- blurNode.setRenderEffect(null)
195
- return
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)
196
247
  }
197
- blurNode.setRenderEffect(
198
- RenderEffect.createBlurEffect(blurRadiusX, blurRadiusY, Shader.TileMode.CLAMP)
199
- )
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()
200
270
  }
201
271
 
202
272
  // ── Draw ───────────────────────────────────────────────────────────────────
203
273
 
204
274
  override fun onDraw(canvas: Canvas) {
205
- val w = width.toFloat()
206
- val h = height.toFloat()
207
- if (w <= 0f || h <= 0f) return
208
-
209
- if (!blurNode.hasDisplayList()) return
275
+ if (!blurEnabled || !initialized) return
276
+ val w = width.toFloat(); if (w <= 0f) return
277
+ val h = height.toFloat(); if (h <= 0f) return
278
+ if (!renderNode.hasDisplayList()) return
210
279
 
211
- // ── Step 1: Save layer so we can apply mask on top of blur ────────────────
212
- // saveLayer lets us composite blur + progressive mask as a unit
280
+ // Step 1: save layer for progressive mask compositing
213
281
  val saveCount = if (progressiveDirection != PROGRESSIVE_NONE) {
214
282
  canvas.saveLayer(0f, 0f, w, h, null)
215
- } else {
216
- -1
217
- }
218
-
219
- // ── Step 2: Draw blurred backdrop ─────────────────────────────────────────
220
- canvas.drawRenderNode(blurNode)
221
-
222
- // ── Step 3: Progressive mask (alpha gradient fades the blur) ──────────────
283
+ } else -1
284
+
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
223
297
  if (progressiveDirection != PROGRESSIVE_NONE && saveCount >= 0) {
224
- val shader = buildProgressiveShader(w, h)
225
- if (shader != null) {
298
+ buildProgressiveShader(w, h)?.let { shader ->
226
299
  maskPaint.shader = shader
227
300
  canvas.drawRect(0f, 0f, w, h, maskPaint)
228
301
  }
229
302
  canvas.restoreToCount(saveCount)
230
303
  }
231
304
 
232
- // ── Step 4: Overlay tint ──────────────────────────────────────────────────
233
- if (Color.alpha(overlayColor) > 0) {
234
- overlayPaint.color = overlayColor
235
- if (cornerRadiusPx > 0f) {
236
- canvas.drawRoundRect(RectF(0f, 0f, w, h), cornerRadiusPx, cornerRadiusPx, overlayPaint)
237
- } else {
238
- canvas.drawRect(0f, 0f, w, h, overlayPaint)
239
- }
240
- }
241
-
242
- // ── Step 5: Noise texture (tactile frosted-glass feel) ────────────────────
243
- if (noiseFactor > 0f && noiseBitmap != null && !noiseBitmap!!.isRecycled) {
244
- noisePaint.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
245
- val noiseShader = BitmapShader(noiseBitmap!!, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
246
- noisePaint.shader = noiseShader
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)
247
309
  canvas.drawRect(0f, 0f, w, h, noisePaint)
248
310
  }
249
311
  }
250
312
 
251
- // ── Progressive shader builder ────────────────────────────────────────────
313
+ // ── Progressive shader ────────────────────────────────────────────────────
252
314
 
253
315
  private fun buildProgressiveShader(w: Float, h: Float): Shader? {
254
- // Map intensity values to alpha: 1.0 = fully opaque (full blur), 0.0 = fully transparent (no blur)
255
- val startAlpha = progressiveStartIntensity.coerceIn(0f, 1f)
256
- val endAlpha = progressiveEndIntensity.coerceIn(0f, 1f)
257
- val startColor = Color.argb((startAlpha * 255).toInt(), 0, 0, 0)
258
- val endColor = Color.argb((endAlpha * 255).toInt(), 0, 0, 0)
259
-
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(
262
- 0f, 0f, 0f, h, startColor, endColor, Shader.TileMode.CLAMP
263
- )
264
- PROGRESSIVE_BOTTOM_TO_TOP -> LinearGradient(
265
- 0f, h, 0f, 0f, startColor, endColor, Shader.TileMode.CLAMP
266
- )
267
- PROGRESSIVE_LEFT_TO_RIGHT -> LinearGradient(
268
- 0f, 0f, w, 0f, startColor, endColor, Shader.TileMode.CLAMP
269
- )
270
- PROGRESSIVE_RIGHT_TO_LEFT -> LinearGradient(
271
- w, 0f, 0f, 0f, startColor, endColor, Shader.TileMode.CLAMP
272
- )
273
- PROGRESSIVE_RADIAL -> RadialGradient(
274
- w / 2f, h / 2f,
275
- min(w, h) / 2f,
276
- startColor, endColor,
277
- Shader.TileMode.CLAMP
278
- )
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)
279
324
  else -> null
280
325
  }
281
326
  }
282
327
 
283
- // ── Noise generation ─────────────────────────────────────────────────────
284
- //
285
- // Generates a small (64×64) tileable noise bitmap once.
286
- // Haze uses noise at 15% opacity for tactility — the fine grain
287
- // breaks up the uniform blur and makes it look more like real frosted glass.
328
+ // ── Noise bitmap ──────────────────────────────────────────────────────────
288
329
 
289
330
  private fun generateNoiseBitmap() {
290
- if (noiseBitmap != null && !noiseBitmap!!.isRecycled) return
331
+ if (noiseBitmap?.isRecycled == false) return
291
332
  val size = 64
292
333
  val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
293
- val rng = Random(42) // fixed seed = deterministic noise, no shimmer on re-render
294
- for (x in 0 until size) {
295
- for (y in 0 until size) {
296
- val v = rng.nextInt(256)
297
- bmp.setPixel(x, y, Color.argb(255, v, v, v))
298
- }
334
+ val rng = Random(42)
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))
299
338
  }
300
339
  noiseBitmap = bmp
301
340
  }
@@ -303,17 +342,13 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
303
342
  // ── Public setters ─────────────────────────────────────────────────────────
304
343
 
305
344
  fun setBlurAmount(amount: Float) {
306
- val t = amount.coerceIn(0f, 100f) / 100f
307
- val radius = t * t * MAX_BLUR_RADIUS // quadratic — matches CSS backdrop-blur feel
308
- blurRadiusX = radius
309
- blurRadiusY = radius
310
- applyBlurRenderEffect()
345
+ blurAmount = amount.coerceIn(0f, 100f)
311
346
  scheduleFrame()
312
347
  }
313
348
 
314
349
  fun setOverlayColor(colorString: String?) {
315
350
  overlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
316
- invalidate()
351
+ scheduleFrame()
317
352
  }
318
353
 
319
354
  fun applyBorderRadius(radiusDp: Float) {
@@ -329,77 +364,70 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
329
364
  invalidate()
330
365
  }
331
366
 
332
- fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") color: String?) {
333
- // iOS-only — no-op on Android
334
- }
335
-
336
- /**
337
- * Progressive blur direction.
338
- * @param direction one of: "none", "topToBottom", "bottomToTop",
339
- * "leftToRight", "rightToLeft", "radial"
340
- */
341
- fun setProgressiveBlurDirection(direction: String?) {
342
- progressiveDirection = when (direction) {
343
- "topToBottom" -> PROGRESSIVE_TOP_TO_BOTTOM
344
- "bottomToTop" -> PROGRESSIVE_BOTTOM_TO_TOP
345
- "leftToRight" -> PROGRESSIVE_LEFT_TO_RIGHT
346
- "rightToLeft" -> PROGRESSIVE_RIGHT_TO_LEFT
347
- "radial" -> PROGRESSIVE_RADIAL
348
- else -> PROGRESSIVE_NONE
349
- }
350
- invalidate()
367
+ fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") c: String?) {}
368
+
369
+ fun setProgressiveBlurDirection(d: String?) {
370
+ progressiveDirection = when (d) {
371
+ "topToBottom" -> PROGRESSIVE_TOP_TO_BOTTOM
372
+ "bottomToTop" -> PROGRESSIVE_BOTTOM_TO_TOP
373
+ "leftToRight" -> PROGRESSIVE_LEFT_TO_RIGHT
374
+ "rightToLeft" -> PROGRESSIVE_RIGHT_TO_LEFT
375
+ "radial" -> PROGRESSIVE_RADIAL
376
+ else -> PROGRESSIVE_NONE
377
+ }; invalidate()
351
378
  }
352
379
 
353
- /**
354
- * Progressive blur start intensity (0.0 = no blur, 1.0 = full blur).
355
- * This is the intensity at the START of the gradient direction.
356
- * Default 1.0 — full blur at top/left/center.
357
- */
358
- fun setProgressiveStartIntensity(intensity: Float) {
359
- progressiveStartIntensity = intensity.coerceIn(0f, 1f)
360
- invalidate()
361
- }
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() }
362
383
 
363
- /**
364
- * Progressive blur end intensity (0.0 = no blur, 1.0 = full blur).
365
- * This is the intensity at the END of the gradient direction.
366
- * Default 0.0 — fades to no blur at bottom/right/edge.
367
- */
368
- fun setProgressiveEndIntensity(intensity: Float) {
369
- progressiveEndIntensity = intensity.coerceIn(0f, 1f)
370
- invalidate()
384
+ fun applyBlurEnabled(enabled: Boolean) {
385
+ blurEnabled = enabled
386
+ if (enabled) {
387
+ safeAddPreDrawListener()
388
+ scheduleFrame()
389
+ } else {
390
+ blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
391
+ Choreographer.getInstance().removeFrameCallback(frameCallback)
392
+ frameScheduled = false
393
+ renderNode.discardDisplayList()
394
+ invalidate()
395
+ }
371
396
  }
372
397
 
373
- /**
374
- * Noise factor — grain overlay strength for frosted-glass tactility.
375
- * 0.0 = no noise, 1.0 = full noise. Default 0.08 (8%).
376
- * Haze's default is 0.15. Set 0 to disable.
377
- */
378
- fun setNoiseFactor(factor: Float) {
379
- noiseFactor = factor.coerceIn(0f, 1f)
380
- invalidate()
398
+ fun setAutoUpdate(update: Boolean) {
399
+ autoUpdate = update
400
+ if (update) safeAddPreDrawListener()
401
+ else {
402
+ blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
403
+ Choreographer.getInstance().removeFrameCallback(frameCallback)
404
+ frameScheduled = false
405
+ }
381
406
  }
382
407
 
383
408
  // ── Helpers ────────────────────────────────────────────────────────────────
384
409
 
385
410
  private fun scheduleFrame() {
386
- if (!frameScheduled) {
411
+ if (!frameScheduled && blurEnabled) {
387
412
  frameScheduled = true
388
413
  Choreographer.getInstance().postFrameCallback(frameCallback)
389
414
  }
390
415
  }
391
416
 
417
+ private fun blurRadiusFromAmount(amount: Float): Float {
418
+ val t = amount / 100f
419
+ return (t * t * 25f).coerceIn(1f, 25f)
420
+ }
421
+
392
422
  private fun findBlurRoot(): ViewGroup? {
393
423
  var p = parent
394
424
  while (p != null) {
395
- if ((p as? View)?.javaClass?.name == "com.swmansion.rnscreens.Screen")
396
- return p as? ViewGroup
425
+ if ((p as? View)?.javaClass?.name == "com.swmansion.rnscreens.Screen") return p as? ViewGroup
397
426
  p = (p as? View)?.parent
398
427
  }
399
428
  p = parent
400
429
  while (p != null) {
401
- if ((p as? View)?.javaClass?.name == "com.facebook.react.ReactRootView")
402
- return p as? ViewGroup
430
+ if ((p as? View)?.javaClass?.name == "com.facebook.react.ReactRootView") return p as? ViewGroup
403
431
  p = (p as? View)?.parent
404
432
  }
405
433
  return rootView as? ViewGroup
@@ -412,37 +440,29 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
412
440
  val hex = t.removePrefix("#")
413
441
  return try {
414
442
  when (hex.length) {
415
- 3 -> Color.argb(255,
416
- hex[0].toString().repeat(2).toInt(16),
417
- hex[1].toString().repeat(2).toInt(16),
418
- hex[2].toString().repeat(2).toInt(16))
419
- 6 -> Color.argb(255,
420
- hex.substring(0, 2).toInt(16),
421
- hex.substring(2, 4).toInt(16),
422
- hex.substring(4, 6).toInt(16))
423
- 8 -> Color.argb(
424
- hex.substring(6, 8).toInt(16),
425
- hex.substring(0, 2).toInt(16),
426
- hex.substring(2, 4).toInt(16),
427
- 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))
449
+ 8 -> Color.argb(hex.substring(6,8).toInt(16),
450
+ hex.substring(0,2).toInt(16),
451
+ hex.substring(2,4).toInt(16),
452
+ hex.substring(4,6).toInt(16))
428
453
  else -> null
429
454
  }
430
455
  } catch (_: NumberFormatException) { null }
431
456
  }
432
457
 
433
- override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
434
- // Yoga handles all layout
435
- }
458
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
436
459
 
437
460
  companion object {
438
- private const val MAX_BLUR_RADIUS = 25f
439
- private const val DEFAULT_BLUR_RADIUS = 2.5f
440
-
441
- const val PROGRESSIVE_NONE = 0
442
- const val PROGRESSIVE_TOP_TO_BOTTOM = 1
443
- const val PROGRESSIVE_BOTTOM_TO_TOP = 2
444
- const val PROGRESSIVE_LEFT_TO_RIGHT = 3
445
- const val PROGRESSIVE_RIGHT_TO_LEFT = 4
446
- 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
447
467
  }
448
468
  }