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="
|
|
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
|
-
|
|
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
|
[](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
|
|
|
@@ -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
|
|
39
|
-
private var overlayColor
|
|
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
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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
|
|
65
|
+
private var pixelCopyBitmap: Bitmap? = null
|
|
66
|
+
private var scaledBitmap: Bitmap? = null
|
|
67
|
+
@Volatile private var readyBitmap: Bitmap? = null
|
|
80
68
|
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
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
|
|
79
|
+
// ── Root / window ─────────────────────────────────────────────────────────
|
|
89
80
|
|
|
90
|
-
private var blurRoot:
|
|
91
|
-
private val
|
|
92
|
-
private val
|
|
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
|
|
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)
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
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
|
-
|
|
132
|
+
scheduleFrame()
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
override fun onDetachedFromWindow() {
|
|
139
|
-
|
|
136
|
+
safeRemovePreDrawListener()
|
|
140
137
|
Choreographer.getInstance().removeFrameCallback(frameCallback)
|
|
141
|
-
frameScheduled
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
blurRoot
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
276
|
-
canvas.
|
|
195
|
+
// Step 2: blurred bitmap
|
|
196
|
+
canvas.drawBitmap(bmp, null, RectF(0f, 0f, w, h), bitmapPaint)
|
|
277
197
|
|
|
278
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
canvas.drawRect(0f, 0f, w, h,
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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