react-native-blur-vibe 0.1.12 → 0.1.14

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
@@ -1,27 +1,26 @@
1
1
  # React Native Blur-Vibe
2
2
 
3
- <img width="1500" height="500" alt="github-banner" src="https://github.com/user-attachments/assets/78b2e5ec-5b57-48c0-b984-69cb57cbcf26" />
3
+ <a href="https://www.npmjs.com/package/react-native-blur-vibe"><img width="100%" height="35%" alt="github-banner" src="https://github.com/user-attachments/assets/78b2e5ec-5b57-48c0-b984-69cb57cbcf26" /></a>
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 both Old (Paper) and New (Fabric) Architecture 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,23 +3,25 @@ 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
10
+ import android.graphics.PixelFormat
13
11
  import android.graphics.PorterDuff
14
12
  import android.graphics.PorterDuffXfermode
15
13
  import android.graphics.RadialGradient
14
+ import android.graphics.Rect
16
15
  import android.graphics.RectF
17
- import android.graphics.RenderEffect
18
- import android.graphics.RenderNode
19
16
  import android.graphics.Shader
20
17
  import android.os.Build
18
+ import android.os.Handler
19
+ import android.os.HandlerThread
20
+ import android.os.Looper
21
21
  import android.util.TypedValue
22
22
  import android.view.Choreographer
23
+ import android.view.PixelCopy
24
+ import android.view.Surface
23
25
  import android.view.View
24
26
  import android.view.ViewGroup
25
27
  import android.view.ViewOutlineProvider
@@ -30,13 +32,16 @@ import com.facebook.react.views.view.ReactViewGroup
30
32
  import kotlin.math.min
31
33
  import kotlin.random.Random
32
34
 
35
+ /**
36
+ * BlurVibeViewApi31 — Backdrop blur for Android API 31+
37
+ **/
33
38
  @RequiresApi(Build.VERSION_CODES.S)
34
39
  class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
35
40
 
36
41
  // ── Blur params ────────────────────────────────────────────────────────────
37
42
 
38
- private var blurAmount = 10f
39
- private var overlayColor = Color.TRANSPARENT
43
+ private var blurAmount = 10f
44
+ private var overlayColor = Color.TRANSPARENT
40
45
  private var cornerRadiusPx = 0f
41
46
 
42
47
  // ── Progressive blur ──────────────────────────────────────────────────────
@@ -51,58 +56,46 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
51
56
  private var noiseBitmap: Bitmap? = null
52
57
  private val noisePaint = Paint()
53
58
 
54
- // ── Bitmap + RenderNode ────────────────────────────────────────────────────
55
-
56
- private var internalBitmap: Bitmap? = null
57
- private val renderNode = RenderNode("BlurVibeNode")
58
-
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.
59
+ // ── Bitmap double-buffer ──────────────────────────────────────────────────
68
60
  //
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.
61
+ // pixelCopyBitmap: written by PixelCopy (on its own callback thread)
62
+ // scaledBitmap: written by workerThread after downsampling
63
+ // readyBitmap: @Volatile RenderThread reads this in onDraw()
78
64
 
79
- private var isCapturing = false
65
+ private var pixelCopyBitmap: Bitmap? = null
66
+ private var scaledBitmap: Bitmap? = null
67
+ @Volatile private var readyBitmap: Bitmap? = null
80
68
 
81
- // ── Draw paints ───────────────────────────────────────────────────────────
69
+ private val capturePaint = Paint(Paint.FILTER_BITMAP_FLAG)
70
+ private val bitmapPaint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG)
82
71
 
83
- private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
84
- xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
85
- }
86
- private val noisePaintFinal = Paint()
72
+ // ── Worker thread ─────────────────────────────────────────────────────────
73
+
74
+ private val workerThread = HandlerThread("BlurVibeWorker31-${hashCode()}")
75
+ .also { it.start() }
76
+ private val workerHandler = Handler(workerThread.looper)
77
+ private val mainHandler = Handler(Looper.getMainLooper())
87
78
 
88
- // ── Root view ─────────────────────────────────────────────────────────────
79
+ // ── Root / window ─────────────────────────────────────────────────────────
89
80
 
90
- private var blurRoot: ViewGroup? = null
91
- private val myLocation = IntArray(2)
92
- private val rootLocation = IntArray(2)
81
+ private var blurRoot: ViewGroup? = null
82
+ private val myLoc = IntArray(2)
83
+ private val rootLoc = IntArray(2)
93
84
 
94
85
  // ── State ─────────────────────────────────────────────────────────────────
95
86
 
87
+ var isCapturing = false
88
+ private set
96
89
  private var blurEnabled = true
97
90
  private var autoUpdate = true
98
91
  private var frameScheduled = false
99
- private var initialized = false
92
+ private var pixelCopyInFlight = false
100
93
 
101
94
  // ── Choreographer gate ────────────────────────────────────────────────────
102
95
 
103
96
  private val frameCallback = Choreographer.FrameCallback {
104
97
  frameScheduled = false
105
- if (isAttachedToWindow && blurEnabled) updateBlur()
98
+ if (isAttachedToWindow && blurEnabled) captureAndBlur()
106
99
  }
107
100
 
108
101
  private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
@@ -113,16 +106,20 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
113
106
  true
114
107
  }
115
108
 
109
+ // ── Paint objects ─────────────────────────────────────────────────────────
110
+
111
+ private val overlayPaint = Paint(Paint.ANTI_ALIAS_FLAG)
112
+ private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
113
+ xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
114
+ }
115
+
116
116
  // ── Init ───────────────────────────────────────────────────────────────────
117
117
 
118
118
  init {
119
119
  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
- // ColorDrawable — destroying all style prop handling.
120
+ // outlineProvider = BACKGROUND: ReactViewBackgroundDrawable implements
121
+ // getOutline() correctly for all RN borderRadius variants automatically.
122
+ outlineProvider = ViewOutlineProvider.BACKGROUND
126
123
  }
127
124
 
128
125
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -132,34 +129,31 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
132
129
  blurRoot = findBlurRoot()
133
130
  safeAddPreDrawListener()
134
131
  generateNoiseBitmap()
135
- if (measuredWidth > 0 && measuredHeight > 0) initBlur()
132
+ scheduleFrame()
136
133
  }
137
134
 
138
135
  override fun onDetachedFromWindow() {
139
- blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
136
+ safeRemovePreDrawListener()
140
137
  Choreographer.getInstance().removeFrameCallback(frameCallback)
141
- frameScheduled = false
142
- initialized = false
143
- isCapturing = false
144
- blurRoot = null
145
- noiseBitmap?.recycle(); noiseBitmap = null
146
- internalBitmap?.recycle(); internalBitmap = null
147
- renderNode.discardDisplayList()
138
+ frameScheduled = false
139
+ isCapturing = false
140
+ pixelCopyInFlight = false
141
+ blurRoot = null
142
+ readyBitmap = null
143
+ noiseBitmap?.recycle(); noiseBitmap = null
144
+ workerHandler.post { releaseBitmapPool() }
148
145
  super.onDetachedFromWindow()
149
146
  }
150
147
 
151
148
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
152
149
  super.onSizeChanged(w, h, oldw, oldh)
153
150
  if (w > 0 && h > 0) {
154
- internalBitmap?.recycle(); internalBitmap = null
155
- renderNode.discardDisplayList()
156
- initialized = false
157
- initBlur()
151
+ readyBitmap = null
152
+ workerHandler.post { releaseBitmapPool() }
153
+ scheduleFrame()
158
154
  }
159
155
  }
160
156
 
161
- // ── Multi-window safety ───────────────────────────────────────────────────
162
-
163
157
  override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
164
158
  super.onWindowFocusChanged(hasWindowFocus)
165
159
  if (hasWindowFocus && blurEnabled && autoUpdate) {
@@ -168,114 +162,40 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
168
162
  }
169
163
  }
170
164
 
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 ────────────────────────────────
251
- //
252
- // When isCapturing = true (root.draw() is in progress capturing background),
253
- // suppress our own draw so we don't paint stale blur into the capture bitmap.
254
- // This makes us invisible to root.draw() during capture only —
255
- // NOT to the actual screen renderer.
165
+ // ── draw() — no-op during root capture ────────────────────────────────────
256
166
 
257
167
  override fun draw(canvas: Canvas) {
258
- if (isCapturing) return // skip self during root capture
168
+ if (isCapturing) return
259
169
  super.draw(canvas)
260
170
  }
261
171
 
262
172
  // ── onDraw ────────────────────────────────────────────────────────────────
263
173
 
264
174
  override fun onDraw(canvas: Canvas) {
265
- if (!blurEnabled || !initialized) return
175
+ if (!blurEnabled) return
266
176
  val w = width.toFloat(); if (w <= 0f) return
267
177
  val h = height.toFloat(); if (h <= 0f) return
268
- if (!renderNode.hasDisplayList()) return
269
178
 
270
- // Progressive mask requires a saved layer so DST_IN mask composites correctly
179
+ val bmp = readyBitmap?.takeIf { !it.isRecycled } ?: run {
180
+ // No blur ready yet — show overlay color as placeholder
181
+ if (Color.alpha(overlayColor) > 0) {
182
+ overlayPaint.color = overlayColor
183
+ canvas.drawRect(0f, 0f, w, h, overlayPaint)
184
+ }
185
+ // Redraw border on top even when no blur ready
186
+ background?.draw(canvas)
187
+ return
188
+ }
189
+
190
+ // Step 1: progressive mask layer
271
191
  val saveCount = if (progressiveDirection != PROGRESSIVE_NONE) {
272
192
  canvas.saveLayer(0f, 0f, w, h, null)
273
193
  } else -1
274
194
 
275
- // Draw GPU-blurred + tinted result from RenderNode
276
- canvas.drawRenderNode(renderNode)
195
+ // Step 2: blurred bitmap
196
+ canvas.drawBitmap(bmp, null, RectF(0f, 0f, w, h), bitmapPaint)
277
197
 
278
- // Progressive alpha mask — fades blur across the view
198
+ // Step 3: progressive alpha mask
279
199
  if (progressiveDirection != PROGRESSIVE_NONE && saveCount >= 0) {
280
200
  buildProgressiveShader(w, h)?.let { shader ->
281
201
  maskPaint.shader = shader
@@ -284,16 +204,262 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
284
204
  canvas.restoreToCount(saveCount)
285
205
  }
286
206
 
287
- // Noise grain overlay
207
+ // Step 4: overlay tint
208
+ if (Color.alpha(overlayColor) > 0) {
209
+ overlayPaint.color = overlayColor
210
+ canvas.drawRect(0f, 0f, w, h, overlayPaint)
211
+ }
212
+
213
+ // Step 5: noise grain
288
214
  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)
215
+ noisePaint.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
216
+ noisePaint.shader = BitmapShader(noise, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
217
+ canvas.drawRect(0f, 0f, w, h, noisePaint)
218
+ }
219
+ background?.draw(canvas)
220
+ }
221
+
222
+ // ── Capture pipeline — PixelCopy (API 31+) ────────────────────────────────
223
+
224
+ private fun captureAndBlur() {
225
+ if (isCapturing || pixelCopyInFlight) return
226
+ val root = blurRoot ?: return
227
+ val vw = width; if (vw <= 0) return
228
+ val vh = height; if (vh <= 0) return
229
+
230
+ // Compute this view's screen rect for PixelCopy
231
+ getLocationInWindow(myLoc)
232
+ root.getLocationInWindow(rootLoc)
233
+
234
+ // Screen-space rect of the CONTENT BEHIND this view (use root location
235
+ // as origin since PixelCopy works in window coordinates)
236
+ val srcRect = Rect(
237
+ myLoc[0], myLoc[1],
238
+ myLoc[0] + vw, myLoc[1] + vh
239
+ )
240
+
241
+ val sw = (vw / DOWNSAMPLE).toInt().coerceAtLeast(1)
242
+ val sh = (vh / DOWNSAMPLE).toInt().coerceAtLeast(1)
243
+
244
+ val destBitmap = reuseBitmap(pixelCopyBitmap, vw, vh)
245
+ .also { pixelCopyBitmap = it }
246
+
247
+ // Hide ourselves during PixelCopy so we capture ONLY content behind us
248
+ isCapturing = true
249
+ pixelCopyInFlight = true
250
+
251
+ val window = (context as? android.app.Activity)?.window
252
+ ?: run {
253
+ // Fallback to root.draw() if window not available
254
+ isCapturing = false
255
+ pixelCopyInFlight = false
256
+ captureWithRootDraw()
257
+ return
258
+ }
259
+
260
+ PixelCopy.request(
261
+ window,
262
+ srcRect,
263
+ destBitmap,
264
+ { result ->
265
+ isCapturing = false
266
+ pixelCopyInFlight = false
267
+
268
+ if (result != PixelCopy.SUCCESS) {
269
+ // PixelCopy failed — fall back to root.draw()
270
+ mainHandler.post { captureWithRootDraw() }
271
+ return@request
272
+ }
273
+
274
+ // Blur on worker thread
275
+ val captureRef = destBitmap
276
+ workerHandler.post {
277
+ val scaled = reuseBitmap(scaledBitmap, sw, sh).also { scaledBitmap = it }
278
+
279
+ // Downsample
280
+ Canvas(scaled).drawBitmap(
281
+ captureRef,
282
+ Rect(0, 0, captureRef.width, captureRef.height),
283
+ Rect(0, 0, scaled.width, scaled.height),
284
+ capturePaint
285
+ )
286
+
287
+ // Multi-pass software Gaussian blur
288
+ val radius = blurRadiusFromAmount(blurAmount)
289
+ repeat(BLUR_ROUNDS) { stackBlur(scaled, radius.toInt().coerceAtLeast(1)) }
290
+
291
+ readyBitmap = scaled
292
+ mainHandler.post { invalidate() }
293
+ }
294
+ },
295
+ mainHandler
296
+ )
297
+ }
298
+
299
+ // ── Fallback: root.draw() when PixelCopy unavailable ─────────────────────
300
+
301
+ private fun captureWithRootDraw() {
302
+ if (isCapturing) return
303
+ val root = blurRoot ?: return
304
+ val vw = width; if (vw <= 0) return
305
+ val vh = height; if (vh <= 0) return
306
+ val sw = (vw / DOWNSAMPLE).toInt().coerceAtLeast(1)
307
+ val sh = (vh / DOWNSAMPLE).toInt().coerceAtLeast(1)
308
+
309
+ root.getLocationInWindow(rootLoc)
310
+ getLocationInWindow(myLoc)
311
+ val offsetX = (myLoc[0] - rootLoc[0]).toFloat()
312
+ val offsetY = (myLoc[1] - rootLoc[1]).toFloat()
313
+
314
+ val capture = reuseBitmap(pixelCopyBitmap, vw, vh).also { pixelCopyBitmap = it }
315
+ val scaled = reuseBitmap(scaledBitmap, sw, sh).also { scaledBitmap = it }
316
+
317
+ isCapturing = true
318
+ val c = Canvas(capture)
319
+ c.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
320
+ c.translate(-offsetX, -offsetY)
321
+ try { root.draw(c) } catch (_: Exception) { isCapturing = false; return }
322
+ isCapturing = false
323
+
324
+ val captureRef = capture
325
+ workerHandler.post {
326
+ Canvas(scaled).drawBitmap(
327
+ captureRef,
328
+ Rect(0, 0, captureRef.width, captureRef.height),
329
+ Rect(0, 0, scaled.width, scaled.height),
330
+ capturePaint
331
+ )
332
+ val radius = blurRadiusFromAmount(blurAmount)
333
+ repeat(BLUR_ROUNDS) { stackBlur(scaled, radius.toInt().coerceAtLeast(1)) }
334
+ readyBitmap = scaled
335
+ mainHandler.post { invalidate() }
336
+ }
337
+ }
338
+
339
+ // ── Stack blur (pure Kotlin, no deprecated APIs) ──────────────────────────
340
+ //
341
+ // Mario Klingemann's StackBlur — O(w×h) regardless of radius.
342
+ // Fast, no RenderScript, works on all API levels, zero deprecation warnings.
343
+ // Used by many production apps including Facebook's Fresco library.
344
+ // radius clamped 1–254 (algorithm limit).
345
+
346
+ private fun stackBlur(bmp: Bitmap, radius: Int) {
347
+ val r = radius.coerceIn(1, 254)
348
+ val w = bmp.width
349
+ val h = bmp.height
350
+ val pixels = IntArray(w * h)
351
+ bmp.getPixels(pixels, 0, w, 0, 0, w, h)
352
+
353
+ val div = r + r + 1
354
+ val wm = w - 1
355
+ val hm = h - 1
356
+ val wh = w * h
357
+ val divSum = (div + 1) shr 1
358
+ val divSumSq = divSum * divSum
359
+ val dv = IntArray(256 * divSumSq) { it / divSumSq }
360
+
361
+ var yi = 0
362
+ val vmin = IntArray(maxOf(w, h))
363
+ val vmax = IntArray(maxOf(w, h))
364
+
365
+ val rStack = IntArray(div)
366
+ val gStack = IntArray(div)
367
+ val bStack = IntArray(div)
368
+
369
+ for (y in 0 until h) {
370
+ var rSum = 0; var gSum = 0; var bSum = 0
371
+ var rOut = 0; var gOut = 0; var bOut = 0
372
+
373
+ var p = pixels[yi]
374
+ var pr = (p shr 16) and 0xFF
375
+ var pg = (p shr 8) and 0xFF
376
+ var pb = p and 0xFF
377
+
378
+ for (i in 0 until divSum) {
379
+ rStack[i] = pr; gStack[i] = pg; bStack[i] = pb
380
+ rSum += pr * (i + 1); gSum += pg * (i + 1); bSum += pb * (i + 1)
381
+ rOut += pr; gOut += pg; bOut += pb
382
+ }
383
+ for (i in 1 until divSum) {
384
+ val ii = if (i <= wm) i else wm
385
+ p = pixels[yi + ii]
386
+ pr = (p shr 16) and 0xFF; pg = (p shr 8) and 0xFF; pb = p and 0xFF
387
+ rStack[i + r] = pr; gStack[i + r] = pg; bStack[i + r] = pb
388
+ rSum += pr * (divSum - i)
389
+ gSum += pg * (divSum - i)
390
+ bSum += pb * (divSum - i)
391
+ }
392
+
393
+ var si = r
394
+ for (x in 0 until w) {
395
+ pixels[yi + x] = (-0x1000000 or (dv[rSum] shl 16) or (dv[gSum] shl 8) or dv[bSum])
396
+ rSum -= rOut; gSum -= gOut; bSum -= bOut
397
+ rOut -= rStack[si]; gOut -= gStack[si]; bOut -= bStack[si]
398
+ var sip = si + divSum
399
+ if (sip >= div) sip -= div
400
+ pr = rStack[sip]; pg = gStack[sip]; pb = bStack[sip]
401
+ rOut += pr; gOut += pg; bOut += pb
402
+ rSum += rOut; gSum += gOut; bSum += bOut
403
+ if (x < r) vmin[x] = x + r + 1 else if (x + r < wm) vmin[x] = x + r + 1 else vmin[x] = wm
404
+ if (x > r) vmax[x] = x - r else vmax[x] = 0
405
+ val sp = pixels[yi + vmin[x]]
406
+ val vp = pixels[yi + vmax[x]]
407
+ rStack[sip] = (sp shr 16) and 0xFF
408
+ gStack[sip] = (sp shr 8) and 0xFF
409
+ bStack[sip] = sp and 0xFF
410
+ rOut += rStack[sip] - ((vp shr 16) and 0xFF)
411
+ gOut += gStack[sip] - ((vp shr 8) and 0xFF)
412
+ bOut += bStack[sip] - (vp and 0xFF)
413
+ if (++si >= div) si = 0
414
+ }
415
+ yi += w
292
416
  }
293
417
 
294
- // Let ReactViewGroup draw borders/background on top (handles borderRadius
295
- // and all other RN style props natively — no conflict with our blur)
296
- super.onDraw(canvas)
418
+ var xi = 0
419
+ for (x in 0 until w) {
420
+ var rSum = 0; var gSum = 0; var bSum = 0
421
+ var rOut = 0; var gOut = 0; var bOut = 0
422
+ var yp = -r * w
423
+ var p = pixels[xi]
424
+ var pr = (p shr 16) and 0xFF
425
+ var pg = (p shr 8) and 0xFF
426
+ var pb = p and 0xFF
427
+ for (i in 0 until divSum) {
428
+ rStack[i] = pr; gStack[i] = pg; bStack[i] = pb
429
+ rSum += pr * (i + 1); gSum += pg * (i + 1); bSum += pb * (i + 1)
430
+ rOut += pr; gOut += pg; bOut += pb
431
+ }
432
+ for (i in 1..r) {
433
+ if (i <= hm) yp += w
434
+ p = pixels[xi + yp]
435
+ pr = (p shr 16) and 0xFF; pg = (p shr 8) and 0xFF; pb = p and 0xFF
436
+ rStack[i + r] = pr; gStack[i + r] = pg; bStack[i + r] = pb
437
+ rSum += pr * (divSum - i); gSum += pg * (divSum - i); bSum += pb * (divSum - i)
438
+ }
439
+ var si = r
440
+ for (y in 0 until h) {
441
+ pixels[xi + y * w] = (-0x1000000 or (dv[rSum] shl 16) or (dv[gSum] shl 8) or dv[bSum])
442
+ rSum -= rOut; gSum -= gOut; bSum -= bOut
443
+ rOut -= rStack[si]; gOut -= gStack[si]; bOut -= bStack[si]
444
+ var sip = si + divSum; if (sip >= div) sip -= div
445
+ pr = rStack[sip]; pg = gStack[sip]; pb = bStack[sip]
446
+ rOut += pr; gOut += pg; bOut += pb
447
+ rSum += rOut; gSum += gOut; bSum += bOut
448
+ vmin[y] = if (y + r < hm) (y + r + 1) * w else hm * w
449
+ vmax[y] = if (y > r) (y - r) * w else 0
450
+ val sp = pixels[xi + vmin[y]]
451
+ val vp = pixels[xi + vmax[y]]
452
+ rStack[sip] = (sp shr 16) and 0xFF
453
+ gStack[sip] = (sp shr 8) and 0xFF
454
+ bStack[sip] = sp and 0xFF
455
+ rOut += rStack[sip] - ((vp shr 16) and 0xFF)
456
+ gOut += gStack[sip] - ((vp shr 8) and 0xFF)
457
+ bOut += bStack[sip] - (vp and 0xFF)
458
+ if (++si >= div) si = 0
459
+ }
460
+ xi++
461
+ }
462
+ bmp.setPixels(pixels, 0, w, 0, 0, w, h)
297
463
  }
298
464
 
299
465
  // ── Progressive shader ────────────────────────────────────────────────────
@@ -319,8 +485,7 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
319
485
  val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
320
486
  val rng = Random(42)
321
487
  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))
488
+ val v = rng.nextInt(256); bmp.setPixel(x, y, Color.argb(255, v, v, v))
324
489
  }
325
490
  noiseBitmap = bmp
326
491
  }
@@ -333,27 +498,14 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
333
498
 
334
499
  fun setOverlayColor(colorString: String?) {
335
500
  overlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
336
- scheduleFrame()
501
+ invalidate()
337
502
  }
338
503
 
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
504
  fun applyBorderRadius(radiusDp: Float) {
343
505
  cornerRadiusPx = TypedValue.applyDimension(
344
506
  TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
345
507
  )
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
- }
508
+ clipToOutline = cornerRadiusPx > 0f
357
509
  invalidate()
358
510
  }
359
511
 
@@ -378,22 +530,15 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
378
530
  blurEnabled = enabled
379
531
  if (enabled) { safeAddPreDrawListener(); scheduleFrame() }
380
532
  else {
381
- blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
533
+ safeRemovePreDrawListener()
382
534
  Choreographer.getInstance().removeFrameCallback(frameCallback)
383
- frameScheduled = false
384
- renderNode.discardDisplayList()
385
- invalidate()
535
+ frameScheduled = false; readyBitmap = null; invalidate()
386
536
  }
387
537
  }
388
538
 
389
539
  fun setAutoUpdate(update: Boolean) {
390
540
  autoUpdate = update
391
- if (update) safeAddPreDrawListener()
392
- else {
393
- blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
394
- Choreographer.getInstance().removeFrameCallback(frameCallback)
395
- frameScheduled = false
396
- }
541
+ if (update) safeAddPreDrawListener() else safeRemovePreDrawListener()
397
542
  }
398
543
 
399
544
  // ── Helpers ────────────────────────────────────────────────────────────────
@@ -405,15 +550,15 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
405
550
  }
406
551
  }
407
552
 
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
553
+ private fun safeAddPreDrawListener() {
554
+ val root = blurRoot ?: return
555
+ val vto = root.viewTreeObserver
556
+ vto.removeOnPreDrawListener(preDrawListener)
557
+ if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
558
+ }
559
+
560
+ private fun safeRemovePreDrawListener() {
561
+ blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
417
562
  }
418
563
 
419
564
  private fun findBlurRoot(): ViewGroup? {
@@ -430,6 +575,23 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
430
575
  return rootView as? ViewGroup
431
576
  }
432
577
 
578
+ private fun releaseBitmapPool() {
579
+ pixelCopyBitmap?.recycle(); pixelCopyBitmap = null
580
+ scaledBitmap?.recycle(); scaledBitmap = null
581
+ }
582
+
583
+ private fun reuseBitmap(existing: Bitmap?, w: Int, h: Int): Bitmap {
584
+ if (existing != null && !existing.isRecycled
585
+ && existing.width == w && existing.height == h) return existing
586
+ existing?.recycle()
587
+ return Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
588
+ }
589
+
590
+ private fun blurRadiusFromAmount(amount: Float): Float {
591
+ val t = amount.coerceIn(0f, 100f) / 100f
592
+ return (2f + t * 22f) // 2–24, StackBlur works well in this range per pass
593
+ }
594
+
433
595
  private fun parseHexColor(s: String): Int? {
434
596
  val t = s.trim()
435
597
  if (t.equals("transparent", ignoreCase = true)) return Color.TRANSPARENT
@@ -455,6 +617,8 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
455
617
  override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
456
618
 
457
619
  companion object {
620
+ private const val DOWNSAMPLE = 2f
621
+ private const val BLUR_ROUNDS = 3
458
622
  const val PROGRESSIVE_NONE = 0
459
623
  const val PROGRESSIVE_TOP_TO_BOTTOM = 1
460
624
  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.14",
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",