react-native-blur-vibe 0.1.3 → 0.1.5
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
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
}
|
|
@@ -1,240 +1,230 @@
|
|
|
1
1
|
package com.blurvibe
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.Canvas
|
|
4
6
|
import android.graphics.Color
|
|
5
7
|
import android.graphics.Outline
|
|
8
|
+
import android.graphics.Paint
|
|
9
|
+
import android.graphics.Rect
|
|
10
|
+
import android.graphics.RectF
|
|
6
11
|
import android.util.TypedValue
|
|
7
12
|
import android.view.View
|
|
8
13
|
import android.view.ViewGroup
|
|
9
14
|
import android.view.ViewOutlineProvider
|
|
10
|
-
import android.view.ViewTreeObserver
|
|
11
15
|
import androidx.core.graphics.toColorInt
|
|
12
|
-
import com.
|
|
13
|
-
import com.qmdeve.blurview.widget.BlurViewGroup
|
|
16
|
+
import com.facebook.react.views.view.ReactViewGroup
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
|
-
* BlurVibeView —
|
|
19
|
+
* BlurVibeView — CSS backdrop-filter: blur() for React Native / Android
|
|
17
20
|
*
|
|
18
|
-
* Extends
|
|
19
|
-
*
|
|
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
|
|
21
|
+
* Extends ReactViewGroup so it can host React Native children correctly
|
|
22
|
+
* (Yoga layout, touch events, z-ordering all work out of the box).
|
|
24
23
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* Blur is produced by BlurCaptureCoordinator — a singleton per root view that
|
|
25
|
+
* captures + blurs the root ONCE per vsync and shares the result to every
|
|
26
|
+
* registered BlurVibeView. N blur views on screen = same cost as 1.
|
|
28
27
|
*
|
|
29
|
-
*
|
|
28
|
+
* Each view clips the shared blurred bitmap to its own screen-space rect in
|
|
29
|
+
* onDraw(), then draws the overlay color on top.
|
|
30
30
|
*/
|
|
31
|
-
class BlurVibeView(context: Context) :
|
|
31
|
+
class BlurVibeView(context: Context) : ReactViewGroup(context) {
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
private var currentOverlayColor = Color.TRANSPARENT
|
|
35
|
-
private var currentCornerRadius = 0f
|
|
36
|
-
private var isBlurInitialized = false
|
|
33
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
private var blurRadius = DEFAULT_BLUR_RADIUS
|
|
36
|
+
private var overlayColor = Color.TRANSPARENT
|
|
37
|
+
private var cornerRadiusPx = 0f
|
|
38
|
+
|
|
39
|
+
// ── Coordinator ───────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
private var coordinator: BlurCaptureCoordinator? = null
|
|
42
|
+
|
|
43
|
+
// ── Draw state (main thread only) ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
@Volatile private var latestBitmap: Bitmap? = null
|
|
46
|
+
private val bitmapPaint = Paint(Paint.FILTER_BITMAP_FLAG)
|
|
47
|
+
private val overlayPaint = Paint()
|
|
48
|
+
private val srcRect = Rect()
|
|
49
|
+
private val dstRect = RectF()
|
|
50
|
+
|
|
51
|
+
// ── Init ───────────────────────────────────────────────────────────────────
|
|
50
52
|
|
|
51
53
|
init {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
setWillNotDraw(false)
|
|
55
|
+
super.setBackgroundColor(Color.TRANSPARENT)
|
|
54
56
|
clipToOutline = true
|
|
55
|
-
blurRounds = 5
|
|
56
|
-
super.setDownsampleFactor(6.0f)
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
59
61
|
override fun onAttachedToWindow() {
|
|
60
62
|
super.onAttachedToWindow()
|
|
61
|
-
|
|
62
|
-
swapBlurRootToOptimalAncestor()
|
|
63
|
-
initializeBlur()
|
|
63
|
+
attachToCoordinator()
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
override fun onDetachedFromWindow() {
|
|
67
|
+
coordinator?.unregister(this)
|
|
68
|
+
coordinator = null
|
|
67
69
|
super.onDetachedFromWindow()
|
|
68
|
-
isBlurInitialized = false
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
* Uses reflection to redirect QmBlurView's internal blur capture root
|
|
73
|
-
* from the activity decor view to the nearest Screen or ReactRootView.
|
|
74
|
-
* This prevents the full-screen blur issue when BlurVibeView is used
|
|
75
|
-
* inside a ScrollView or with absolute positioning and zIndex.
|
|
76
|
-
*/
|
|
77
|
-
private fun swapBlurRootToOptimalAncestor() {
|
|
78
|
-
val newRoot = findNearestScreenAncestor() ?: findNearestReactRootView() ?: return
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
val blurViewGroupClass = BlurViewGroup::class.java
|
|
82
|
-
val baseField = blurViewGroupClass.getDeclaredField("mBaseBlurViewGroup")
|
|
83
|
-
baseField.isAccessible = true
|
|
84
|
-
val baseBlurViewGroup = baseField.get(this) ?: return
|
|
85
|
-
|
|
86
|
-
val baseClass = BaseBlurViewGroup::class.java
|
|
72
|
+
// ── Coordinator attachment ─────────────────────────────────────────────────
|
|
87
73
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
74
|
+
private fun attachToCoordinator() {
|
|
75
|
+
coordinator?.unregister(this)
|
|
76
|
+
val root = findBlurRoot() ?: return
|
|
77
|
+
val coord = BlurCaptureCoordinator.forRoot(root).also {
|
|
78
|
+
it.blurRadius = blurRadius
|
|
79
|
+
coordinator = it
|
|
80
|
+
}
|
|
81
|
+
coord.register(this)
|
|
82
|
+
}
|
|
91
83
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
84
|
+
/** Called by coordinator on main thread when a new blurred bitmap is ready. */
|
|
85
|
+
fun onBlurReady(bitmap: Bitmap) {
|
|
86
|
+
latestBitmap = bitmap
|
|
87
|
+
invalidate()
|
|
88
|
+
}
|
|
96
89
|
|
|
97
|
-
|
|
98
|
-
// Remove listener from old root
|
|
99
|
-
oldDecorView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
|
|
90
|
+
// ── Drawing ────────────────────────────────────────────────────────────────
|
|
100
91
|
|
|
101
|
-
|
|
102
|
-
|
|
92
|
+
override fun onDraw(canvas: Canvas) {
|
|
93
|
+
val bitmap = latestBitmap?.takeIf { !it.isRecycled } ?: return
|
|
94
|
+
val root = findBlurRoot() ?: return
|
|
103
95
|
|
|
104
|
-
|
|
105
|
-
|
|
96
|
+
// compute this view's offset within the blur root
|
|
97
|
+
val myLoc = IntArray(2); getLocationInWindow(myLoc)
|
|
98
|
+
val rootLoc = IntArray(2); root.getLocationInWindow(rootLoc)
|
|
106
99
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
differentRootField.isAccessible = true
|
|
110
|
-
differentRootField.setBoolean(baseBlurViewGroup, newRoot.rootView != this.rootView)
|
|
100
|
+
val l = myLoc[0] - rootLoc[0]
|
|
101
|
+
val t = myLoc[1] - rootLoc[1]
|
|
111
102
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
103
|
+
// the blurred bitmap is at 1/DOWNSAMPLE_FACTOR resolution — scale coords
|
|
104
|
+
val f = BlurCaptureCoordinator.DOWNSAMPLE_FACTOR
|
|
105
|
+
srcRect.set(
|
|
106
|
+
(l / f).toInt().coerceAtLeast(0),
|
|
107
|
+
(t / f).toInt().coerceAtLeast(0),
|
|
108
|
+
((l + width) / f).toInt().coerceAtMost(bitmap.width),
|
|
109
|
+
((t + height) / f).toInt().coerceAtMost(bitmap.height)
|
|
110
|
+
)
|
|
111
|
+
dstRect.set(0f, 0f, width.toFloat(), height.toFloat())
|
|
122
112
|
|
|
123
|
-
|
|
124
|
-
var current = parent
|
|
125
|
-
while (current != null) {
|
|
126
|
-
if (current.javaClass.name == "com.swmansion.rnscreens.Screen") {
|
|
127
|
-
return current as? ViewGroup
|
|
128
|
-
}
|
|
129
|
-
current = current.parent
|
|
130
|
-
}
|
|
131
|
-
return null
|
|
132
|
-
}
|
|
113
|
+
if (!srcRect.isEmpty) canvas.drawBitmap(bitmap, srcRect, dstRect, bitmapPaint)
|
|
133
114
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (current.javaClass.name == "com.facebook.react.ReactRootView") {
|
|
138
|
-
return current as? ViewGroup
|
|
139
|
-
}
|
|
140
|
-
current = current.parent
|
|
141
|
-
}
|
|
142
|
-
return null
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
private fun initializeBlur() {
|
|
146
|
-
if (isBlurInitialized) return
|
|
147
|
-
try {
|
|
148
|
-
super.setBlurRadius(currentBlurRadius)
|
|
149
|
-
super.setOverlayColor(currentOverlayColor)
|
|
150
|
-
updateCornerRadius()
|
|
151
|
-
isBlurInitialized = true
|
|
152
|
-
} catch (e: Exception) {
|
|
153
|
-
// Ignore — view may not be fully attached yet
|
|
115
|
+
if (Color.alpha(overlayColor) > 0) {
|
|
116
|
+
overlayPaint.color = overlayColor
|
|
117
|
+
canvas.drawRect(dstRect, overlayPaint)
|
|
154
118
|
}
|
|
155
119
|
}
|
|
156
120
|
|
|
157
|
-
//
|
|
121
|
+
// ── Public setters (ViewManager → UI thread) ──────────────────────────────
|
|
158
122
|
|
|
159
123
|
fun setBlurAmount(amount: Float) {
|
|
160
|
-
|
|
161
|
-
|
|
124
|
+
blurRadius = (amount.coerceIn(0f, 100f) / 100f) * 25f
|
|
125
|
+
coordinator?.blurRadius = blurRadius
|
|
162
126
|
}
|
|
163
127
|
|
|
164
|
-
fun
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
super.setBackgroundColor(currentOverlayColor)
|
|
168
|
-
super.setOverlayColor(currentOverlayColor)
|
|
169
|
-
} catch (e: Exception) {}
|
|
128
|
+
fun applyOverlayColor(colorString: String?) {
|
|
129
|
+
overlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
|
|
130
|
+
invalidate()
|
|
170
131
|
}
|
|
171
132
|
|
|
172
|
-
|
|
173
|
-
|
|
133
|
+
/**
|
|
134
|
+
* blurRadius prop: Android downsample factor (1–8).
|
|
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()
|
|
174
141
|
}
|
|
175
142
|
|
|
176
|
-
fun
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
143
|
+
fun applyBorderRadius(radiusDp: Float) {
|
|
144
|
+
cornerRadiusPx = TypedValue.applyDimension(
|
|
145
|
+
TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
|
|
146
|
+
)
|
|
147
|
+
updateOutline()
|
|
180
148
|
}
|
|
181
149
|
|
|
182
|
-
fun
|
|
183
|
-
|
|
184
|
-
updateCornerRadius()
|
|
150
|
+
fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") color: String?) {
|
|
151
|
+
// iOS-only concept — no-op on Android
|
|
185
152
|
}
|
|
186
153
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
outline.setRoundRect(0, 0, view.width, view.height, radiusPx)
|
|
154
|
+
// ── Corner radius / outline ────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
private fun updateOutline() {
|
|
157
|
+
if (cornerRadiusPx > 0f) {
|
|
158
|
+
outlineProvider = object : ViewOutlineProvider() {
|
|
159
|
+
override fun getOutline(view: View, outline: Outline) {
|
|
160
|
+
outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx)
|
|
161
|
+
}
|
|
196
162
|
}
|
|
163
|
+
clipToOutline = true
|
|
164
|
+
} else {
|
|
165
|
+
outlineProvider = ViewOutlineProvider.BACKGROUND
|
|
166
|
+
clipToOutline = false
|
|
197
167
|
}
|
|
198
|
-
|
|
199
|
-
try { super.setCornerRadius(radiusPx) } catch (e: Exception) {}
|
|
168
|
+
invalidate()
|
|
200
169
|
}
|
|
201
170
|
|
|
202
|
-
// React Native
|
|
171
|
+
// ── React Native layout passthrough ───────────────────────────────────────
|
|
172
|
+
|
|
203
173
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
204
|
-
//
|
|
174
|
+
// Yoga owns layout — calling super would run ReactViewGroup's layout which is correct,
|
|
175
|
+
// but we must NOT call FrameLayout super here (ReactViewGroup handles it internally).
|
|
205
176
|
}
|
|
206
177
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
178
|
+
// ── Blur root finder ──────────────────────────────────────────────────────
|
|
179
|
+
//
|
|
180
|
+
// Priority: react-native-screens Screen → ReactRootView → window root
|
|
181
|
+
// The root is what gets captured — use the narrowest stable ancestor.
|
|
182
|
+
|
|
183
|
+
private fun findBlurRoot(): ViewGroup? {
|
|
184
|
+
var p = parent
|
|
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
|
|
214
189
|
}
|
|
215
|
-
|
|
190
|
+
p = parent
|
|
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("#")
|
|
216
207
|
return try {
|
|
217
208
|
when (hex.length) {
|
|
218
|
-
3
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
hex.substring(6, 8).toInt(16), // AA is last in #RRGGBBAA
|
|
232
|
-
hex.substring(0, 2).toInt(16),
|
|
233
|
-
hex.substring(2, 4).toInt(16),
|
|
234
|
-
hex.substring(4, 6).toInt(16)
|
|
235
|
-
)
|
|
209
|
+
3 -> Color.argb(255,
|
|
210
|
+
hex[0].toString().repeat(2).toInt(16),
|
|
211
|
+
hex[1].toString().repeat(2).toInt(16),
|
|
212
|
+
hex[2].toString().repeat(2).toInt(16))
|
|
213
|
+
6 -> Color.argb(255,
|
|
214
|
+
hex.substring(0, 2).toInt(16),
|
|
215
|
+
hex.substring(2, 4).toInt(16),
|
|
216
|
+
hex.substring(4, 6).toInt(16))
|
|
217
|
+
8 -> Color.argb(
|
|
218
|
+
hex.substring(6, 8).toInt(16), // alpha LAST in #RRGGBBAA
|
|
219
|
+
hex.substring(0, 2).toInt(16),
|
|
220
|
+
hex.substring(2, 4).toInt(16),
|
|
221
|
+
hex.substring(4, 6).toInt(16))
|
|
236
222
|
else -> null
|
|
237
223
|
}
|
|
238
|
-
} catch (
|
|
224
|
+
} catch (_: NumberFormatException) { null }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
companion object {
|
|
228
|
+
private const val DEFAULT_BLUR_RADIUS = 2.5f // blurAmount=10 → 2.5
|
|
239
229
|
}
|
|
240
230
|
}
|
|
@@ -7,8 +7,15 @@ import com.facebook.react.uimanager.annotations.ReactProp
|
|
|
7
7
|
/**
|
|
8
8
|
* BlurVibeViewManager
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* All @ReactProp handler names are intentionally distinct from BaseViewManager /
|
|
11
|
+
* ReactViewGroup / View supertype methods to avoid "hides member" compile errors:
|
|
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
|
|
12
19
|
*/
|
|
13
20
|
class BlurVibeViewManager : ViewGroupManager<BlurVibeView>() {
|
|
14
21
|
|
|
@@ -17,34 +24,29 @@ class BlurVibeViewManager : ViewGroupManager<BlurVibeView>() {
|
|
|
17
24
|
override fun createViewInstance(context: ThemedReactContext) = BlurVibeView(context)
|
|
18
25
|
|
|
19
26
|
@ReactProp(name = "blurAmount", defaultFloat = 10f)
|
|
20
|
-
fun setBlurAmount(view: BlurVibeView, amount: Float)
|
|
27
|
+
fun setBlurAmount(view: BlurVibeView, amount: Float) =
|
|
21
28
|
view.setBlurAmount(amount)
|
|
22
|
-
}
|
|
23
29
|
|
|
24
30
|
@ReactProp(name = "blurType")
|
|
25
|
-
fun
|
|
26
|
-
//
|
|
31
|
+
fun setBlurTypeProp(view: BlurVibeView, @Suppress("UNUSED_PARAMETER") type: String?) {
|
|
32
|
+
// iOS UIBlurEffectStyle — no-op on Android
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
@ReactProp(name = "overlayColor")
|
|
30
|
-
fun
|
|
31
|
-
view.
|
|
32
|
-
}
|
|
36
|
+
fun setOverlayColorProp(view: BlurVibeView, color: String?) =
|
|
37
|
+
view.applyOverlayColor(color)
|
|
33
38
|
|
|
34
39
|
@ReactProp(name = "reducedTransparencyFallbackColor")
|
|
35
|
-
fun setReducedTransparencyFallbackColor(view: BlurVibeView, color: String?)
|
|
40
|
+
fun setReducedTransparencyFallbackColor(view: BlurVibeView, color: String?) =
|
|
36
41
|
view.setReducedTransparencyFallbackColor(color)
|
|
37
|
-
}
|
|
38
42
|
|
|
39
43
|
@ReactProp(name = "blurRadius", defaultInt = 4)
|
|
40
|
-
fun
|
|
41
|
-
view.
|
|
42
|
-
}
|
|
44
|
+
fun setBlurRadiusProp(view: BlurVibeView, radius: Int) =
|
|
45
|
+
view.applyBlurRadius(radius)
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
@ReactProp(name = "borderRadius", defaultFloat = 0f)
|
|
48
|
+
fun setBlurBorderRadius(view: BlurVibeView, radius: Float) =
|
|
49
|
+
view.applyBorderRadius(radius)
|
|
47
50
|
|
|
48
|
-
// React Native's Yoga handles child layout — return false
|
|
49
51
|
override fun needsCustomLayoutForChildren(): Boolean = false
|
|
50
52
|
}
|