react-native-blur-vibe 0.1.4 → 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,293 +1,203 @@
|
|
|
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
|
|
6
|
-
import android.
|
|
7
|
-
import android.
|
|
8
|
+
import android.graphics.Paint
|
|
9
|
+
import android.graphics.Rect
|
|
10
|
+
import android.graphics.RectF
|
|
8
11
|
import android.util.TypedValue
|
|
9
|
-
import android.view.Choreographer
|
|
10
12
|
import android.view.View
|
|
11
13
|
import android.view.ViewGroup
|
|
12
14
|
import android.view.ViewOutlineProvider
|
|
13
|
-
import android.view.ViewTreeObserver
|
|
14
15
|
import androidx.core.graphics.toColorInt
|
|
15
|
-
import com.
|
|
16
|
-
import com.qmdeve.blurview.widget.BlurViewGroup
|
|
16
|
+
import com.facebook.react.views.view.ReactViewGroup
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* BlurVibeView —
|
|
19
|
+
* BlurVibeView — CSS backdrop-filter: blur() for React Native / Android
|
|
20
20
|
*
|
|
21
|
-
*
|
|
21
|
+
* Extends ReactViewGroup so it can host React Native children correctly
|
|
22
|
+
* (Yoga layout, touch events, z-ordering all work out of the box).
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* to the human eye and costs 1/5 as much. Fixed: blurRounds = 1.
|
|
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.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* at radius=100 iterates a ~200-wide kernel per-pixel every frame.
|
|
31
|
-
* Fixed: map blurAmount 0–100 → radius 0–25.
|
|
32
|
-
*
|
|
33
|
-
* 3. OnPreDrawListener fires every frame with no throttling
|
|
34
|
-
* The listener was doing full blur work synchronously inside the pre-draw callback,
|
|
35
|
-
* blocking the draw thread on every invalidation of every child in the tree.
|
|
36
|
-
* Fixed: listener only sets a dirty flag; actual blur work is deferred to a
|
|
37
|
-
* Choreographer.FrameCallback which fires at most once-per-vsync.
|
|
38
|
-
*
|
|
39
|
-
* 4. preDrawListener leaked on re-attach
|
|
40
|
-
* Each call to onAttachedToWindow re-added the listener without removing the old one,
|
|
41
|
-
* multiplying the per-frame cost every time a modal or navigator re-mounted the view.
|
|
42
|
-
* Fixed: detachPreDrawListener() called before every re-attach.
|
|
43
|
-
*
|
|
44
|
-
* ─── Performance profile after fixes ─────────────────────────────────────────
|
|
45
|
-
*
|
|
46
|
-
* • blur cost reduced ~40× (5 rounds → 1, radius 100 → 25, gated to 1/vsync)
|
|
47
|
-
* • zero JS thread work (Choreographer callback runs on UI thread only)
|
|
48
|
-
* • zero GC pressure (no bitmap allocations on hot path)
|
|
49
|
-
* • works with: Modal, ScrollView, FlatList, FlashList, ImageBackground,
|
|
50
|
-
* Reanimated (both JS and UI thread), react-navigation transitions
|
|
28
|
+
* Each view clips the shared blurred bitmap to its own screen-space rect in
|
|
29
|
+
* onDraw(), then draws the overlay color on top.
|
|
51
30
|
*/
|
|
52
|
-
class BlurVibeView(context: Context) :
|
|
53
|
-
|
|
54
|
-
// ── Blur state ─────────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
private var pendingBlurRadius = DEFAULT_BLUR_RADIUS
|
|
57
|
-
private var currentOverlayColor = Color.TRANSPARENT
|
|
58
|
-
private var currentCornerRadius = 0f
|
|
59
|
-
private var isSetupDone = false
|
|
31
|
+
class BlurVibeView(context: Context) : ReactViewGroup(context) {
|
|
60
32
|
|
|
61
|
-
// ──
|
|
62
|
-
//
|
|
63
|
-
// OnPreDrawListener sets pendingFrame = true and returns immediately (never
|
|
64
|
-
// blocks). Choreographer fires frameCallback at the next vsync boundary,
|
|
65
|
-
// which calls invalidate() → QmBlurView captures + blurs + draws exactly once.
|
|
66
|
-
// pendingFrame prevents multiple queued callbacks stacking up.
|
|
33
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
67
34
|
|
|
68
|
-
private var
|
|
35
|
+
private var blurRadius = DEFAULT_BLUR_RADIUS
|
|
36
|
+
private var overlayColor = Color.TRANSPARENT
|
|
37
|
+
private var cornerRadiusPx = 0f
|
|
69
38
|
|
|
70
|
-
|
|
71
|
-
pendingFrame = false
|
|
72
|
-
if (isAttachedToWindow) triggerBlurUpdate()
|
|
73
|
-
}
|
|
39
|
+
// ── Coordinator ───────────────────────────────────────────────────────────
|
|
74
40
|
|
|
75
|
-
|
|
41
|
+
private var coordinator: BlurCaptureCoordinator? = null
|
|
76
42
|
|
|
77
|
-
|
|
43
|
+
// ── Draw state (main thread only) ─────────────────────────────────────────
|
|
78
44
|
|
|
79
|
-
private
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
true // MUST return true — false would block the entire frame draw pass
|
|
85
|
-
}
|
|
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()
|
|
86
50
|
|
|
87
|
-
// ── Init
|
|
51
|
+
// ── Init ───────────────────────────────────────────────────────────────────
|
|
88
52
|
|
|
89
53
|
init {
|
|
54
|
+
setWillNotDraw(false)
|
|
90
55
|
super.setBackgroundColor(Color.TRANSPARENT)
|
|
91
|
-
clipChildren = true
|
|
92
56
|
clipToOutline = true
|
|
93
|
-
|
|
94
|
-
// THE critical fix #1: 1 round instead of 5.
|
|
95
|
-
// A single Gaussian pass on a downsampled bitmap is perceptually identical
|
|
96
|
-
// to 5 passes and costs exactly 1/5 as much GPU/CPU time.
|
|
97
|
-
blurRounds = 1
|
|
98
|
-
|
|
99
|
-
// Aggressive downsample: capture at 1/8 resolution before blurring.
|
|
100
|
-
// The blur kernel smooths away all pixel-level detail so 1/8 is sufficient.
|
|
101
|
-
// This reduces the bitmap size 64× and the blur kernel work proportionally.
|
|
102
|
-
super.setDownsampleFactor(8f)
|
|
103
57
|
}
|
|
104
58
|
|
|
105
|
-
// ── Lifecycle
|
|
59
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
106
60
|
|
|
107
61
|
override fun onAttachedToWindow() {
|
|
108
62
|
super.onAttachedToWindow()
|
|
109
|
-
|
|
110
|
-
if (!isSetupDone) applyPendingBlurConfig()
|
|
63
|
+
attachToCoordinator()
|
|
111
64
|
}
|
|
112
65
|
|
|
113
66
|
override fun onDetachedFromWindow() {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
pendingFrame = false
|
|
117
|
-
isSetupDone = false
|
|
67
|
+
coordinator?.unregister(this)
|
|
68
|
+
coordinator = null
|
|
118
69
|
super.onDetachedFromWindow()
|
|
119
70
|
}
|
|
120
71
|
|
|
121
|
-
// ──
|
|
72
|
+
// ── Coordinator attachment ─────────────────────────────────────────────────
|
|
122
73
|
|
|
123
|
-
private fun
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
val
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
attachedRoot = root
|
|
132
|
-
root.viewTreeObserver.addOnPreDrawListener(preDrawListener)
|
|
133
|
-
redirectQmBlurCaptureRoot(root)
|
|
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)
|
|
134
82
|
}
|
|
135
83
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
84
|
+
/** Called by coordinator on main thread when a new blurred bitmap is ready. */
|
|
85
|
+
fun onBlurReady(bitmap: Bitmap) {
|
|
86
|
+
latestBitmap = bitmap
|
|
87
|
+
invalidate()
|
|
139
88
|
}
|
|
140
89
|
|
|
141
|
-
|
|
142
|
-
* Redirects QmBlurView's internal bitmap-capture root (mDecorView) to [newRoot]
|
|
143
|
-
* via reflection. This scopes QmBlurView's capture to the chosen subtree instead
|
|
144
|
-
* of the full activity decor view — smaller captures = faster blur.
|
|
145
|
-
*
|
|
146
|
-
* We do NOT mirror QmBlurView's internal preDrawListener. We own the invalidation
|
|
147
|
-
* cycle via our own Choreographer-gated listener above.
|
|
148
|
-
*/
|
|
149
|
-
private fun redirectQmBlurCaptureRoot(newRoot: ViewGroup) {
|
|
150
|
-
try {
|
|
151
|
-
val baseField = BlurViewGroup::class.java.getDeclaredField("mBaseBlurViewGroup")
|
|
152
|
-
baseField.isAccessible = true
|
|
153
|
-
val base = baseField.get(this) ?: return
|
|
154
|
-
|
|
155
|
-
val baseClass = BaseBlurViewGroup::class.java
|
|
90
|
+
// ── Drawing ────────────────────────────────────────────────────────────────
|
|
156
91
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
92
|
+
override fun onDraw(canvas: Canvas) {
|
|
93
|
+
val bitmap = latestBitmap?.takeIf { !it.isRecycled } ?: return
|
|
94
|
+
val root = findBlurRoot() ?: return
|
|
160
95
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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)
|
|
164
99
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
forceRedrawField.setBoolean(base, true)
|
|
100
|
+
val l = myLoc[0] - rootLoc[0]
|
|
101
|
+
val t = myLoc[1] - rootLoc[1]
|
|
168
102
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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())
|
|
176
112
|
|
|
177
|
-
|
|
178
|
-
try {
|
|
179
|
-
if (!isSetupDone) applyPendingBlurConfig() else invalidate()
|
|
180
|
-
} catch (_: Exception) {}
|
|
181
|
-
}
|
|
113
|
+
if (!srcRect.isEmpty) canvas.drawBitmap(bitmap, srcRect, dstRect, bitmapPaint)
|
|
182
114
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
super.setOverlayColor(currentOverlayColor)
|
|
187
|
-
updateCornerRadiusInternal()
|
|
188
|
-
isSetupDone = true
|
|
189
|
-
} catch (_: Exception) {
|
|
190
|
-
// Not fully attached yet — next Choreographer tick will retry
|
|
115
|
+
if (Color.alpha(overlayColor) > 0) {
|
|
116
|
+
overlayPaint.color = overlayColor
|
|
117
|
+
canvas.drawRect(dstRect, overlayPaint)
|
|
191
118
|
}
|
|
192
119
|
}
|
|
193
120
|
|
|
194
121
|
// ── Public setters (ViewManager → UI thread) ──────────────────────────────
|
|
195
122
|
|
|
196
|
-
/**
|
|
197
|
-
* blurAmount: JS-facing 0–100.
|
|
198
|
-
* Mapped to 0–25 internally (QmBlurView Gaussian kernel's designed range).
|
|
199
|
-
* Values above 25 produce no visible increase in blur but cost more.
|
|
200
|
-
*/
|
|
201
123
|
fun setBlurAmount(amount: Float) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
try { super.setBlurRadius(pendingBlurRadius) } catch (_: Exception) {}
|
|
205
|
-
scheduleBlurFrame()
|
|
206
|
-
}
|
|
124
|
+
blurRadius = (amount.coerceIn(0f, 100f) / 100f) * 25f
|
|
125
|
+
coordinator?.blurRadius = blurRadius
|
|
207
126
|
}
|
|
208
127
|
|
|
209
|
-
fun
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
super.setBackgroundColor(Color.TRANSPARENT)
|
|
214
|
-
super.setOverlayColor(currentOverlayColor)
|
|
215
|
-
} catch (_: Exception) {}
|
|
216
|
-
scheduleBlurFrame()
|
|
217
|
-
}
|
|
128
|
+
fun applyOverlayColor(colorString: String?) {
|
|
129
|
+
overlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
|
|
130
|
+
invalidate()
|
|
218
131
|
}
|
|
219
132
|
|
|
220
|
-
/**
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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()
|
|
224
141
|
}
|
|
225
142
|
|
|
226
143
|
fun applyBorderRadius(radiusDp: Float) {
|
|
227
|
-
|
|
228
|
-
|
|
144
|
+
cornerRadiusPx = TypedValue.applyDimension(
|
|
145
|
+
TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
|
|
146
|
+
)
|
|
147
|
+
updateOutline()
|
|
229
148
|
}
|
|
230
149
|
|
|
231
|
-
fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER")
|
|
232
|
-
//
|
|
150
|
+
fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") color: String?) {
|
|
151
|
+
// iOS-only concept — no-op on Android
|
|
233
152
|
}
|
|
234
153
|
|
|
235
|
-
// ── Corner radius
|
|
154
|
+
// ── Corner radius / outline ────────────────────────────────────────────────
|
|
236
155
|
|
|
237
|
-
private fun
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
outline.setRoundRect(0, 0, view.width, view.height, px)
|
|
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
|
+
}
|
|
244
162
|
}
|
|
163
|
+
clipToOutline = true
|
|
164
|
+
} else {
|
|
165
|
+
outlineProvider = ViewOutlineProvider.BACKGROUND
|
|
166
|
+
clipToOutline = false
|
|
245
167
|
}
|
|
246
|
-
|
|
247
|
-
try { super.setCornerRadius(px) } catch (_: Exception) {}
|
|
168
|
+
invalidate()
|
|
248
169
|
}
|
|
249
170
|
|
|
250
|
-
// ──
|
|
171
|
+
// ── React Native layout passthrough ───────────────────────────────────────
|
|
251
172
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
Choreographer.getInstance().postFrameCallback(frameCallback)
|
|
256
|
-
}
|
|
173
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
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).
|
|
257
176
|
}
|
|
258
177
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
//
|
|
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.
|
|
263
182
|
|
|
264
|
-
private fun
|
|
183
|
+
private fun findBlurRoot(): ViewGroup? {
|
|
265
184
|
var p = parent
|
|
266
185
|
while (p != null) {
|
|
267
|
-
if (p
|
|
186
|
+
if ((p as? View)?.javaClass?.name == "com.swmansion.rnscreens.Screen")
|
|
187
|
+
return p as? ViewGroup
|
|
268
188
|
p = (p as? View)?.parent
|
|
269
189
|
}
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private fun findNearestReactRootView(): ViewGroup? {
|
|
274
|
-
var p = parent
|
|
190
|
+
p = parent
|
|
275
191
|
while (p != null) {
|
|
276
|
-
if (p
|
|
192
|
+
if ((p as? View)?.javaClass?.name == "com.facebook.react.ReactRootView")
|
|
193
|
+
return p as? ViewGroup
|
|
277
194
|
p = (p as? View)?.parent
|
|
278
195
|
}
|
|
279
|
-
return
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// ── React Native layout passthrough ───────────────────────────────────────
|
|
283
|
-
|
|
284
|
-
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
285
|
-
// Yoga handles all child layout. Calling super here would cause QmBlurView's
|
|
286
|
-
// FrameLayout logic to fight RN's layout system.
|
|
196
|
+
return rootView as? ViewGroup
|
|
287
197
|
}
|
|
288
198
|
|
|
289
199
|
// ── Color parser ──────────────────────────────────────────────────────────
|
|
290
|
-
//
|
|
200
|
+
// Handles: "transparent", "#RGB", "#RRGGBB", "#RRGGBBAA", named colors
|
|
291
201
|
|
|
292
202
|
private fun parseHexColor(s: String): Int? {
|
|
293
203
|
val t = s.trim()
|
|
@@ -296,28 +206,25 @@ class BlurVibeView(context: Context) : BlurViewGroup(context, null) {
|
|
|
296
206
|
val hex = t.removePrefix("#")
|
|
297
207
|
return try {
|
|
298
208
|
when (hex.length) {
|
|
299
|
-
3
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
6
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
8
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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))
|
|
312
222
|
else -> null
|
|
313
223
|
}
|
|
314
224
|
} catch (_: NumberFormatException) { null }
|
|
315
225
|
}
|
|
316
226
|
|
|
317
|
-
// ── Constants ─────────────────────────────────────────────────────────────
|
|
318
|
-
|
|
319
227
|
companion object {
|
|
320
|
-
// blurAmount=10 →
|
|
321
|
-
private const val DEFAULT_BLUR_RADIUS = 2.5f
|
|
228
|
+
private const val DEFAULT_BLUR_RADIUS = 2.5f // blurAmount=10 → 2.5
|
|
322
229
|
}
|
|
323
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,30 +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) =
|
|
28
|
+
view.setBlurAmount(amount)
|
|
21
29
|
|
|
22
30
|
@ReactProp(name = "blurType")
|
|
23
|
-
fun
|
|
24
|
-
//
|
|
31
|
+
fun setBlurTypeProp(view: BlurVibeView, @Suppress("UNUSED_PARAMETER") type: String?) {
|
|
32
|
+
// iOS UIBlurEffectStyle — no-op on Android
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
@ReactProp(name = "overlayColor")
|
|
28
|
-
fun
|
|
36
|
+
fun setOverlayColorProp(view: BlurVibeView, color: String?) =
|
|
37
|
+
view.applyOverlayColor(color)
|
|
29
38
|
|
|
30
39
|
@ReactProp(name = "reducedTransparencyFallbackColor")
|
|
31
40
|
fun setReducedTransparencyFallbackColor(view: BlurVibeView, color: String?) =
|
|
32
41
|
view.setReducedTransparencyFallbackColor(color)
|
|
33
42
|
|
|
34
43
|
@ReactProp(name = "blurRadius", defaultInt = 4)
|
|
35
|
-
fun
|
|
44
|
+
fun setBlurRadiusProp(view: BlurVibeView, radius: Int) =
|
|
45
|
+
view.applyBlurRadius(radius)
|
|
36
46
|
|
|
37
47
|
@ReactProp(name = "borderRadius", defaultFloat = 0f)
|
|
38
|
-
fun setBlurBorderRadius(view: BlurVibeView, radius: Float) =
|
|
39
|
-
|
|
40
|
-
override fun onDropViewInstance(view: BlurVibeView) {
|
|
41
|
-
super.onDropViewInstance(view)
|
|
42
|
-
}
|
|
48
|
+
fun setBlurBorderRadius(view: BlurVibeView, radius: Float) =
|
|
49
|
+
view.applyBorderRadius(radius)
|
|
43
50
|
|
|
44
|
-
// Yoga drives all child layout — return false
|
|
45
51
|
override fun needsCustomLayoutForChildren(): Boolean = false
|
|
46
52
|
}
|