react-native-blur-vibe 0.1.11 → 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
- super.setBackgroundColor(Color.TRANSPARENT)
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
 
@@ -137,8 +109,10 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
137
109
  // ── Helpers ────────────────────────────────────────────────────────────────
138
110
 
139
111
  private fun mapBlurAmount(amount: Float): Float {
112
+ // Linear 0→1, 100→25 (RenderScript max kernel is 25)
113
+ // With 3 blur rounds this gives equivalent spread to Api31's 120px single-pass
140
114
  val t = amount.coerceIn(0f, 100f) / 100f
141
- return t * t * 25f
115
+ return (1f + t * 24f) // 1–25 linear, rounds=3 gives wide spread
142
116
  }
143
117
 
144
118
  private fun findBlurRoot(): ViewGroup? {
@@ -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,11 +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
- super.setBackgroundColor(Color.TRANSPARENT)
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
121
137
  }
122
138
 
123
139
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -127,34 +143,34 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
127
143
  blurRoot = findBlurRoot()
128
144
  safeAddPreDrawListener()
129
145
  generateNoiseBitmap()
130
- if (measuredWidth > 0 && measuredHeight > 0) initBlur()
146
+ workerHandler.post { initRenderScript() }
147
+ scheduleFrame()
131
148
  }
132
149
 
133
150
  override fun onDetachedFromWindow() {
134
- blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
151
+ safeRemovePreDrawListener()
135
152
  Choreographer.getInstance().removeFrameCallback(frameCallback)
136
153
  frameScheduled = false
137
- initialized = false
138
154
  isCapturing = false
139
155
  blurRoot = null
140
- noiseBitmap?.recycle(); noiseBitmap = null
141
- internalBitmap?.recycle(); internalBitmap = null
142
- renderNode.discardDisplayList()
156
+ readyBitmap = null
157
+ noiseBitmap?.recycle(); noiseBitmap = null
158
+ workerHandler.post {
159
+ releaseBitmapPool()
160
+ releaseRenderScript()
161
+ }
143
162
  super.onDetachedFromWindow()
144
163
  }
145
164
 
146
165
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
147
166
  super.onSizeChanged(w, h, oldw, oldh)
148
167
  if (w > 0 && h > 0) {
149
- internalBitmap?.recycle(); internalBitmap = null
150
- renderNode.discardDisplayList()
151
- initialized = false
152
- initBlur()
168
+ readyBitmap = null
169
+ workerHandler.post { releaseBitmapPool() }
170
+ scheduleFrame()
153
171
  }
154
172
  }
155
173
 
156
- // ── Multi-window safety ───────────────────────────────────────────────────
157
-
158
174
  override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
159
175
  super.onWindowFocusChanged(hasWindowFocus)
160
176
  if (hasWindowFocus && blurEnabled && autoUpdate) {
@@ -163,108 +179,43 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
163
179
  }
164
180
  }
165
181
 
166
- private fun safeAddPreDrawListener() {
167
- val root = blurRoot ?: return
168
- val vto = root.viewTreeObserver
169
- vto.removeOnPreDrawListener(preDrawListener)
170
- if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
171
- }
172
-
173
- // ── Init blur ─────────────────────────────────────────────────────────────
174
-
175
- private fun initBlur() {
176
- val w = measuredWidth; if (w <= 0) return
177
- val h = measuredHeight; if (h <= 0) return
178
- internalBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
179
- renderNode.setPosition(0, 0, w, h)
180
- initialized = true
181
- updateBlur()
182
- }
183
-
184
- // ── Core: capture + blur + render ─────────────────────────────────────────
185
-
186
- private fun updateBlur() {
187
- if (!blurEnabled || !initialized) return
188
- val root = blurRoot ?: return
189
- val bitmap = internalBitmap ?: return
190
- if (bitmap.isRecycled) return
191
-
192
- // ① Compute this view's offset within the root (window coords — correct
193
- // for all window modes: split-screen, freeform, PiP, DeX)
194
- root.getLocationInWindow(rootLocation)
195
- getLocationInWindow(myLocation)
196
- val offsetX = (myLocation[0] - rootLocation[0]).toFloat()
197
- val offsetY = (myLocation[1] - rootLocation[1]).toFloat()
198
-
199
- // ② Capture root content EXCLUDING this view.
200
- // isCapturing = true causes our draw() to be a no-op, so root.draw()
201
- // skips us and captures only the content behind us.
202
- isCapturing = true
203
- val captureCanvas = Canvas(bitmap)
204
- captureCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
205
- captureCanvas.translate(-offsetX, -offsetY)
206
- try {
207
- root.draw(captureCanvas)
208
- } catch (_: Exception) {
209
- isCapturing = false
210
- return
211
- }
212
- isCapturing = false
213
-
214
- // ③ Record bitmap into RenderNode.
215
- // Drawing a BITMAP into RenderNode is stable on all OEM drivers.
216
- // Drawing a RenderNode into another RenderNode's recording is NOT.
217
- renderNode.setPosition(0, 0, bitmap.width, bitmap.height)
218
- val nodeCanvas = renderNode.beginRecording()
219
- nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
220
- renderNode.endRecording()
221
-
222
- // ④ Apply GPU blur + tint as a chained RenderEffect (single GPU pass)
223
- val radius = blurRadiusFromAmount(blurAmount)
224
- val blurEffect = RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.MIRROR)
225
- renderNode.setRenderEffect(
226
- if (Color.alpha(overlayColor) > 0) {
227
- RenderEffect.createChainEffect(
228
- RenderEffect.createColorFilterEffect(
229
- BlendModeColorFilter(overlayColor, BlendMode.SRC_ATOP)
230
- ),
231
- blurEffect
232
- )
233
- } else blurEffect
234
- )
235
-
236
- invalidate()
237
- }
238
-
239
- // ── draw() override — no-op during capture ────────────────────────────────
182
+ // ── draw() — no-op during root capture ────────────────────────────────────
240
183
  //
241
- // When isCapturing = true (root.draw() is in progress capturing background),
242
- // suppress our own draw so we don't paint stale blur into the capture bitmap.
243
- // This makes us invisible to root.draw() during capture only —
244
- // NOT to the actual screen renderer.
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.
245
187
 
246
188
  override fun draw(canvas: Canvas) {
247
- if (isCapturing) return // skip self during root capture
189
+ if (isCapturing) return
248
190
  super.draw(canvas)
249
191
  }
250
192
 
251
193
  // ── onDraw ────────────────────────────────────────────────────────────────
252
194
 
253
195
  override fun onDraw(canvas: Canvas) {
254
- if (!blurEnabled || !initialized) return
196
+ if (!blurEnabled) return
255
197
  val w = width.toFloat(); if (w <= 0f) return
256
198
  val h = height.toFloat(); if (h <= 0f) return
257
- if (!renderNode.hasDisplayList()) return
258
199
 
259
- // 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
260
211
  val saveCount = if (progressiveDirection != PROGRESSIVE_NONE) {
261
212
  canvas.saveLayer(0f, 0f, w, h, null)
262
213
  } else -1
263
214
 
264
- // Draw GPU-blurred + tinted result from RenderNode
265
- canvas.drawRenderNode(renderNode)
215
+ // Step 2: draw blurred bitmap
216
+ canvas.drawBitmap(bmp, null, RectF(0f, 0f, w, h), bitmapPaint)
266
217
 
267
- // Progressive alpha mask — fades blur across the view
218
+ // Step 3: progressive alpha mask
268
219
  if (progressiveDirection != PROGRESSIVE_NONE && saveCount >= 0) {
269
220
  buildProgressiveShader(w, h)?.let { shader ->
270
221
  maskPaint.shader = shader
@@ -273,18 +224,122 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
273
224
  canvas.restoreToCount(saveCount)
274
225
  }
275
226
 
276
- // 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
277
234
  noiseBitmap?.takeIf { !it.isRecycled && noiseFactor > 0f }?.let { noise ->
278
- noisePaintFinal.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
279
- noisePaintFinal.shader = BitmapShader(noise, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
280
- canvas.drawRect(0f, 0f, w, h, noisePaintFinal)
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)
281
238
  }
282
239
 
283
- // Let ReactViewGroup draw borders/background on top (handles borderRadius
284
- // and all other RN style props natively — no conflict with our blur)
240
+ // Step 6: let ReactViewGroup draw borders/radius on top
285
241
  super.onDraw(canvas)
286
242
  }
287
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
+
288
343
  // ── Progressive shader ────────────────────────────────────────────────────
289
344
 
290
345
  private fun buildProgressiveShader(w: Float, h: Float): Shader? {
@@ -308,8 +363,7 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
308
363
  val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
309
364
  val rng = Random(42)
310
365
  for (x in 0 until size) for (y in 0 until size) {
311
- val v = rng.nextInt(256)
312
- 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))
313
367
  }
314
368
  noiseBitmap = bmp
315
369
  }
@@ -322,27 +376,17 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
322
376
 
323
377
  fun setOverlayColor(colorString: String?) {
324
378
  overlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
325
- scheduleFrame()
379
+ invalidate()
326
380
  }
327
381
 
328
- // borderRadius from JS style prop — handled natively by ReactViewGroup.
329
- // applyBorderRadius is called by our @ReactProp "borderRadius" binding.
330
- // We additionally set clipToOutline so the blur content is clipped correctly.
331
382
  fun applyBorderRadius(radiusDp: Float) {
332
383
  cornerRadiusPx = TypedValue.applyDimension(
333
384
  TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
334
385
  )
335
- if (cornerRadiusPx > 0f) {
336
- outlineProvider = object : ViewOutlineProvider() {
337
- override fun getOutline(view: View, outline: Outline) {
338
- outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx)
339
- }
340
- }
341
- clipToOutline = true
342
- } else {
343
- outlineProvider = ViewOutlineProvider.BACKGROUND
344
- clipToOutline = false
345
- }
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
346
390
  invalidate()
347
391
  }
348
392
 
@@ -367,22 +411,17 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
367
411
  blurEnabled = enabled
368
412
  if (enabled) { safeAddPreDrawListener(); scheduleFrame() }
369
413
  else {
370
- blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
414
+ safeRemovePreDrawListener()
371
415
  Choreographer.getInstance().removeFrameCallback(frameCallback)
372
416
  frameScheduled = false
373
- renderNode.discardDisplayList()
417
+ readyBitmap = null
374
418
  invalidate()
375
419
  }
376
420
  }
377
421
 
378
422
  fun setAutoUpdate(update: Boolean) {
379
423
  autoUpdate = update
380
- if (update) safeAddPreDrawListener()
381
- else {
382
- blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
383
- Choreographer.getInstance().removeFrameCallback(frameCallback)
384
- frameScheduled = false
385
- }
424
+ if (update) safeAddPreDrawListener() else safeRemovePreDrawListener()
386
425
  }
387
426
 
388
427
  // ── Helpers ────────────────────────────────────────────────────────────────
@@ -394,8 +433,16 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
394
433
  }
395
434
  }
396
435
 
397
- private fun blurRadiusFromAmount(amount: Float): Float =
398
- ((amount / 100f).let { it * it } * 25f).coerceIn(1f, 25f)
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)
445
+ }
399
446
 
400
447
  private fun findBlurRoot(): ViewGroup? {
401
448
  var p = parent
@@ -411,6 +458,28 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
411
458
  return rootView as? ViewGroup
412
459
  }
413
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
+
414
483
  private fun parseHexColor(s: String): Int? {
415
484
  val t = s.trim()
416
485
  if (t.equals("transparent", ignoreCase = true)) return Color.TRANSPARENT
@@ -433,9 +502,19 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
433
502
  } catch (_: NumberFormatException) { null }
434
503
  }
435
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
+
436
513
  override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
437
514
 
438
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+
439
518
  const val PROGRESSIVE_NONE = 0
440
519
  const val PROGRESSIVE_TOP_TO_BOTTOM = 1
441
520
  const val PROGRESSIVE_BOTTOM_TO_TOP = 2
@@ -33,9 +33,17 @@ internal class LegacyBlurController(
33
33
  ) {
34
34
 
35
35
  companion object {
36
- private const val DOWNSAMPLE_FACTOR = 4f
37
- private const val BLUR_RADIUS = 8f
38
- private const val BLUR_ROUNDS = 2
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
39
47
  }
40
48
 
41
49
  // ── Bitmap pool ────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-blur-vibe",
3
- "version": "0.1.11",
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",