react-native-blur-vibe 0.1.5 → 0.1.6
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/android/build.gradle
CHANGED
|
@@ -1,230 +1,256 @@
|
|
|
1
1
|
package com.blurvibe
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
-
import android.graphics.Bitmap
|
|
5
|
-
import android.graphics.Canvas
|
|
6
4
|
import android.graphics.Color
|
|
7
5
|
import android.graphics.Outline
|
|
8
|
-
import android.graphics.Paint
|
|
9
|
-
import android.graphics.Rect
|
|
10
|
-
import android.graphics.RectF
|
|
11
6
|
import android.util.TypedValue
|
|
12
7
|
import android.view.View
|
|
13
8
|
import android.view.ViewGroup
|
|
14
9
|
import android.view.ViewOutlineProvider
|
|
10
|
+
import android.view.ViewTreeObserver
|
|
15
11
|
import androidx.core.graphics.toColorInt
|
|
16
|
-
import com.
|
|
12
|
+
import com.qmdeve.blurview.base.BaseBlurViewGroup
|
|
13
|
+
import com.qmdeve.blurview.widget.BlurViewGroup
|
|
17
14
|
|
|
18
15
|
/**
|
|
19
|
-
* BlurVibeView —
|
|
16
|
+
* BlurVibeView — Android backdrop blur implementation
|
|
20
17
|
*
|
|
21
|
-
* Extends
|
|
22
|
-
*
|
|
18
|
+
* Extends QmBlurView's BlurViewGroup — a high-performance blur library
|
|
19
|
+
* that correctly implements CSS backdrop-filter: blur() semantics:
|
|
20
|
+
* - Blurs content BEHIND the view, not the view itself
|
|
21
|
+
* - Hardware accelerated via native blur algorithms
|
|
22
|
+
* - Handles scroll, animation, zIndex, absolute positioning correctly
|
|
23
|
+
* - Never causes draw loops or bitmap capture on the JS thread
|
|
23
24
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Uses reflection to redirect the blur capture root from the activity
|
|
26
|
+
* decor view to the nearest ReactRootView or react-native-screens Screen,
|
|
27
|
+
* preventing full-screen blur and navigation transition artifacts.
|
|
27
28
|
*
|
|
28
|
-
*
|
|
29
|
-
* onDraw(), then draws the overlay color on top.
|
|
29
|
+
* Credit: approach adapted from sbaiahmed1/react-native-blur
|
|
30
30
|
*/
|
|
31
|
-
class BlurVibeView(context: Context) :
|
|
31
|
+
class BlurVibeView(context: Context) : BlurViewGroup(context, null) {
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
private var currentBlurRadius = DEFAULT_BLUR_RADIUS
|
|
34
|
+
private var currentOverlayColor = Color.TRANSPARENT
|
|
35
|
+
private var currentCornerRadius = 0f
|
|
36
|
+
private var isBlurInitialized = false
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
private val overlayPaint = Paint()
|
|
48
|
-
private val srcRect = Rect()
|
|
49
|
-
private val dstRect = RectF()
|
|
50
|
-
|
|
51
|
-
// ── Init ───────────────────────────────────────────────────────────────────
|
|
38
|
+
companion object {
|
|
39
|
+
private const val DEFAULT_BLUR_RADIUS = 10f
|
|
40
|
+
private const val MIN_BLUR_AMOUNT = 0f
|
|
41
|
+
private const val MAX_BLUR_AMOUNT = 100f
|
|
42
|
+
private const val MAX_BLUR_RADIUS = 25f // QmBlurView Gaussian kernel designed for 0-25
|
|
43
|
+
|
|
44
|
+
// Maps 0–100 blurAmount to 0–25 QmBlurView radius range
|
|
45
|
+
private fun mapBlurAmountToRadius(amount: Float): Float {
|
|
46
|
+
val clamped = amount.coerceIn(MIN_BLUR_AMOUNT, MAX_BLUR_AMOUNT)
|
|
47
|
+
return (clamped / MAX_BLUR_AMOUNT) * MAX_BLUR_RADIUS
|
|
48
|
+
}
|
|
49
|
+
}
|
|
52
50
|
|
|
53
51
|
init {
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
super.setBackgroundColor(currentOverlayColor)
|
|
53
|
+
clipChildren = true
|
|
56
54
|
clipToOutline = true
|
|
55
|
+
blurRounds = 1 // was 5 — single pass is visually identical, 5x cheaper
|
|
56
|
+
super.setDownsampleFactor(8.0f) // was 6 — 1/64 pixel count, blur hides the difference
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
60
|
-
|
|
61
59
|
override fun onAttachedToWindow() {
|
|
62
60
|
super.onAttachedToWindow()
|
|
63
|
-
|
|
61
|
+
if (isBlurInitialized) return
|
|
62
|
+
swapBlurRootToOptimalAncestor()
|
|
63
|
+
initializeBlur()
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
override fun onDetachedFromWindow() {
|
|
67
|
-
coordinator?.unregister(this)
|
|
68
|
-
coordinator = null
|
|
69
67
|
super.onDetachedFromWindow()
|
|
68
|
+
isBlurInitialized = false
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
private var frameScheduled = false
|
|
73
72
|
|
|
74
|
-
private
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
val coord = BlurCaptureCoordinator.forRoot(root).also {
|
|
78
|
-
it.blurRadius = blurRadius
|
|
79
|
-
coordinator = it
|
|
80
|
-
}
|
|
81
|
-
coord.register(this)
|
|
73
|
+
private val frameCallback = android.view.Choreographer.FrameCallback {
|
|
74
|
+
frameScheduled = false
|
|
75
|
+
try { invalidate() } catch (_: Exception) {}
|
|
82
76
|
}
|
|
83
77
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Redirects QmBlurView's internal preDrawListener from the old root to [newRoot].
|
|
80
|
+
* Also wraps it in a Choreographer gate so blur work fires at most ONCE per vsync,
|
|
81
|
+
* even when many views invalidate simultaneously (scroll, animation, etc).
|
|
82
|
+
*/
|
|
83
|
+
private fun swapBlurRootToOptimalAncestor() {
|
|
84
|
+
val newRoot = findNearestScreenAncestor() ?: findNearestReactRootView() ?: return
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
val blurViewGroupClass = BlurViewGroup::class.java
|
|
88
|
+
val baseField = blurViewGroupClass.getDeclaredField("mBaseBlurViewGroup")
|
|
89
|
+
baseField.isAccessible = true
|
|
90
|
+
val baseBlurViewGroup = baseField.get(this) ?: return
|
|
91
|
+
|
|
92
|
+
val baseClass = BaseBlurViewGroup::class.java
|
|
93
|
+
|
|
94
|
+
val decorViewField = baseClass.getDeclaredField("mDecorView")
|
|
95
|
+
decorViewField.isAccessible = true
|
|
96
|
+
val oldDecorView = decorViewField.get(baseBlurViewGroup) as? View
|
|
97
|
+
|
|
98
|
+
val preDrawListenerField = baseClass.getDeclaredField("preDrawListener")
|
|
99
|
+
preDrawListenerField.isAccessible = true
|
|
100
|
+
val preDrawListener = preDrawListenerField.get(baseBlurViewGroup)
|
|
101
|
+
as? ViewTreeObserver.OnPreDrawListener
|
|
102
|
+
|
|
103
|
+
if (oldDecorView != null && preDrawListener != null) {
|
|
104
|
+
// Remove listener from old root
|
|
105
|
+
oldDecorView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
|
|
106
|
+
|
|
107
|
+
// Set new root
|
|
108
|
+
decorViewField.set(baseBlurViewGroup, newRoot)
|
|
109
|
+
|
|
110
|
+
// Wrap in Choreographer gate: fires at most once per vsync regardless of
|
|
111
|
+
// how many child invalidations happen in the same frame
|
|
112
|
+
val gatedListener = ViewTreeObserver.OnPreDrawListener {
|
|
113
|
+
if (!frameScheduled) {
|
|
114
|
+
frameScheduled = true
|
|
115
|
+
android.view.Choreographer.getInstance().postFrameCallback(frameCallback)
|
|
116
|
+
}
|
|
117
|
+
true // never block the draw pass
|
|
118
|
+
}
|
|
91
119
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
val root = findBlurRoot() ?: return
|
|
120
|
+
// Add gated listener to new root (NOT the original raw listener)
|
|
121
|
+
newRoot.viewTreeObserver.addOnPreDrawListener(gatedListener)
|
|
95
122
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
// Update mDifferentRoot flag
|
|
124
|
+
val differentRootField = baseClass.getDeclaredField("mDifferentRoot")
|
|
125
|
+
differentRootField.isAccessible = true
|
|
126
|
+
differentRootField.setBoolean(baseBlurViewGroup, newRoot.rootView != this.rootView)
|
|
99
127
|
|
|
100
|
-
|
|
101
|
-
|
|
128
|
+
// Force redraw
|
|
129
|
+
val forceRedrawField = baseClass.getDeclaredField("mForceRedraw")
|
|
130
|
+
forceRedrawField.isAccessible = true
|
|
131
|
+
forceRedrawField.setBoolean(baseBlurViewGroup, true)
|
|
132
|
+
}
|
|
133
|
+
} catch (e: Exception) {
|
|
134
|
+
// Reflection failed — QmBlurView internals changed
|
|
135
|
+
// Fall back gracefully to default decor view blur root
|
|
136
|
+
}
|
|
137
|
+
}
|
|
102
138
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
139
|
+
private fun findNearestScreenAncestor(): ViewGroup? {
|
|
140
|
+
var current = parent
|
|
141
|
+
while (current != null) {
|
|
142
|
+
if (current.javaClass.name == "com.swmansion.rnscreens.Screen") {
|
|
143
|
+
return current as? ViewGroup
|
|
144
|
+
}
|
|
145
|
+
current = current.parent
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
112
149
|
|
|
113
|
-
|
|
150
|
+
private fun findNearestReactRootView(): ViewGroup? {
|
|
151
|
+
var current = parent
|
|
152
|
+
while (current != null) {
|
|
153
|
+
if (current.javaClass.name == "com.facebook.react.ReactRootView") {
|
|
154
|
+
return current as? ViewGroup
|
|
155
|
+
}
|
|
156
|
+
current = current.parent
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
114
160
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
161
|
+
private fun initializeBlur() {
|
|
162
|
+
if (isBlurInitialized) return
|
|
163
|
+
try {
|
|
164
|
+
super.setBlurRadius(currentBlurRadius)
|
|
165
|
+
super.setOverlayColor(currentOverlayColor)
|
|
166
|
+
updateCornerRadius()
|
|
167
|
+
isBlurInitialized = true
|
|
168
|
+
} catch (e: Exception) {
|
|
169
|
+
// Ignore — view may not be fully attached yet
|
|
118
170
|
}
|
|
119
171
|
}
|
|
120
172
|
|
|
121
|
-
//
|
|
173
|
+
// MARK: - Public setters
|
|
122
174
|
|
|
123
175
|
fun setBlurAmount(amount: Float) {
|
|
124
|
-
|
|
125
|
-
|
|
176
|
+
currentBlurRadius = mapBlurAmountToRadius(amount)
|
|
177
|
+
try { super.setBlurRadius(currentBlurRadius) } catch (e: Exception) {}
|
|
126
178
|
}
|
|
127
179
|
|
|
128
|
-
fun
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
fun setOverlayColor(colorString: String?) {
|
|
181
|
+
currentOverlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
|
|
182
|
+
try {
|
|
183
|
+
super.setBackgroundColor(currentOverlayColor)
|
|
184
|
+
super.setOverlayColor(currentOverlayColor)
|
|
185
|
+
} catch (e: Exception) {}
|
|
131
186
|
}
|
|
132
187
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
* Higher = faster + softer. Sets the global factor on the coordinator.
|
|
136
|
-
*/
|
|
137
|
-
fun applyBlurRadius(factor: Int) {
|
|
138
|
-
BlurCaptureCoordinator.DOWNSAMPLE_FACTOR = factor.coerceIn(2, 16).toFloat()
|
|
139
|
-
// re-attach so coordinator picks up new factor
|
|
140
|
-
attachToCoordinator()
|
|
188
|
+
fun setReducedTransparencyFallbackColor(colorString: String?) {
|
|
189
|
+
// Stored for future use — QmBlurView handles accessibility fallback internally
|
|
141
190
|
}
|
|
142
191
|
|
|
143
|
-
fun
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
)
|
|
147
|
-
updateOutline()
|
|
192
|
+
fun setBlurRadius(radius: Int) {
|
|
193
|
+
// blurRadius is the Android downscale factor — map to QmBlurView's downsample factor
|
|
194
|
+
val downsample = radius.coerceIn(1, 8).toFloat()
|
|
195
|
+
try { super.setDownsampleFactor(downsample) } catch (e: Exception) {}
|
|
148
196
|
}
|
|
149
197
|
|
|
150
|
-
fun
|
|
151
|
-
|
|
198
|
+
fun setBorderRadius(radius: Float) {
|
|
199
|
+
currentCornerRadius = radius
|
|
200
|
+
updateCornerRadius()
|
|
152
201
|
}
|
|
153
202
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
203
|
+
private fun updateCornerRadius() {
|
|
204
|
+
val radiusPx = TypedValue.applyDimension(
|
|
205
|
+
TypedValue.COMPLEX_UNIT_DIP,
|
|
206
|
+
currentCornerRadius,
|
|
207
|
+
context.resources.displayMetrics
|
|
208
|
+
)
|
|
209
|
+
outlineProvider = object : ViewOutlineProvider() {
|
|
210
|
+
override fun getOutline(view: View, outline: Outline) {
|
|
211
|
+
outline.setRoundRect(0, 0, view.width, view.height, radiusPx)
|
|
162
212
|
}
|
|
163
|
-
clipToOutline = true
|
|
164
|
-
} else {
|
|
165
|
-
outlineProvider = ViewOutlineProvider.BACKGROUND
|
|
166
|
-
clipToOutline = false
|
|
167
213
|
}
|
|
168
|
-
|
|
214
|
+
clipToOutline = true
|
|
215
|
+
try { super.setCornerRadius(radiusPx) } catch (e: Exception) {}
|
|
169
216
|
}
|
|
170
217
|
|
|
171
|
-
//
|
|
172
|
-
|
|
218
|
+
// React Native handles layout — prevent superclass from interfering
|
|
173
219
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
174
|
-
//
|
|
175
|
-
// but we must NOT call FrameLayout super here (ReactViewGroup handles it internally).
|
|
220
|
+
// No-op: layout handled by React Native's Yoga engine
|
|
176
221
|
}
|
|
177
222
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
while (p != null) {
|
|
186
|
-
if ((p as? View)?.javaClass?.name == "com.swmansion.rnscreens.Screen")
|
|
187
|
-
return p as? ViewGroup
|
|
188
|
-
p = (p as? View)?.parent
|
|
223
|
+
// MARK: - Color parser
|
|
224
|
+
// Supports: "transparent", "#RGB", "#RRGGBB", "#RRGGBBAA"
|
|
225
|
+
private fun parseHexColor(colorString: String): Int? {
|
|
226
|
+
val s = colorString.trim()
|
|
227
|
+
if (s.equals("transparent", ignoreCase = true)) return Color.TRANSPARENT
|
|
228
|
+
if (!s.startsWith("#")) {
|
|
229
|
+
return try { s.toColorInt() } catch (e: Exception) { null }
|
|
189
230
|
}
|
|
190
|
-
|
|
191
|
-
while (p != null) {
|
|
192
|
-
if ((p as? View)?.javaClass?.name == "com.facebook.react.ReactRootView")
|
|
193
|
-
return p as? ViewGroup
|
|
194
|
-
p = (p as? View)?.parent
|
|
195
|
-
}
|
|
196
|
-
return rootView as? ViewGroup
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ── Color parser ──────────────────────────────────────────────────────────
|
|
200
|
-
// Handles: "transparent", "#RGB", "#RRGGBB", "#RRGGBBAA", named colors
|
|
201
|
-
|
|
202
|
-
private fun parseHexColor(s: String): Int? {
|
|
203
|
-
val t = s.trim()
|
|
204
|
-
if (t.equals("transparent", ignoreCase = true)) return Color.TRANSPARENT
|
|
205
|
-
if (!t.startsWith("#")) return try { t.toColorInt() } catch (_: Exception) { null }
|
|
206
|
-
val hex = t.removePrefix("#")
|
|
231
|
+
val hex = s.removePrefix("#")
|
|
207
232
|
return try {
|
|
208
233
|
when (hex.length) {
|
|
209
|
-
3
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
234
|
+
3 -> Color.argb(
|
|
235
|
+
255,
|
|
236
|
+
hex[0].toString().repeat(2).toInt(16),
|
|
237
|
+
hex[1].toString().repeat(2).toInt(16),
|
|
238
|
+
hex[2].toString().repeat(2).toInt(16)
|
|
239
|
+
)
|
|
240
|
+
6 -> Color.argb(
|
|
241
|
+
255,
|
|
242
|
+
hex.substring(0, 2).toInt(16),
|
|
243
|
+
hex.substring(2, 4).toInt(16),
|
|
244
|
+
hex.substring(4, 6).toInt(16)
|
|
245
|
+
)
|
|
246
|
+
8 -> Color.argb(
|
|
247
|
+
hex.substring(6, 8).toInt(16), // AA is last in #RRGGBBAA
|
|
248
|
+
hex.substring(0, 2).toInt(16),
|
|
249
|
+
hex.substring(2, 4).toInt(16),
|
|
250
|
+
hex.substring(4, 6).toInt(16)
|
|
251
|
+
)
|
|
222
252
|
else -> null
|
|
223
253
|
}
|
|
224
|
-
} catch (
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
companion object {
|
|
228
|
-
private const val DEFAULT_BLUR_RADIUS = 2.5f // blurAmount=10 → 2.5
|
|
254
|
+
} catch (e: NumberFormatException) { null }
|
|
229
255
|
}
|
|
230
256
|
}
|
|
@@ -7,15 +7,8 @@ import com.facebook.react.uimanager.annotations.ReactProp
|
|
|
7
7
|
/**
|
|
8
8
|
* BlurVibeViewManager
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* setBlurAmount — unique, not in any supertype
|
|
14
|
-
* setBlurTypeProp — avoids any "setBlurType" conflict
|
|
15
|
-
* setOverlayColorProp — avoids BaseViewManager.setBackgroundColor etc.
|
|
16
|
-
* setReducedTransparencyFallbackColor — unique
|
|
17
|
-
* setBlurRadiusProp — avoids View.setRadius / BlurView.setBlurRadius
|
|
18
|
-
* setBlurBorderRadius — avoids BaseViewManager.setBorderRadius
|
|
10
|
+
* ViewGroupManager — BlurVibeView (which extends BlurViewGroup/FrameLayout)
|
|
11
|
+
* hosts React children, so we must use ViewGroupManager, not SimpleViewManager.
|
|
19
12
|
*/
|
|
20
13
|
class BlurVibeViewManager : ViewGroupManager<BlurVibeView>() {
|
|
21
14
|
|
|
@@ -24,29 +17,35 @@ class BlurVibeViewManager : ViewGroupManager<BlurVibeView>() {
|
|
|
24
17
|
override fun createViewInstance(context: ThemedReactContext) = BlurVibeView(context)
|
|
25
18
|
|
|
26
19
|
@ReactProp(name = "blurAmount", defaultFloat = 10f)
|
|
27
|
-
fun setBlurAmount(view: BlurVibeView, amount: Float)
|
|
20
|
+
fun setBlurAmount(view: BlurVibeView, amount: Float) {
|
|
28
21
|
view.setBlurAmount(amount)
|
|
22
|
+
}
|
|
29
23
|
|
|
30
24
|
@ReactProp(name = "blurType")
|
|
31
|
-
fun
|
|
32
|
-
//
|
|
25
|
+
fun setBlurType(view: BlurVibeView, type: String?) {
|
|
26
|
+
// No-op on Android — blurType maps to iOS UIBlurEffectStyle only
|
|
33
27
|
}
|
|
34
28
|
|
|
35
29
|
@ReactProp(name = "overlayColor")
|
|
36
|
-
fun
|
|
37
|
-
view.
|
|
30
|
+
fun setOverlayColor(view: BlurVibeView, color: String?) {
|
|
31
|
+
view.setOverlayColor(color)
|
|
32
|
+
}
|
|
38
33
|
|
|
39
34
|
@ReactProp(name = "reducedTransparencyFallbackColor")
|
|
40
|
-
fun setReducedTransparencyFallbackColor(view: BlurVibeView, color: String?)
|
|
35
|
+
fun setReducedTransparencyFallbackColor(view: BlurVibeView, color: String?) {
|
|
41
36
|
view.setReducedTransparencyFallbackColor(color)
|
|
37
|
+
}
|
|
42
38
|
|
|
43
39
|
@ReactProp(name = "blurRadius", defaultInt = 4)
|
|
44
|
-
fun
|
|
45
|
-
view.
|
|
40
|
+
fun setBlurRadius(view: BlurVibeView, radius: Int) {
|
|
41
|
+
view.setBlurRadius(radius)
|
|
42
|
+
}
|
|
46
43
|
|
|
47
44
|
@ReactProp(name = "borderRadius", defaultFloat = 0f)
|
|
48
|
-
fun setBlurBorderRadius(view: BlurVibeView, radius: Float)
|
|
49
|
-
view.
|
|
45
|
+
fun setBlurBorderRadius(view: BlurVibeView, radius: Float) {
|
|
46
|
+
view.setBorderRadius(radius)
|
|
47
|
+
}
|
|
50
48
|
|
|
49
|
+
// React Native's Yoga handles child layout — return false
|
|
51
50
|
override fun needsCustomLayoutForChildren(): Boolean = false
|
|
52
51
|
}
|
package/package.json
CHANGED
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
package com.blurvibe
|
|
2
|
-
|
|
3
|
-
import android.graphics.Bitmap
|
|
4
|
-
import android.graphics.Canvas
|
|
5
|
-
import android.graphics.Color
|
|
6
|
-
import android.graphics.Paint
|
|
7
|
-
import android.os.Build
|
|
8
|
-
import android.os.Handler
|
|
9
|
-
import android.os.HandlerThread
|
|
10
|
-
import android.os.Looper
|
|
11
|
-
import android.renderscript.Allocation
|
|
12
|
-
import android.renderscript.Element
|
|
13
|
-
import android.renderscript.RenderScript
|
|
14
|
-
import android.renderscript.ScriptIntrinsicBlur
|
|
15
|
-
import android.view.Choreographer
|
|
16
|
-
import android.view.ViewGroup
|
|
17
|
-
import android.view.ViewTreeObserver
|
|
18
|
-
import java.util.concurrent.CopyOnWriteArraySet
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* BlurCaptureCoordinator
|
|
22
|
-
*
|
|
23
|
-
* Singleton per root-view. Owns ONE preDrawListener, ONE bitmap capture, ONE blur pass
|
|
24
|
-
* per vsync — shared across ALL BlurVibeViews that point at the same root.
|
|
25
|
-
*
|
|
26
|
-
* Cost: O(1) per frame regardless of how many BlurVibeViews are mounted.
|
|
27
|
-
* Compare to naive per-view: O(N) per frame.
|
|
28
|
-
*
|
|
29
|
-
* Thread model:
|
|
30
|
-
* rootView.draw() → main thread (Android requires this)
|
|
31
|
-
* RenderScript blur → workerThread (non-blocking)
|
|
32
|
-
* onBlurReady() → main thread via mainHandler.post()
|
|
33
|
-
*/
|
|
34
|
-
internal class BlurCaptureCoordinator private constructor(
|
|
35
|
-
private val rootView: ViewGroup
|
|
36
|
-
) {
|
|
37
|
-
|
|
38
|
-
// registered BlurVibeViews — thread-safe, iterated on main thread
|
|
39
|
-
private val clients = CopyOnWriteArraySet<BlurVibeView>()
|
|
40
|
-
|
|
41
|
-
// bitmap pool — allocated once, reused every frame (zero GC)
|
|
42
|
-
private var captureBitmap: Bitmap? = null
|
|
43
|
-
private var scaledBitmap: Bitmap? = null
|
|
44
|
-
|
|
45
|
-
private val capturePaint = Paint(Paint.FILTER_BITMAP_FLAG)
|
|
46
|
-
|
|
47
|
-
// worker thread for blur (keeps main thread free)
|
|
48
|
-
private val workerThread = HandlerThread("BlurVibeWorker-${System.identityHashCode(rootView)}")
|
|
49
|
-
.also { it.start() }
|
|
50
|
-
private val workerHandler = Handler(workerThread.looper)
|
|
51
|
-
private val mainHandler = Handler(Looper.getMainLooper())
|
|
52
|
-
|
|
53
|
-
// RenderScript state (API < 31 only, created lazily on workerThread)
|
|
54
|
-
private var rs: RenderScript? = null
|
|
55
|
-
private var blurScript: ScriptIntrinsicBlur? = null
|
|
56
|
-
private var inputAlloc: Allocation? = null
|
|
57
|
-
private var outputAlloc: Allocation? = null
|
|
58
|
-
|
|
59
|
-
// blur params
|
|
60
|
-
var blurRadius: Float = 8f
|
|
61
|
-
set(value) { field = value.coerceIn(1f, 25f) }
|
|
62
|
-
var downsampleFactor: Float = DOWNSAMPLE_FACTOR
|
|
63
|
-
|
|
64
|
-
// frame gate — at most one capture queued at a time
|
|
65
|
-
private var frameScheduled = false
|
|
66
|
-
private val frameCallback = Choreographer.FrameCallback {
|
|
67
|
-
frameScheduled = false
|
|
68
|
-
captureAndBlur()
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ONE preDrawListener for the entire coordinator
|
|
72
|
-
private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
|
|
73
|
-
if (!frameScheduled) {
|
|
74
|
-
frameScheduled = true
|
|
75
|
-
Choreographer.getInstance().postFrameCallback(frameCallback)
|
|
76
|
-
}
|
|
77
|
-
true // never block the draw pass
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── Init / destroy ────────────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
init {
|
|
83
|
-
rootView.viewTreeObserver.addOnPreDrawListener(preDrawListener)
|
|
84
|
-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
|
85
|
-
workerHandler.post { initRenderScript() }
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
private fun destroy() {
|
|
90
|
-
rootView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
|
|
91
|
-
Choreographer.getInstance().removeFrameCallback(frameCallback)
|
|
92
|
-
cache.remove(rootView)
|
|
93
|
-
workerHandler.post {
|
|
94
|
-
inputAlloc?.destroy(); inputAlloc = null
|
|
95
|
-
outputAlloc?.destroy(); outputAlloc = null
|
|
96
|
-
blurScript?.destroy(); blurScript = null
|
|
97
|
-
rs?.destroy(); rs = null
|
|
98
|
-
captureBitmap?.recycle(); captureBitmap = null
|
|
99
|
-
scaledBitmap?.recycle(); scaledBitmap = null
|
|
100
|
-
workerThread.quitSafely()
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ── Registration ──────────────────────────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
fun register(view: BlurVibeView) {
|
|
107
|
-
clients.add(view)
|
|
108
|
-
// deliver cached result immediately so view doesn't flash blank
|
|
109
|
-
scaledBitmap?.takeIf { !it.isRecycled }?.let { view.onBlurReady(it) }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
fun unregister(view: BlurVibeView) {
|
|
113
|
-
clients.remove(view)
|
|
114
|
-
if (clients.isEmpty()) destroy()
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ── Capture + blur pipeline ───────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
private fun captureAndBlur() {
|
|
120
|
-
if (clients.isEmpty()) return
|
|
121
|
-
val w = rootView.width; if (w <= 0) return
|
|
122
|
-
val h = rootView.height; if (h <= 0) return
|
|
123
|
-
|
|
124
|
-
val factor = downsampleFactor
|
|
125
|
-
val scaledW = (w / factor).toInt().coerceAtLeast(1)
|
|
126
|
-
val scaledH = (h / factor).toInt().coerceAtLeast(1)
|
|
127
|
-
|
|
128
|
-
// allocate / reuse bitmaps
|
|
129
|
-
val capture = reuseBitmap(captureBitmap, w, h).also { captureBitmap = it }
|
|
130
|
-
val scaled = reuseBitmap(scaledBitmap, scaledW, scaledH).also { scaledBitmap = it }
|
|
131
|
-
|
|
132
|
-
// ① capture on main thread (required by Android)
|
|
133
|
-
try {
|
|
134
|
-
val c = Canvas(capture)
|
|
135
|
-
c.drawColor(Color.TRANSPARENT, android.graphics.PorterDuff.Mode.CLEAR)
|
|
136
|
-
rootView.draw(c)
|
|
137
|
-
} catch (_: Exception) { return }
|
|
138
|
-
|
|
139
|
-
val radius = blurRadius
|
|
140
|
-
val captureRef = capture
|
|
141
|
-
val scaledRef = scaled
|
|
142
|
-
|
|
143
|
-
// ② blur on worker thread
|
|
144
|
-
workerHandler.post {
|
|
145
|
-
// downsample
|
|
146
|
-
Canvas(scaledRef).drawBitmap(
|
|
147
|
-
captureRef,
|
|
148
|
-
android.graphics.Rect(0, 0, captureRef.width, captureRef.height),
|
|
149
|
-
android.graphics.Rect(0, 0, scaledRef.width, scaledRef.height),
|
|
150
|
-
capturePaint
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
// blur
|
|
154
|
-
val blurred = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
155
|
-
blurSoftware(scaledRef, radius) // BlurMaskFilter — fast enough at small size
|
|
156
|
-
} else {
|
|
157
|
-
blurRenderScript(scaledRef, radius) ?: blurSoftware(scaledRef, radius)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ③ deliver to all clients on main thread
|
|
161
|
-
mainHandler.post {
|
|
162
|
-
clients.forEach { it.onBlurReady(blurred) }
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ── Blur implementations ──────────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
private fun initRenderScript() {
|
|
170
|
-
try {
|
|
171
|
-
rs = RenderScript.create(rootView.context)
|
|
172
|
-
blurScript = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
|
|
173
|
-
} catch (_: Exception) {}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
private fun blurRenderScript(src: Bitmap, radius: Float): Bitmap? {
|
|
177
|
-
val r = this.rs ?: return null
|
|
178
|
-
val sc = this.blurScript ?: return null
|
|
179
|
-
return try {
|
|
180
|
-
val inA = reuseAlloc(inputAlloc, src, r).also { inputAlloc = it }
|
|
181
|
-
val outA = reuseAlloc(outputAlloc, src, r).also { outputAlloc = it }
|
|
182
|
-
inA.copyFrom(src)
|
|
183
|
-
sc.setRadius(radius)
|
|
184
|
-
sc.setInput(inA)
|
|
185
|
-
sc.forEach(outA)
|
|
186
|
-
val out = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888)
|
|
187
|
-
outA.copyTo(out)
|
|
188
|
-
out
|
|
189
|
-
} catch (_: Exception) { null }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
private fun blurSoftware(src: Bitmap, radius: Float): Bitmap {
|
|
193
|
-
val out = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888)
|
|
194
|
-
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
195
|
-
maskFilter = android.graphics.BlurMaskFilter(radius, android.graphics.BlurMaskFilter.Blur.NORMAL)
|
|
196
|
-
}
|
|
197
|
-
Canvas(out).drawBitmap(src, 0f, 0f, paint)
|
|
198
|
-
return out
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ── Bitmap / Allocation helpers ───────────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
private fun reuseBitmap(existing: Bitmap?, w: Int, h: Int): Bitmap {
|
|
204
|
-
if (existing != null && !existing.isRecycled
|
|
205
|
-
&& existing.width == w && existing.height == h) return existing
|
|
206
|
-
existing?.recycle()
|
|
207
|
-
return Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private fun reuseAlloc(existing: Allocation?, src: Bitmap, rs: RenderScript): Allocation {
|
|
211
|
-
if (existing != null
|
|
212
|
-
&& existing.type.x == src.width
|
|
213
|
-
&& existing.type.y == src.height) return existing
|
|
214
|
-
existing?.destroy()
|
|
215
|
-
return Allocation.createFromBitmap(rs, src,
|
|
216
|
-
Allocation.MipmapControl.MIPMAP_NONE, Allocation.USAGE_SCRIPT)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ── Singleton cache ───────────────────────────────────────────────────────
|
|
220
|
-
|
|
221
|
-
companion object {
|
|
222
|
-
/** Global downsample factor. Higher = faster + softer. Range 2–16. */
|
|
223
|
-
var DOWNSAMPLE_FACTOR: Float = 8f
|
|
224
|
-
|
|
225
|
-
private val cache = HashMap<ViewGroup, BlurCaptureCoordinator>()
|
|
226
|
-
|
|
227
|
-
fun forRoot(rootView: ViewGroup): BlurCaptureCoordinator =
|
|
228
|
-
cache.getOrPut(rootView) { BlurCaptureCoordinator(rootView) }
|
|
229
|
-
}
|
|
230
|
-
}
|