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
|
-
|
|
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
|
[](https://www.npmjs.com/package/react-native-blur-vibe)
|
|
12
13
|
[](https://github.com/I-am-Pritam-20/react-native-blur-vibe/actions/workflows/build-ios.yml)
|
|
13
14
|
[](https://github.com/I-am-Pritam-20/react-native-blur-vibe/actions/workflows/build-android.yml)
|
|
14
15
|
[](https://opensource.org/licenses/MIT)
|
|
15
|
-
|
|
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"`)
|
|
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**
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
### `enabled`
|
|
197
|
+
|
|
198
|
+
| | |
|
|
199
|
+
|---|---|
|
|
200
|
+
| Type | `boolean` |
|
|
201
|
+
| Default | `true` |
|
|
202
|
+
| Platform | iOS + Android |
|
|
219
203
|
|
|
220
|
-
|
|
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={
|
|
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+**
|
|
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
|
|
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
|
|
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+**
|
|
289
|
+
| Platform | **iOS + Android API 31+** |
|
|
304
290
|
|
|
305
|
-
Noise grain overlay
|
|
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
|
|
311
|
-
| `0.15` | Noticeable grain
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
334
|
-
overlayColor="#
|
|
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}>
|
|
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
|
|
450
|
+
### Music player card — dark frosted glass
|
|
374
451
|
|
|
375
452
|
```tsx
|
|
376
453
|
<BlurView
|
|
377
454
|
blurAmount={60}
|
|
378
|
-
blurType="systemMaterial"
|
|
379
|
-
overlayColor="#00000050"
|
|
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
|
-
###
|
|
474
|
+
### Static background blur (best performance)
|
|
386
475
|
|
|
387
476
|
```tsx
|
|
388
|
-
//
|
|
477
|
+
// Capture once, never update — great for album art, splash screens
|
|
389
478
|
<BlurView
|
|
390
479
|
blurAmount={50}
|
|
391
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
39
|
-
private var overlayColor
|
|
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
|
-
// ──
|
|
60
|
+
// ── Double-buffer bitmap pool ─────────────────────────────────────────────
|
|
55
61
|
|
|
56
|
-
private var
|
|
57
|
-
private
|
|
62
|
+
private var captureBitmap: Bitmap? = null
|
|
63
|
+
private var scaledBitmap: Bitmap? = null
|
|
64
|
+
@Volatile private var readyBitmap: Bitmap? = null
|
|
58
65
|
|
|
59
|
-
|
|
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
|
-
|
|
69
|
+
// ── Worker thread — blur runs here, main thread never blocks ──────────────
|
|
80
70
|
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
private
|
|
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
|
|
92
|
-
private val
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
146
|
+
workerHandler.post { initRenderScript() }
|
|
147
|
+
scheduleFrame()
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
override fun onDetachedFromWindow() {
|
|
134
|
-
|
|
151
|
+
safeRemovePreDrawListener()
|
|
135
152
|
Choreographer.getInstance().removeFrameCallback(frameCallback)
|
|
136
153
|
frameScheduled = false
|
|
137
|
-
initialized = false
|
|
138
154
|
isCapturing = false
|
|
139
155
|
blurRoot = null
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
265
|
-
canvas.
|
|
215
|
+
// Step 2: draw blurred bitmap
|
|
216
|
+
canvas.drawBitmap(bmp, null, RectF(0f, 0f, w, h), bitmapPaint)
|
|
266
217
|
|
|
267
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
canvas.drawRect(0f, 0f, w, h,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
414
|
+
safeRemovePreDrawListener()
|
|
371
415
|
Choreographer.getInstance().removeFrameCallback(frameCallback)
|
|
372
416
|
frameScheduled = false
|
|
373
|
-
|
|
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
|
|
398
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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