react-native-blur-vibe 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,26 +2,25 @@
2
2
 
3
3
  <img width="1500" height="500" alt="github-banner" src="https://github.com/user-attachments/assets/78b2e5ec-5b57-48c0-b984-69cb57cbcf26" />
4
4
  <br></br>
5
- A modern, actively maintained blur view for React Native. Works on <b>iOS</b> and <b>Android</b> with full New Architecture (Fabric) support.
6
-
5
+
6
+ A modern, actively maintained blur view for React Native. Works on **iOS** and **Android** with full New Architecture (Fabric) support.
7
+
7
8
  > The key difference from other blur libraries: `overlayColor` works on **both iOS and Android** — letting you control blur visibility the same way CSS `backdrop-filter` + `background-color` works on the web.
8
- <br></br>
9
9
 
10
+ <br></br>
10
11
 
11
12
  [![npm version](https://img.shields.io/npm/v/react-native-blur-vibe)](https://www.npmjs.com/package/react-native-blur-vibe)
12
13
  [![Build iOS](https://github.com/I-am-Pritam-20/react-native-blur-vibe/actions/workflows/build-ios.yml/badge.svg)](https://github.com/I-am-Pritam-20/react-native-blur-vibe/actions/workflows/build-ios.yml)
13
14
  [![Build Android](https://github.com/I-am-Pritam-20/react-native-blur-vibe/actions/workflows/build-android.yml/badge.svg)](https://github.com/I-am-Pritam-20/react-native-blur-vibe/actions/workflows/build-android.yml)
14
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
15
- <div align="left">
16
+
17
+ <div align="left">
16
18
  <p>
17
19
  <img src="https://img.shields.io/badge/iOS-13%2B-blue?style=flat-square" alt="iOS 13+" />
18
20
  <img src="https://img.shields.io/badge/Android-API%2021%2B-green?style=flat-square" alt="Android API 21+" />
19
21
  </p>
20
22
  </div>
21
23
 
22
- ---
23
-
24
-
25
24
  ---
26
25
 
27
26
  ## Platform matrix
@@ -32,8 +31,13 @@ A modern, actively maintained blur view for React Native. Works on <b>iOS</b> an
32
31
  | Overlay tint | ✅ | ✅ | ✅ |
33
32
  | Progressive blur | ✅ | ✅ | ❌ |
34
33
  | Noise texture | ✅ | ✅ | ❌ |
34
+ | Full RN style props | ✅ | ✅ | ✅ |
35
35
  | `blurType` | ✅ | ❌ | ❌ |
36
+ | `enabled` / `autoUpdate` | ✅ | ✅ | ✅ |
36
37
  | `blurRadius` downsample | ❌ | ❌ | ✅ |
38
+ | Split-screen / PiP / Freeform | ✅ | ✅ | ✅ |
39
+ | Old Architecture (Paper) | ✅ | ✅ | ✅ |
40
+ | New Architecture (Fabric) | ✅ | ✅ | ✅ |
37
41
 
38
42
  ---
39
43
 
@@ -55,9 +59,7 @@ Minimum deployment target: **iOS 13.0**
55
59
 
56
60
  ### Android
57
61
 
58
- Minimum SDK: **API 21** (Android 5.0)
59
-
60
- No extra configuration required. The package automatically picks the best blur engine for the running API level.
62
+ Minimum SDK: **API 21** (Android 5.0). No extra configuration needed.
61
63
 
62
64
  ---
63
65
 
@@ -94,8 +96,6 @@ export default function Card() {
94
96
 
95
97
  Blur intensity from `0` (no blur) to `100` (maximum blur).
96
98
 
97
- Approximate CSS `backdrop-filter` equivalents:
98
-
99
99
  | `blurAmount` | CSS equivalent | Visual feel |
100
100
  |---|---|---|
101
101
  | `5` | `backdrop-blur-sm` (4px) | Subtle hint |
@@ -103,7 +103,7 @@ Approximate CSS `backdrop-filter` equivalents:
103
103
  | `25` | `backdrop-blur-md` (12px) | Standard card |
104
104
  | `50` | `backdrop-blur-xl` (24px) | Heavy glass |
105
105
  | `75` | `backdrop-blur-2xl` | Dense blur |
106
- | `100` | `backdrop-blur-3xl` | Maximum |
106
+ | `100` | `backdrop-blur-3xl` | Maximum — nearly opaque frosted panel |
107
107
 
108
108
  ```tsx
109
109
  <BlurView blurAmount={30} style={StyleSheet.absoluteFill} />
@@ -126,8 +126,6 @@ backdrop-filter: blur(Xpx);
126
126
  background-color: <overlayColor>;
127
127
  ```
128
128
 
129
- The alpha channel controls how much blur shows through:
130
-
131
129
  | Value | Effect |
132
130
  |---|---|
133
131
  | `"#00000000"` | Transparent — pure blur, no tint |
@@ -138,10 +136,6 @@ The alpha channel controls how much blur shows through:
138
136
 
139
137
  Supported formats: `"transparent"`, `"#RGB"`, `"#RRGGBB"`, `"#RRGGBBAA"`
140
138
 
141
- ```tsx
142
- <BlurView blurAmount={20} overlayColor="#FFFFFF25" style={StyleSheet.absoluteFill} />
143
- ```
144
-
145
139
  ---
146
140
 
147
141
  ### `blurType`
@@ -152,8 +146,7 @@ Supported formats: `"transparent"`, `"#RGB"`, `"#RRGGBB"`, `"#RRGGBBAA"`
152
146
  | Default | `"light"` |
153
147
  | Platform | **iOS only** — ignored on Android |
154
148
 
155
-
156
- **Adaptive styles** (change with light/dark mode — recommended):
149
+ Maps to `UIBlurEffect.Style`. Use `overlayColor` to tint on Android.
157
150
 
158
151
  | Value | Description |
159
152
  |---|---|
@@ -168,9 +161,7 @@ Supported formats: `"transparent"`, `"#RGB"`, `"#RRGGBB"`, `"#RRGGBBAA"`
168
161
  | `"systemThickMaterial"` | Thick material |
169
162
  | `"systemChromeMaterial"` | For toolbars / nav bars |
170
163
 
171
- Also available: `Light` and `Dark` suffixed variants (e.g. `"systemMaterialDark"`) for explicit mode control. See `BlurType` in types for the full list.
172
-
173
- ❗On Android, use `overlayColor` to control the tint instead.
164
+ Also available: `Light` and `Dark` suffixed variants (e.g. `"systemMaterialDark"`). See `BlurType` in types.
174
165
 
175
166
  ```tsx
176
167
  <BlurView blurType="systemMaterial" blurAmount={100} style={StyleSheet.absoluteFill} />
@@ -186,19 +177,7 @@ Also available: `Light` and `Dark` suffixed variants (e.g. `"systemMaterialDark"
186
177
  | Default | `"#F2F2F2"` |
187
178
  | Platform | iOS + Android |
188
179
 
189
- Solid color shown when blur is unavailable:
190
- - **iOS**: User enabled *Settings → Accessibility → Display & Text Size → Reduce Transparency*
191
- - **Android**: Device API level < 21
192
-
193
- Should provide sufficient contrast without the blur. Use a semi-opaque version of your background color.
194
-
195
- ```tsx
196
- <BlurView
197
- blurAmount={20}
198
- reducedTransparencyFallbackColor="#1C1C1ECC"
199
- style={StyleSheet.absoluteFill}
200
- />
201
- ```
180
+ Solid color shown when blur is unavailable (iOS Reduce Transparency enabled, or Android API < 21).
202
181
 
203
182
  ---
204
183
 
@@ -208,19 +187,40 @@ Should provide sufficient contrast without the blur. Use a semi-opaque version o
208
187
  |---|---|
209
188
  | Type | `number` (integer 1–8) |
210
189
  | Default | `4` |
211
- | Platform | **Android API < 31 only** — ignored on API 31+ and iOS |
190
+ | Platform | **Android API < 31 only** |
212
191
 
192
+ RenderScript capture downsample factor. Higher = faster but softer. Ignored on API 31+ and iOS.
213
193
 
214
- | Value | Pixels captured | Quality | Speed |
215
- |---|---|---|---|
216
- | `1` | Full res | Sharpest | Slowest |
217
- | `4` | 1/16 (default) | Good | Fast |
218
- | `8` | 1/64 | Softer | Fastest |
194
+ ---
195
+
196
+ ### `enabled`
197
+
198
+ | | |
199
+ |---|---|
200
+ | Type | `boolean` |
201
+ | Default | `true` |
202
+ | Platform | iOS + Android |
219
203
 
220
- On **Android API 31+** the blur runs at full resolution on the GPU this prop has no effect.
204
+ Enable or disable the blur effect. When `false`, the view renders transparently. Useful for toggling blur based on scroll position or performance mode.
221
205
 
222
206
  ```tsx
223
- <BlurView blurAmount={20} blurRadius={4} style={StyleSheet.absoluteFill} />
207
+ <BlurView blurAmount={30} enabled={isScrolling ? false : true} style={StyleSheet.absoluteFill} />
208
+ ```
209
+
210
+ ---
211
+
212
+ ### `autoUpdate`
213
+
214
+ | | |
215
+ |---|---|
216
+ | Type | `boolean` |
217
+ | Default | `true` |
218
+ | Platform | iOS + Android |
219
+
220
+ When `false`, blur is captured once at mount and never updated. Use for completely static backgrounds (e.g. blurred album art) to eliminate all per-frame cost on Android API < 31.
221
+
222
+ ```tsx
223
+ <BlurView blurAmount={40} autoUpdate={false} style={StyleSheet.absoluteFill} />
224
224
  ```
225
225
 
226
226
  ---
@@ -231,7 +231,7 @@ On **Android API 31+** the blur runs at full resolution on the GPU — this prop
231
231
  |---|---|
232
232
  | Type | `ProgressiveBlurDirection` |
233
233
  | Default | `"none"` |
234
- | Platform | **iOS + Android API 31+** — Android API < 31 shows uniform blur |
234
+ | Platform | **iOS + Android API 31+** |
235
235
 
236
236
  Direction the blur intensity fades across the view.
237
237
 
@@ -244,9 +244,7 @@ Direction the blur intensity fades across the view.
244
244
  | `"rightToLeft"` | Right edge | Left edge |
245
245
  | `"radial"` | Center | Outer edges |
246
246
 
247
-
248
247
  ```tsx
249
- // Sticky header — full blur at top, fades to nothing at bottom
250
248
  <BlurView
251
249
  blurAmount={40}
252
250
  progressiveBlurDirection="topToBottom"
@@ -266,17 +264,7 @@ Direction the blur intensity fades across the view.
266
264
  | Default | `1.0` |
267
265
  | Platform | **iOS + Android API 31+** |
268
266
 
269
- Blur intensity at the **start** of the gradient direction.
270
-
271
- - `1.0` — full blur at `blurAmount` intensity
272
- - `0.0` — completely transparent / no blur
273
-
274
- "Start" depends on `progressiveBlurDirection`:
275
- - `"topToBottom"` → top edge
276
- - `"bottomToTop"` → bottom edge
277
- - `"leftToRight"` → left edge
278
- - `"rightToLeft"` → right edge
279
- - `"radial"` → center
267
+ Blur intensity at the start of the gradient direction. `1.0` = full blur, `0.0` = no blur.
280
268
 
281
269
  ---
282
270
 
@@ -288,9 +276,7 @@ Blur intensity at the **start** of the gradient direction.
288
276
  | Default | `0.0` |
289
277
  | Platform | **iOS + Android API 31+** |
290
278
 
291
- Blur intensity at the **end** of the gradient direction.
292
-
293
- "End" is the opposite edge/point from `progressiveStartIntensity`.
279
+ Blur intensity at the end of the gradient direction.
294
280
 
295
281
  ---
296
282
 
@@ -300,26 +286,55 @@ Blur intensity at the **end** of the gradient direction.
300
286
  |---|---|
301
287
  | Type | `number` (0.0–1.0) |
302
288
  | Default | `0.08` |
303
- | Platform | **iOS + Android API 31+** — Android API < 31 silently ignores |
289
+ | Platform | **iOS + Android API 31+** |
304
290
 
305
- Noise grain overlay strength. Adds a subtle static grain texture on top of the blur, mimicking the micro-texture of real ground glass and making the blur feel more physical and premium.
291
+ Noise grain overlay for tactile frosted-glass texture.
306
292
 
307
293
  | Value | Effect |
308
294
  |---|---|
309
295
  | `0` | No noise — clean digital blur |
310
- | `0.08` | Subtle, barely perceptible (default) |
311
- | `0.15` | Noticeable grain (Haze library default) |
296
+ | `0.08` | Subtle grain (default) |
297
+ | `0.15` | Noticeable grain |
312
298
  | `0.30` | Heavy grain |
313
299
 
300
+ ---
301
+
302
+ ## Style props
303
+
304
+ `BlurView` accepts **all standard React Native View style props** via `StyleSheet` — including `borderRadius`, `borderColor`, `borderWidth`, `opacity`, `backgroundColor`, `elevation`, `shadowColor`, and all others.
305
+
306
+ ### `borderRadius` via StyleSheet
307
+
308
+ Use `borderRadius` directly inside `style` — it works exactly like any other RN view:
309
+
314
310
  ```tsx
315
- <BlurView blurAmount={50} noiseFactor={0.12} style={StyleSheet.absoluteFill} />
311
+ // Via StyleSheet (recommended)
312
+ <BlurView
313
+ blurAmount={30}
314
+ overlayColor="#FFFFFF20"
315
+ style={{
316
+ borderRadius: 20,
317
+ overflow: 'hidden', // required on iOS for clipping
318
+ ...StyleSheet.absoluteFillObject,
319
+ }}
320
+ />
321
+
322
+ // ✅ Via StyleSheet.create
323
+ const styles = StyleSheet.create({
324
+ blur: {
325
+ borderRadius: 16,
326
+ overflow: 'hidden',
327
+ position: 'absolute',
328
+ top: 0, left: 0, right: 0, bottom: 0,
329
+ },
330
+ });
331
+
332
+ <BlurView blurAmount={25} overlayColor="#00000040" style={styles.blur} />
316
333
  ```
317
334
 
318
- ---
335
+ > **Note:** Add `overflow: 'hidden'` when using `borderRadius` on iOS to ensure child content is clipped correctly. On Android this is handled automatically via `clipToOutline`.
319
336
 
320
- ## Usage examples
321
-
322
- ### Basic frosted glass card
337
+ ### Rounded frosted card
323
338
 
324
339
  ```tsx
325
340
  import { BlurView } from 'react-native-blur-vibe';
@@ -330,22 +345,85 @@ function FrostedCard() {
330
345
  <ImageBackground source={require('./bg.jpg')} style={styles.bg}>
331
346
  <View style={styles.card}>
332
347
  <BlurView
333
- blurAmount={30}
334
- overlayColor="#FFFFFF20"
348
+ blurAmount={35}
349
+ overlayColor="#FFFFFF18"
335
350
  noiseFactor={0.1}
336
- style={StyleSheet.absoluteFill}
351
+ style={[StyleSheet.absoluteFill, styles.blur]}
337
352
  />
338
- <Text style={styles.title}>Hello</Text>
353
+ <Text style={styles.title}>Now Playing</Text>
339
354
  </View>
340
355
  </ImageBackground>
341
356
  );
342
357
  }
358
+
359
+ const styles = StyleSheet.create({
360
+ bg: { flex: 1 },
361
+ card: {
362
+ margin: 20,
363
+ borderRadius: 24,
364
+ overflow: 'hidden', // clips blur to card shape on iOS
365
+ padding: 20,
366
+ },
367
+ blur: {
368
+ borderRadius: 24, // matches card borderRadius
369
+ },
370
+ title: { color: '#fff', fontSize: 18, fontWeight: '600' },
371
+ });
372
+ ```
373
+
374
+ ### Individual corner radii
375
+
376
+ ```tsx
377
+ <BlurView
378
+ blurAmount={25}
379
+ style={{
380
+ borderTopLeftRadius: 0,
381
+ borderTopRightRadius: 0,
382
+ borderBottomLeftRadius: 20,
383
+ borderBottomRightRadius: 20,
384
+ overflow: 'hidden',
385
+ }}
386
+ />
387
+ ```
388
+
389
+ ### Border with blur
390
+
391
+ ```tsx
392
+ <BlurView
393
+ blurAmount={30}
394
+ overlayColor="#FFFFFF10"
395
+ style={{
396
+ borderRadius: 16,
397
+ borderWidth: 1,
398
+ borderColor: 'rgba(255,255,255,0.3)',
399
+ overflow: 'hidden',
400
+ }}
401
+ />
402
+ ```
403
+
404
+ ---
405
+
406
+ ## Usage examples
407
+
408
+ ### Basic frosted glass card
409
+
410
+ ```tsx
411
+ <ImageBackground source={require('./bg.jpg')} style={styles.bg}>
412
+ <View style={styles.card}>
413
+ <BlurView
414
+ blurAmount={30}
415
+ overlayColor="#FFFFFF20"
416
+ noiseFactor={0.1}
417
+ style={[StyleSheet.absoluteFill, { borderRadius: 20 }]}
418
+ />
419
+ <Text style={styles.title}>Hello</Text>
420
+ </View>
421
+ </ImageBackground>
343
422
  ```
344
423
 
345
424
  ### Sticky header with progressive blur
346
425
 
347
426
  ```tsx
348
- // Full blur at top, fades to nothing — used by iOS App Library, Spotlight
349
427
  <BlurView
350
428
  blurAmount={40}
351
429
  overlayColor="#00000020"
@@ -359,7 +437,6 @@ function FrostedCard() {
359
437
  ### Bottom sheet scrim
360
438
 
361
439
  ```tsx
362
- // No blur at top, full blur at bottom — bottom sheet background
363
440
  <BlurView
364
441
  blurAmount={50}
365
442
  overlayColor="#00000040"
@@ -370,33 +447,44 @@ function FrostedCard() {
370
447
  />
371
448
  ```
372
449
 
373
- ### Music player card (dark frosted glass)
450
+ ### Music player card dark frosted glass
374
451
 
375
452
  ```tsx
376
453
  <BlurView
377
454
  blurAmount={60}
378
- blurType="systemMaterial" // iOS: system material
379
- overlayColor="#00000050" // Android: dark tint
455
+ blurType="systemMaterial"
456
+ overlayColor="#00000050"
380
457
  noiseFactor={0.12}
458
+ style={[StyleSheet.absoluteFill, { borderRadius: 16 }]}
459
+ />
460
+ ```
461
+
462
+ ### Toggle blur on scroll
463
+
464
+ ```tsx
465
+ const [isScrolling, setIsScrolling] = React.useState(false);
466
+
467
+ <BlurView
468
+ blurAmount={30}
469
+ enabled={!isScrolling}
381
470
  style={StyleSheet.absoluteFill}
382
471
  />
383
472
  ```
384
473
 
385
- ### Pure blur, no tint
474
+ ### Static background blur (best performance)
386
475
 
387
476
  ```tsx
388
- // Maximum blur, fully transparent overlay
477
+ // Capture once, never update — great for album art, splash screens
389
478
  <BlurView
390
479
  blurAmount={50}
391
- overlayColor="#00000000"
480
+ autoUpdate={false}
481
+ overlayColor="#00000030"
392
482
  style={StyleSheet.absoluteFill}
393
483
  />
394
484
  ```
395
485
 
396
486
  ### Inside a Modal
397
487
 
398
- Works out of the box — the blur root is automatically scoped to the nearest `Screen` or `ReactRootView`.
399
-
400
488
  ```tsx
401
489
  <Modal visible={visible} transparent>
402
490
  <BlurView
@@ -408,7 +496,7 @@ Works out of the box — the blur root is automatically scoped to the nearest `S
408
496
  </Modal>
409
497
  ```
410
498
 
411
- ### Inside FlatList / FlashList cards
499
+ ### Inside FlatList / FlashList
412
500
 
413
501
  ```tsx
414
502
  <FlatList
@@ -418,7 +506,7 @@ Works out of the box — the blur root is automatically scoped to the nearest `S
418
506
  <BlurView
419
507
  blurAmount={20}
420
508
  overlayColor="#FFFFFF15"
421
- style={StyleSheet.absoluteFill}
509
+ style={[StyleSheet.absoluteFill, { borderRadius: 12 }]}
422
510
  />
423
511
  <Text>{item.title}</Text>
424
512
  </ImageBackground>
@@ -426,6 +514,8 @@ Works out of the box — the blur root is automatically scoped to the nearest `S
426
514
  />
427
515
  ```
428
516
 
517
+ ---
518
+
429
519
  ## TypeScript
430
520
 
431
521
  Full TypeScript support with detailed JSDoc on every prop.
@@ -438,4 +528,4 @@ import type { BlurViewProps, BlurType, ProgressiveBlurDirection } from 'react-na
438
528
 
439
529
  ## License
440
530
 
441
- MIT ©[Pritam Nanda](https://github.com/I-am-Pritam-20)
531
+ MIT © [Pritam Nanda](https://github.com/I-am-Pritam-20)
@@ -11,19 +11,6 @@ import android.view.ViewOutlineProvider
11
11
  import androidx.core.graphics.toColorInt
12
12
  import com.facebook.react.views.view.ReactViewGroup
13
13
 
14
- /**
15
- * BlurVibeView — Android API 21–30 backdrop blur.
16
- *
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.
20
- *
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.
26
- */
27
14
  class BlurVibeView(context: Context) : ReactViewGroup(context) {
28
15
 
29
16
  private var blurController: LegacyBlurController? = null
@@ -33,7 +20,8 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
33
20
 
34
21
  init {
35
22
  setWillNotDraw(false)
36
- // DO NOT call setBackgroundColor — see BlurVibeViewApi31 for explanation.
23
+ outlineProvider = ViewOutlineProvider.BACKGROUND
24
+ clipToOutline = true
37
25
  }
38
26
 
39
27
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -64,11 +52,6 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
64
52
  }
65
53
 
66
54
  // ── 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
55
 
73
56
  override fun draw(canvas: Canvas) {
74
57
  if (blurController?.isCapturing == true) return
@@ -105,17 +88,6 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
105
88
  cornerRadiusPx = TypedValue.applyDimension(
106
89
  TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
107
90
  )
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
- }
113
- }
114
- clipToOutline = true
115
- } else {
116
- outlineProvider = ViewOutlineProvider.BACKGROUND
117
- clipToOutline = false
118
- }
119
91
  invalidate()
120
92
  }
121
93
 
@@ -3,21 +3,24 @@ 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
8
6
  import android.graphics.Canvas
9
7
  import android.graphics.Color
10
8
  import android.graphics.LinearGradient
11
- import android.graphics.Outline
12
9
  import android.graphics.Paint
13
10
  import android.graphics.PorterDuff
14
11
  import android.graphics.PorterDuffXfermode
15
12
  import android.graphics.RadialGradient
13
+ import android.graphics.Rect
16
14
  import android.graphics.RectF
17
- import android.graphics.RenderEffect
18
- import android.graphics.RenderNode
19
15
  import android.graphics.Shader
20
16
  import android.os.Build
17
+ import android.os.Handler
18
+ import android.os.HandlerThread
19
+ import android.os.Looper
20
+ import android.renderscript.Allocation
21
+ import android.renderscript.Element
22
+ import android.renderscript.RenderScript
23
+ import android.renderscript.ScriptIntrinsicBlur
21
24
  import android.util.TypedValue
22
25
  import android.view.Choreographer
23
26
  import android.view.View
@@ -30,13 +33,16 @@ import com.facebook.react.views.view.ReactViewGroup
30
33
  import kotlin.math.min
31
34
  import kotlin.random.Random
32
35
 
36
+ /**
37
+ * BlurVibeViewApi31 — Backdrop blur for Android API 31+
38
+ **/
33
39
  @RequiresApi(Build.VERSION_CODES.S)
34
40
  class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
35
41
 
36
42
  // ── Blur params ────────────────────────────────────────────────────────────
37
43
 
38
- private var blurAmount = 10f
39
- private var overlayColor = Color.TRANSPARENT
44
+ private var blurAmount = 10f
45
+ private var overlayColor = Color.TRANSPARENT
40
46
  private var cornerRadiusPx = 0f
41
47
 
42
48
  // ── Progressive blur ──────────────────────────────────────────────────────
@@ -51,58 +57,54 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
51
57
  private var noiseBitmap: Bitmap? = null
52
58
  private val noisePaint = Paint()
53
59
 
54
- // ── Bitmap + RenderNode ────────────────────────────────────────────────────
60
+ // ── Double-buffer bitmap pool ─────────────────────────────────────────────
55
61
 
56
- private var internalBitmap: Bitmap? = null
57
- private val renderNode = RenderNode("BlurVibeNode")
62
+ private var captureBitmap: Bitmap? = null
63
+ private var scaledBitmap: Bitmap? = null
64
+ @Volatile private var readyBitmap: Bitmap? = null
58
65
 
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.
66
+ private val capturePaint = Paint(Paint.FILTER_BITMAP_FLAG)
67
+ private val bitmapPaint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG)
78
68
 
79
- private var isCapturing = false
69
+ // ── Worker thread — blur runs here, main thread never blocks ──────────────
80
70
 
81
- // ── Draw paints ───────────────────────────────────────────────────────────
71
+ private val workerThread = HandlerThread("BlurVibeWorker31-${hashCode()}")
72
+ .also { it.start() }
73
+ private val workerHandler = Handler(workerThread.looper)
74
+ private val mainHandler = Handler(Looper.getMainLooper())
82
75
 
83
- private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
84
- xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
85
- }
86
- private val noisePaintFinal = Paint()
76
+ // ── RenderScript (deprecated API 31 but still functional through API 35) ──
77
+
78
+ @Suppress("DEPRECATION")
79
+ private var rs: RenderScript? = null
80
+ @Suppress("DEPRECATION")
81
+ private var blurScript: ScriptIntrinsicBlur? = null
82
+ @Suppress("DEPRECATION")
83
+ private var inAlloc: Allocation? = null
84
+ @Suppress("DEPRECATION")
85
+ private var outAlloc: Allocation? = null
87
86
 
88
87
  // ── Root view ─────────────────────────────────────────────────────────────
89
88
 
90
89
  private var blurRoot: ViewGroup? = null
91
- private val myLocation = IntArray(2)
92
- private val rootLocation = IntArray(2)
90
+ private val myLoc = IntArray(2)
91
+ private val rootLoc = IntArray(2)
93
92
 
94
93
  // ── State ─────────────────────────────────────────────────────────────────
95
94
 
95
+ // isCapturing: suppresses our own draw() during root.draw() capture
96
+ // so we don't paint stale blur into the capture bitmap (static blur bug)
97
+ var isCapturing = false
98
+ private set
96
99
  private var blurEnabled = true
97
100
  private var autoUpdate = true
98
101
  private var frameScheduled = false
99
- private var initialized = false
100
102
 
101
103
  // ── Choreographer gate ────────────────────────────────────────────────────
102
104
 
103
105
  private val frameCallback = Choreographer.FrameCallback {
104
106
  frameScheduled = false
105
- if (isAttachedToWindow && blurEnabled) updateBlur()
107
+ if (isAttachedToWindow && blurEnabled) captureAndBlur()
106
108
  }
107
109
 
108
110
  private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
@@ -113,16 +115,25 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
113
115
  true
114
116
  }
115
117
 
118
+ // ── Paint objects ─────────────────────────────────────────────────────────
119
+
120
+ private val overlayPaint = Paint(Paint.ANTI_ALIAS_FLAG)
121
+ private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
122
+ xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
123
+ }
124
+
116
125
  // ── Init ───────────────────────────────────────────────────────────────────
117
126
 
118
127
  init {
119
128
  setWillNotDraw(false)
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
- // ColorDrawabledestroying all style prop handling.
129
+ // DO NOT call setBackgroundColor — it replaces ReactViewGroup's
130
+ // ReactViewBackgroundDrawable, killing all RN style prop handling.
131
+ //
132
+ // outlineProvider = BACKGROUND: ReactViewBackgroundDrawable implements
133
+ // getOutline() for all RN borderRadius variants. clipToOutline=false
134
+ // by default only enabled when a non-zero radius is actually set,
135
+ // to avoid GPU clip stack issues with overflow:hidden + Reanimated.
136
+ outlineProvider = ViewOutlineProvider.BACKGROUND
126
137
  }
127
138
 
128
139
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -132,34 +143,34 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
132
143
  blurRoot = findBlurRoot()
133
144
  safeAddPreDrawListener()
134
145
  generateNoiseBitmap()
135
- if (measuredWidth > 0 && measuredHeight > 0) initBlur()
146
+ workerHandler.post { initRenderScript() }
147
+ scheduleFrame()
136
148
  }
137
149
 
138
150
  override fun onDetachedFromWindow() {
139
- blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
151
+ safeRemovePreDrawListener()
140
152
  Choreographer.getInstance().removeFrameCallback(frameCallback)
141
153
  frameScheduled = false
142
- initialized = false
143
154
  isCapturing = false
144
155
  blurRoot = null
145
- noiseBitmap?.recycle(); noiseBitmap = null
146
- internalBitmap?.recycle(); internalBitmap = null
147
- renderNode.discardDisplayList()
156
+ readyBitmap = null
157
+ noiseBitmap?.recycle(); noiseBitmap = null
158
+ workerHandler.post {
159
+ releaseBitmapPool()
160
+ releaseRenderScript()
161
+ }
148
162
  super.onDetachedFromWindow()
149
163
  }
150
164
 
151
165
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
152
166
  super.onSizeChanged(w, h, oldw, oldh)
153
167
  if (w > 0 && h > 0) {
154
- internalBitmap?.recycle(); internalBitmap = null
155
- renderNode.discardDisplayList()
156
- initialized = false
157
- initBlur()
168
+ readyBitmap = null
169
+ workerHandler.post { releaseBitmapPool() }
170
+ scheduleFrame()
158
171
  }
159
172
  }
160
173
 
161
- // ── Multi-window safety ───────────────────────────────────────────────────
162
-
163
174
  override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
164
175
  super.onWindowFocusChanged(hasWindowFocus)
165
176
  if (hasWindowFocus && blurEnabled && autoUpdate) {
@@ -168,114 +179,43 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
168
179
  }
169
180
  }
170
181
 
171
- private fun safeAddPreDrawListener() {
172
- val root = blurRoot ?: return
173
- val vto = root.viewTreeObserver
174
- vto.removeOnPreDrawListener(preDrawListener)
175
- if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
176
- }
177
-
178
- // ── Init blur ─────────────────────────────────────────────────────────────
179
-
180
- private fun initBlur() {
181
- val w = measuredWidth; if (w <= 0) return
182
- val h = measuredHeight; if (h <= 0) return
183
- internalBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
184
- renderNode.setPosition(0, 0, w, h)
185
- initialized = true
186
- updateBlur()
187
- }
188
-
189
- // ── Core: capture + blur + render ─────────────────────────────────────────
190
-
191
- private fun updateBlur() {
192
- if (!blurEnabled || !initialized) return
193
- val root = blurRoot ?: return
194
- val bitmap = internalBitmap ?: return
195
- if (bitmap.isRecycled) return
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
208
- val captureCanvas = Canvas(bitmap)
209
- captureCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
210
- captureCanvas.translate(-offsetX, -offsetY)
211
- try {
212
- root.draw(captureCanvas)
213
- } catch (_: Exception) {
214
- isCapturing = false
215
- return
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)
223
- val nodeCanvas = renderNode.beginRecording()
224
- nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
225
- renderNode.endRecording()
226
-
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
231
- val radius = blurRadiusFromAmount(blurAmount)
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
- )
246
-
247
- invalidate()
248
- }
249
-
250
- // ── draw() override — no-op during capture ────────────────────────────────
182
+ // ── draw() — no-op during root capture ────────────────────────────────────
251
183
  //
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.
184
+ // Prevents stale blur output from being captured into the background bitmap.
185
+ // When isCapturing=true, root.draw() is in progress we skip ourselves
186
+ // so only the content BEHIND us is captured.
256
187
 
257
188
  override fun draw(canvas: Canvas) {
258
- if (isCapturing) return // skip self during root capture
189
+ if (isCapturing) return
259
190
  super.draw(canvas)
260
191
  }
261
192
 
262
193
  // ── onDraw ────────────────────────────────────────────────────────────────
263
194
 
264
195
  override fun onDraw(canvas: Canvas) {
265
- if (!blurEnabled || !initialized) return
196
+ if (!blurEnabled) return
266
197
  val w = width.toFloat(); if (w <= 0f) return
267
198
  val h = height.toFloat(); if (h <= 0f) return
268
- if (!renderNode.hasDisplayList()) return
269
199
 
270
- // Progressive mask requires a saved layer so DST_IN mask composites correctly
200
+ val bmp = readyBitmap?.takeIf { !it.isRecycled } ?: run {
201
+ // No blur ready yet — draw overlay only so view isn't invisible
202
+ if (Color.alpha(overlayColor) > 0) {
203
+ overlayPaint.color = overlayColor
204
+ canvas.drawRect(0f, 0f, w, h, overlayPaint)
205
+ }
206
+ super.onDraw(canvas)
207
+ return
208
+ }
209
+
210
+ // Step 1: progressive mask layer
271
211
  val saveCount = if (progressiveDirection != PROGRESSIVE_NONE) {
272
212
  canvas.saveLayer(0f, 0f, w, h, null)
273
213
  } else -1
274
214
 
275
- // Draw GPU-blurred + tinted result from RenderNode
276
- canvas.drawRenderNode(renderNode)
215
+ // Step 2: draw blurred bitmap
216
+ canvas.drawBitmap(bmp, null, RectF(0f, 0f, w, h), bitmapPaint)
277
217
 
278
- // Progressive alpha mask — fades blur across the view
218
+ // Step 3: progressive alpha mask
279
219
  if (progressiveDirection != PROGRESSIVE_NONE && saveCount >= 0) {
280
220
  buildProgressiveShader(w, h)?.let { shader ->
281
221
  maskPaint.shader = shader
@@ -284,18 +224,122 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
284
224
  canvas.restoreToCount(saveCount)
285
225
  }
286
226
 
287
- // Noise grain overlay
227
+ // Step 4: overlay tint
228
+ if (Color.alpha(overlayColor) > 0) {
229
+ overlayPaint.color = overlayColor
230
+ canvas.drawRect(0f, 0f, w, h, overlayPaint)
231
+ }
232
+
233
+ // Step 5: noise grain
288
234
  noiseBitmap?.takeIf { !it.isRecycled && noiseFactor > 0f }?.let { noise ->
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)
235
+ noisePaint.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
236
+ noisePaint.shader = BitmapShader(noise, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
237
+ canvas.drawRect(0f, 0f, w, h, noisePaint)
292
238
  }
293
239
 
294
- // Let ReactViewGroup draw borders/background on top (handles borderRadius
295
- // and all other RN style props natively — no conflict with our blur)
240
+ // Step 6: let ReactViewGroup draw borders/radius on top
296
241
  super.onDraw(canvas)
297
242
  }
298
243
 
244
+ // ── Capture + blur pipeline ───────────────────────────────────────────────
245
+
246
+ private fun captureAndBlur() {
247
+ if (isCapturing) return
248
+ val root = blurRoot ?: return
249
+ val rw = root.width; if (rw <= 0) return
250
+ val rh = root.height; if (rh <= 0) return
251
+ val vw = width; if (vw <= 0) return
252
+ val vh = height; if (vh <= 0) return
253
+
254
+ val sw = (vw / DOWNSAMPLE).toInt().coerceAtLeast(1)
255
+ val sh = (vh / DOWNSAMPLE).toInt().coerceAtLeast(1)
256
+
257
+ // Compute offset — window coords, correct for split-screen/freeform/PiP
258
+ root.getLocationInWindow(rootLoc)
259
+ getLocationInWindow(myLoc)
260
+ val offsetX = (myLoc[0] - rootLoc[0]).toFloat()
261
+ val offsetY = (myLoc[1] - rootLoc[1]).toFloat()
262
+
263
+ val capture = reuseBitmap(captureBitmap, vw, vh).also { captureBitmap = it }
264
+ val scaled = reuseBitmap(scaledBitmap, sw, sh).also { scaledBitmap = it }
265
+
266
+ // Capture — isCapturing suppresses our own draw() so root.draw() skips us
267
+ isCapturing = true
268
+ val c = Canvas(capture)
269
+ c.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
270
+ c.translate(-offsetX, -offsetY)
271
+ try {
272
+ root.draw(c)
273
+ } catch (_: Exception) {
274
+ isCapturing = false
275
+ return
276
+ }
277
+ isCapturing = false
278
+
279
+ // Downsample + blur on worker thread (never blocks main/RenderThread)
280
+ val captureRef = capture
281
+ val scaledRef = scaled
282
+ val radius = blurRadiusFromAmount(blurAmount)
283
+
284
+ workerHandler.post {
285
+ // Downsample
286
+ Canvas(scaledRef).drawBitmap(
287
+ captureRef,
288
+ Rect(0, 0, captureRef.width, captureRef.height),
289
+ Rect(0, 0, scaledRef.width, scaledRef.height),
290
+ capturePaint
291
+ )
292
+ // Multi-pass blur for wide frosted-glass spread
293
+ repeat(BLUR_ROUNDS) { blurBitmap(scaledRef, radius) }
294
+
295
+ // Atomic swap: readyBitmap is @Volatile — RenderThread sees new value immediately
296
+ // We never mutate scaledRef after this point until the next capture starts
297
+ readyBitmap = scaledRef
298
+
299
+ mainHandler.post { invalidate() }
300
+ }
301
+ }
302
+
303
+ // ── RenderScript blur ─────────────────────────────────────────────────────
304
+
305
+ @Suppress("DEPRECATION")
306
+ private fun initRenderScript() {
307
+ try {
308
+ rs = RenderScript.create(context)
309
+ blurScript = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
310
+ } catch (_: Exception) {}
311
+ }
312
+
313
+ @Suppress("DEPRECATION")
314
+ private fun blurBitmap(bmp: Bitmap, radius: Float) {
315
+ val r = rs ?: return softwareBlur(bmp, radius)
316
+ val sc = blurScript ?: return softwareBlur(bmp, radius)
317
+ try {
318
+ val iA = reuseAlloc(inAlloc, bmp, r).also { inAlloc = it }
319
+ val oA = reuseAlloc(outAlloc, bmp, r).also { outAlloc = it }
320
+ iA.copyFrom(bmp)
321
+ sc.setRadius(radius.coerceIn(1f, 25f))
322
+ sc.setInput(iA)
323
+ sc.forEach(oA)
324
+ oA.copyTo(bmp)
325
+ } catch (_: Exception) { softwareBlur(bmp, radius) }
326
+ }
327
+
328
+ private fun softwareBlur(bmp: Bitmap, radius: Float) {
329
+ val p = Paint(Paint.ANTI_ALIAS_FLAG).apply {
330
+ maskFilter = android.graphics.BlurMaskFilter(radius, android.graphics.BlurMaskFilter.Blur.NORMAL)
331
+ }
332
+ Canvas(bmp).drawBitmap(bmp, 0f, 0f, p)
333
+ }
334
+
335
+ @Suppress("DEPRECATION")
336
+ private fun releaseRenderScript() {
337
+ inAlloc?.destroy(); inAlloc = null
338
+ outAlloc?.destroy(); outAlloc = null
339
+ blurScript?.destroy(); blurScript = null
340
+ rs?.destroy(); rs = null
341
+ }
342
+
299
343
  // ── Progressive shader ────────────────────────────────────────────────────
300
344
 
301
345
  private fun buildProgressiveShader(w: Float, h: Float): Shader? {
@@ -319,8 +363,7 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
319
363
  val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
320
364
  val rng = Random(42)
321
365
  for (x in 0 until size) for (y in 0 until size) {
322
- val v = rng.nextInt(256)
323
- bmp.setPixel(x, y, Color.argb(255, v, v, v))
366
+ val v = rng.nextInt(256); bmp.setPixel(x, y, Color.argb(255, v, v, v))
324
367
  }
325
368
  noiseBitmap = bmp
326
369
  }
@@ -333,27 +376,17 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
333
376
 
334
377
  fun setOverlayColor(colorString: String?) {
335
378
  overlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
336
- scheduleFrame()
379
+ invalidate()
337
380
  }
338
381
 
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.
342
382
  fun applyBorderRadius(radiusDp: Float) {
343
383
  cornerRadiusPx = TypedValue.applyDimension(
344
384
  TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
345
385
  )
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
- }
351
- }
352
- clipToOutline = true
353
- } else {
354
- outlineProvider = ViewOutlineProvider.BACKGROUND
355
- clipToOutline = false
356
- }
386
+ // Only enable clipToOutline when radius > 0.
387
+ // Keeping it false when not needed avoids GPU clip stack issues
388
+ // when overflow:hidden is set on parent + Reanimated is animating.
389
+ clipToOutline = cornerRadiusPx > 0f
357
390
  invalidate()
358
391
  }
359
392
 
@@ -378,22 +411,17 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
378
411
  blurEnabled = enabled
379
412
  if (enabled) { safeAddPreDrawListener(); scheduleFrame() }
380
413
  else {
381
- blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
414
+ safeRemovePreDrawListener()
382
415
  Choreographer.getInstance().removeFrameCallback(frameCallback)
383
416
  frameScheduled = false
384
- renderNode.discardDisplayList()
417
+ readyBitmap = null
385
418
  invalidate()
386
419
  }
387
420
  }
388
421
 
389
422
  fun setAutoUpdate(update: Boolean) {
390
423
  autoUpdate = update
391
- if (update) safeAddPreDrawListener()
392
- else {
393
- blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
394
- Choreographer.getInstance().removeFrameCallback(frameCallback)
395
- frameScheduled = false
396
- }
424
+ if (update) safeAddPreDrawListener() else safeRemovePreDrawListener()
397
425
  }
398
426
 
399
427
  // ── Helpers ────────────────────────────────────────────────────────────────
@@ -405,15 +433,15 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
405
433
  }
406
434
  }
407
435
 
408
- private fun blurRadiusFromAmount(amount: Float): Float {
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
436
+ private fun safeAddPreDrawListener() {
437
+ val root = blurRoot ?: return
438
+ val vto = root.viewTreeObserver
439
+ vto.removeOnPreDrawListener(preDrawListener)
440
+ if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
441
+ }
442
+
443
+ private fun safeRemovePreDrawListener() {
444
+ blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
417
445
  }
418
446
 
419
447
  private fun findBlurRoot(): ViewGroup? {
@@ -430,6 +458,28 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
430
458
  return rootView as? ViewGroup
431
459
  }
432
460
 
461
+ private fun releaseBitmapPool() {
462
+ captureBitmap?.recycle(); captureBitmap = null
463
+ scaledBitmap?.recycle(); scaledBitmap = null
464
+ }
465
+
466
+ @Suppress("DEPRECATION")
467
+ private fun reuseBitmap(existing: Bitmap?, w: Int, h: Int): Bitmap {
468
+ if (existing != null && !existing.isRecycled
469
+ && existing.width == w && existing.height == h) return existing
470
+ existing?.recycle()
471
+ return Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
472
+ }
473
+
474
+ @Suppress("DEPRECATION")
475
+ private fun reuseAlloc(existing: Allocation?, src: Bitmap, rs: RenderScript): Allocation {
476
+ if (existing != null && existing.type.x == src.width && existing.type.y == src.height)
477
+ return existing
478
+ existing?.destroy()
479
+ return Allocation.createFromBitmap(rs, src,
480
+ Allocation.MipmapControl.MIPMAP_NONE, Allocation.USAGE_SCRIPT)
481
+ }
482
+
433
483
  private fun parseHexColor(s: String): Int? {
434
484
  val t = s.trim()
435
485
  if (t.equals("transparent", ignoreCase = true)) return Color.TRANSPARENT
@@ -452,9 +502,19 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
452
502
  } catch (_: NumberFormatException) { null }
453
503
  }
454
504
 
505
+ private fun blurRadiusFromAmount(amount: Float): Float {
506
+ // Linear 0→100 maps to 1→25 (RenderScript kernel max is 25).
507
+ // With BLUR_ROUNDS=4 passes the effective spread is radius × √4 = radius × 2,
508
+ // so blurAmount=100 gives effective spread of ~50px — properly frosted glass.
509
+ val t = amount.coerceIn(0f, 100f) / 100f
510
+ return (1f + t * 24f)
511
+ }
512
+
455
513
  override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
456
514
 
457
515
  companion object {
516
+ private const val DOWNSAMPLE = 2f // 1/4 pixels — higher quality than legacy
517
+ private const val BLUR_ROUNDS = 4 // 4 passes — wider Gaussian spread for API 31+
458
518
  const val PROGRESSIVE_NONE = 0
459
519
  const val PROGRESSIVE_TOP_TO_BOTTOM = 1
460
520
  const val PROGRESSIVE_BOTTOM_TO_TOP = 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-blur-vibe",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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",