react-native-blur-vibe 0.1.9 → 0.1.11
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.
|
@@ -12,31 +12,28 @@ import androidx.core.graphics.toColorInt
|
|
|
12
12
|
import com.facebook.react.views.view.ReactViewGroup
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* BlurVibeView — Android API 21–30 backdrop blur
|
|
15
|
+
* BlurVibeView — Android API 21–30 backdrop blur.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
24
26
|
*/
|
|
25
27
|
class BlurVibeView(context: Context) : ReactViewGroup(context) {
|
|
26
28
|
|
|
27
|
-
// ── State ──────────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
29
|
private var blurController: LegacyBlurController? = null
|
|
30
|
-
private var pendingBlurAmount
|
|
31
|
-
private var pendingOverlay
|
|
32
|
-
private var cornerRadiusPx
|
|
33
|
-
|
|
34
|
-
// ── Init ───────────────────────────────────────────────────────────────────
|
|
30
|
+
private var pendingBlurAmount = 10f
|
|
31
|
+
private var pendingOverlay = Color.TRANSPARENT
|
|
32
|
+
private var cornerRadiusPx = 0f
|
|
35
33
|
|
|
36
34
|
init {
|
|
37
35
|
setWillNotDraw(false)
|
|
38
36
|
super.setBackgroundColor(Color.TRANSPARENT)
|
|
39
|
-
clipToOutline = true
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
@@ -61,10 +58,30 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
|
|
|
61
58
|
blurController?.onSizeChanged()
|
|
62
59
|
}
|
|
63
60
|
|
|
64
|
-
|
|
61
|
+
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
|
62
|
+
super.onWindowFocusChanged(hasWindowFocus)
|
|
63
|
+
if (hasWindowFocus) blurController?.reAttach()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── 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
|
+
|
|
73
|
+
override fun draw(canvas: Canvas) {
|
|
74
|
+
if (blurController?.isCapturing == true) return
|
|
75
|
+
super.draw(canvas)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── onDraw ────────────────────────────────────────────────────────────────
|
|
65
79
|
|
|
66
80
|
override fun onDraw(canvas: Canvas) {
|
|
81
|
+
// Draw the blurred background first
|
|
67
82
|
blurController?.draw(canvas, width.toFloat(), height.toFloat())
|
|
83
|
+
// Let ReactViewGroup draw borders/radius/background on top natively
|
|
84
|
+
super.onDraw(canvas)
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
// ── Public setters ─────────────────────────────────────────────────────────
|
|
@@ -81,24 +98,28 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
|
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
fun setBlurRadius(factor: Int) {
|
|
84
|
-
//
|
|
85
|
-
// Could expose downsampleFactor setter on controller if needed
|
|
101
|
+
// Exposed as a downsample override for power users — not used internally
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
fun applyBorderRadius(radiusDp: Float) {
|
|
89
105
|
cornerRadiusPx = TypedValue.applyDimension(
|
|
90
106
|
TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
|
|
91
107
|
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
113
|
}
|
|
114
|
+
clipToOutline = true
|
|
115
|
+
} else {
|
|
116
|
+
outlineProvider = ViewOutlineProvider.BACKGROUND
|
|
117
|
+
clipToOutline = false
|
|
96
118
|
}
|
|
97
|
-
clipToOutline = cornerRadiusPx > 0f
|
|
98
119
|
invalidate()
|
|
99
120
|
}
|
|
100
121
|
|
|
101
|
-
fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") color: String?) {
|
|
122
|
+
fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") color: String?) {}
|
|
102
123
|
|
|
103
124
|
fun applyBlurEnabled(enabled: Boolean) {
|
|
104
125
|
blurController?.enabled = enabled
|
|
@@ -111,15 +132,13 @@ class BlurVibeView(context: Context) : ReactViewGroup(context) {
|
|
|
111
132
|
|
|
112
133
|
// ── Layout passthrough ─────────────────────────────────────────────────────
|
|
113
134
|
|
|
114
|
-
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
115
|
-
// Yoga handles all layout
|
|
116
|
-
}
|
|
135
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {}
|
|
117
136
|
|
|
118
137
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
119
138
|
|
|
120
139
|
private fun mapBlurAmount(amount: Float): Float {
|
|
121
140
|
val t = amount.coerceIn(0f, 100f) / 100f
|
|
122
|
-
return t * t * 25f
|
|
141
|
+
return t * t * 25f
|
|
123
142
|
}
|
|
124
143
|
|
|
125
144
|
private fun findBlurRoot(): ViewGroup? {
|
|
@@ -3,6 +3,8 @@ 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
|
|
6
8
|
import android.graphics.Canvas
|
|
7
9
|
import android.graphics.Color
|
|
8
10
|
import android.graphics.LinearGradient
|
|
@@ -33,73 +35,78 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
|
|
|
33
35
|
|
|
34
36
|
// ── Blur params ────────────────────────────────────────────────────────────
|
|
35
37
|
|
|
36
|
-
private var
|
|
37
|
-
private var blurRadiusY = DEFAULT_BLUR_RADIUS
|
|
38
|
+
private var blurAmount = 10f
|
|
38
39
|
private var overlayColor = Color.TRANSPARENT
|
|
39
40
|
private var cornerRadiusPx = 0f
|
|
40
41
|
|
|
41
|
-
// ── Progressive blur
|
|
42
|
+
// ── Progressive blur ──────────────────────────────────────────────────────
|
|
42
43
|
|
|
43
44
|
private var progressiveDirection = PROGRESSIVE_NONE
|
|
44
45
|
private var progressiveStartIntensity = 1f
|
|
45
46
|
private var progressiveEndIntensity = 0f
|
|
46
47
|
|
|
47
|
-
// ── Noise
|
|
48
|
+
// ── Noise ─────────────────────────────────────────────────────────────────
|
|
48
49
|
|
|
49
50
|
private var noiseFactor = 0.08f
|
|
50
51
|
private var noiseBitmap: Bitmap? = null
|
|
51
52
|
private val noisePaint = Paint()
|
|
52
53
|
|
|
53
|
-
// ──
|
|
54
|
+
// ── Bitmap + RenderNode ────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
private var internalBitmap: Bitmap? = null
|
|
57
|
+
private val renderNode = RenderNode("BlurVibeNode")
|
|
58
|
+
|
|
59
|
+
// ── Capture exclusion flag ────────────────────────────────────────────────
|
|
54
60
|
//
|
|
55
|
-
//
|
|
56
|
-
// blurNode: crops + translates contentNode to this view's position,
|
|
57
|
-
// with RenderEffect blur applied
|
|
61
|
+
// THE FIX FOR STATIC BLUR:
|
|
58
62
|
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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.
|
|
62
68
|
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
private val contentNode = RenderNode("BlurVibeContent")
|
|
69
|
-
private val blurNode = RenderNode("BlurVibeBlur")
|
|
70
|
-
|
|
71
|
-
// ── Recording guard — prevents double-beginRecording crashes ─────────────
|
|
69
|
+
// Fix: set isCapturing = true before root.draw(), override draw() to be
|
|
70
|
+
// a no-op when isCapturing = true. root.draw() then skips us completely,
|
|
71
|
+
// capturing ONLY the content behind us. This is exactly how Dimezis
|
|
72
|
+
// BlurView solves the same problem.
|
|
72
73
|
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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.
|
|
76
78
|
|
|
77
79
|
private var isCapturing = false
|
|
78
80
|
|
|
79
|
-
// ──
|
|
81
|
+
// ── Draw paints ───────────────────────────────────────────────────────────
|
|
80
82
|
|
|
81
|
-
private val
|
|
82
|
-
private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
83
|
+
private val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
83
84
|
xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
|
|
84
85
|
}
|
|
86
|
+
private val noisePaintFinal = Paint()
|
|
85
87
|
|
|
86
88
|
// ── Root view ─────────────────────────────────────────────────────────────
|
|
87
89
|
|
|
88
90
|
private var blurRoot: ViewGroup? = null
|
|
91
|
+
private val myLocation = IntArray(2)
|
|
92
|
+
private val rootLocation = IntArray(2)
|
|
89
93
|
|
|
90
|
-
// ──
|
|
94
|
+
// ── State ─────────────────────────────────────────────────────────────────
|
|
91
95
|
|
|
96
|
+
private var blurEnabled = true
|
|
97
|
+
private var autoUpdate = true
|
|
92
98
|
private var frameScheduled = false
|
|
99
|
+
private var initialized = false
|
|
100
|
+
|
|
101
|
+
// ── Choreographer gate ────────────────────────────────────────────────────
|
|
102
|
+
|
|
93
103
|
private val frameCallback = Choreographer.FrameCallback {
|
|
94
104
|
frameScheduled = false
|
|
95
|
-
if (isAttachedToWindow)
|
|
96
|
-
captureRootIntoNode()
|
|
97
|
-
invalidate()
|
|
98
|
-
}
|
|
105
|
+
if (isAttachedToWindow && blurEnabled) updateBlur()
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
|
|
102
|
-
if (!frameScheduled) {
|
|
109
|
+
if (!frameScheduled && blurEnabled && autoUpdate) {
|
|
103
110
|
frameScheduled = true
|
|
104
111
|
Choreographer.getInstance().postFrameCallback(frameCallback)
|
|
105
112
|
}
|
|
@@ -111,11 +118,6 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
|
|
|
111
118
|
init {
|
|
112
119
|
setWillNotDraw(false)
|
|
113
120
|
super.setBackgroundColor(Color.TRANSPARENT)
|
|
114
|
-
clipToOutline = true
|
|
115
|
-
// DO NOT call setLayerType(LAYER_TYPE_HARDWARE) here —
|
|
116
|
-
// it conflicts with canvas.drawRenderNode() and causes SIGSEGV in RenderThread.
|
|
117
|
-
// The view uses the default layer type (LAYER_TYPE_NONE) so its canvas is
|
|
118
|
-
// the hardware-accelerated display list canvas — which supports drawRenderNode.
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
@@ -123,107 +125,146 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
|
|
|
123
125
|
override fun onAttachedToWindow() {
|
|
124
126
|
super.onAttachedToWindow()
|
|
125
127
|
blurRoot = findBlurRoot()
|
|
126
|
-
|
|
128
|
+
safeAddPreDrawListener()
|
|
127
129
|
generateNoiseBitmap()
|
|
130
|
+
if (measuredWidth > 0 && measuredHeight > 0) initBlur()
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
override fun onDetachedFromWindow() {
|
|
131
134
|
blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
|
|
132
135
|
Choreographer.getInstance().removeFrameCallback(frameCallback)
|
|
133
136
|
frameScheduled = false
|
|
137
|
+
initialized = false
|
|
134
138
|
isCapturing = false
|
|
135
139
|
blurRoot = null
|
|
136
|
-
noiseBitmap?.recycle()
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
contentNode.discardDisplayList()
|
|
140
|
-
blurNode.discardDisplayList()
|
|
140
|
+
noiseBitmap?.recycle(); noiseBitmap = null
|
|
141
|
+
internalBitmap?.recycle(); internalBitmap = null
|
|
142
|
+
renderNode.discardDisplayList()
|
|
141
143
|
super.onDetachedFromWindow()
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
145
147
|
super.onSizeChanged(w, h, oldw, oldh)
|
|
146
|
-
// Update blurNode bounds — contentNode bounds are set in captureRootIntoNode
|
|
147
148
|
if (w > 0 && h > 0) {
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
internalBitmap?.recycle(); internalBitmap = null
|
|
150
|
+
renderNode.discardDisplayList()
|
|
151
|
+
initialized = false
|
|
152
|
+
initBlur()
|
|
150
153
|
}
|
|
151
154
|
}
|
|
152
155
|
|
|
153
|
-
// ──
|
|
156
|
+
// ── Multi-window safety ───────────────────────────────────────────────────
|
|
154
157
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
|
159
|
+
super.onWindowFocusChanged(hasWindowFocus)
|
|
160
|
+
if (hasWindowFocus && blurEnabled && autoUpdate) {
|
|
161
|
+
safeAddPreDrawListener()
|
|
162
|
+
scheduleFrame()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
162
165
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
contentCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
|
170
|
-
root.draw(contentCanvas)
|
|
171
|
-
} finally {
|
|
172
|
-
contentNode.endRecording() // always end — even if draw() throws
|
|
173
|
-
}
|
|
166
|
+
private fun safeAddPreDrawListener() {
|
|
167
|
+
val root = blurRoot ?: return
|
|
168
|
+
val vto = root.viewTreeObserver
|
|
169
|
+
vto.removeOnPreDrawListener(preDrawListener)
|
|
170
|
+
if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
|
|
171
|
+
}
|
|
174
172
|
|
|
175
|
-
|
|
176
|
-
// in blurNode. This is critical — drawing an actively-recording
|
|
177
|
-
// RenderNode into another canvas is undefined behaviour.
|
|
178
|
-
val myLoc = IntArray(2); getLocationInWindow(myLoc)
|
|
179
|
-
val rootLoc = IntArray(2); root.getLocationInWindow(rootLoc)
|
|
180
|
-
val offsetX = (myLoc[0] - rootLoc[0]).toFloat()
|
|
181
|
-
val offsetY = (myLoc[1] - rootLoc[1]).toFloat()
|
|
182
|
-
|
|
183
|
-
blurNode.setPosition(0, 0, vw, vh)
|
|
184
|
-
applyBlurRenderEffect()
|
|
185
|
-
|
|
186
|
-
val blurCanvas = blurNode.beginRecording()
|
|
187
|
-
try {
|
|
188
|
-
blurCanvas.translate(-offsetX, -offsetY)
|
|
189
|
-
blurCanvas.drawRenderNode(contentNode) // safe: contentNode recording is done
|
|
190
|
-
} finally {
|
|
191
|
-
blurNode.endRecording()
|
|
192
|
-
}
|
|
173
|
+
// ── Init blur ─────────────────────────────────────────────────────────────
|
|
193
174
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
175
|
+
private fun initBlur() {
|
|
176
|
+
val w = measuredWidth; if (w <= 0) return
|
|
177
|
+
val h = measuredHeight; if (h <= 0) return
|
|
178
|
+
internalBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
179
|
+
renderNode.setPosition(0, 0, w, h)
|
|
180
|
+
initialized = true
|
|
181
|
+
updateBlur()
|
|
197
182
|
}
|
|
198
183
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
184
|
+
// ── Core: capture + blur + render ─────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
private fun updateBlur() {
|
|
187
|
+
if (!blurEnabled || !initialized) return
|
|
188
|
+
val root = blurRoot ?: return
|
|
189
|
+
val bitmap = internalBitmap ?: return
|
|
190
|
+
if (bitmap.isRecycled) return
|
|
191
|
+
|
|
192
|
+
// ① Compute this view's offset within the root (window coords — correct
|
|
193
|
+
// for all window modes: split-screen, freeform, PiP, DeX)
|
|
194
|
+
root.getLocationInWindow(rootLocation)
|
|
195
|
+
getLocationInWindow(myLocation)
|
|
196
|
+
val offsetX = (myLocation[0] - rootLocation[0]).toFloat()
|
|
197
|
+
val offsetY = (myLocation[1] - rootLocation[1]).toFloat()
|
|
198
|
+
|
|
199
|
+
// ② Capture root content EXCLUDING this view.
|
|
200
|
+
// isCapturing = true causes our draw() to be a no-op, so root.draw()
|
|
201
|
+
// skips us and captures only the content behind us.
|
|
202
|
+
isCapturing = true
|
|
203
|
+
val captureCanvas = Canvas(bitmap)
|
|
204
|
+
captureCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
|
205
|
+
captureCanvas.translate(-offsetX, -offsetY)
|
|
206
|
+
try {
|
|
207
|
+
root.draw(captureCanvas)
|
|
208
|
+
} catch (_: Exception) {
|
|
209
|
+
isCapturing = false
|
|
202
210
|
return
|
|
203
211
|
}
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
isCapturing = false
|
|
213
|
+
|
|
214
|
+
// ③ Record bitmap into RenderNode.
|
|
215
|
+
// Drawing a BITMAP into RenderNode is stable on all OEM drivers.
|
|
216
|
+
// Drawing a RenderNode into another RenderNode's recording is NOT.
|
|
217
|
+
renderNode.setPosition(0, 0, bitmap.width, bitmap.height)
|
|
218
|
+
val nodeCanvas = renderNode.beginRecording()
|
|
219
|
+
nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
|
|
220
|
+
renderNode.endRecording()
|
|
221
|
+
|
|
222
|
+
// ④ Apply GPU blur + tint as a chained RenderEffect (single GPU pass)
|
|
223
|
+
val radius = blurRadiusFromAmount(blurAmount)
|
|
224
|
+
val blurEffect = RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.MIRROR)
|
|
225
|
+
renderNode.setRenderEffect(
|
|
226
|
+
if (Color.alpha(overlayColor) > 0) {
|
|
227
|
+
RenderEffect.createChainEffect(
|
|
228
|
+
RenderEffect.createColorFilterEffect(
|
|
229
|
+
BlendModeColorFilter(overlayColor, BlendMode.SRC_ATOP)
|
|
230
|
+
),
|
|
231
|
+
blurEffect
|
|
232
|
+
)
|
|
233
|
+
} else blurEffect
|
|
206
234
|
)
|
|
235
|
+
|
|
236
|
+
invalidate()
|
|
207
237
|
}
|
|
208
238
|
|
|
209
|
-
// ──
|
|
239
|
+
// ── draw() override — no-op during capture ────────────────────────────────
|
|
240
|
+
//
|
|
241
|
+
// When isCapturing = true (root.draw() is in progress capturing background),
|
|
242
|
+
// suppress our own draw so we don't paint stale blur into the capture bitmap.
|
|
243
|
+
// This makes us invisible to root.draw() during capture only —
|
|
244
|
+
// NOT to the actual screen renderer.
|
|
245
|
+
|
|
246
|
+
override fun draw(canvas: Canvas) {
|
|
247
|
+
if (isCapturing) return // skip self during root capture
|
|
248
|
+
super.draw(canvas)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── onDraw ────────────────────────────────────────────────────────────────
|
|
210
252
|
|
|
211
253
|
override fun onDraw(canvas: Canvas) {
|
|
254
|
+
if (!blurEnabled || !initialized) return
|
|
212
255
|
val w = width.toFloat(); if (w <= 0f) return
|
|
213
256
|
val h = height.toFloat(); if (h <= 0f) return
|
|
257
|
+
if (!renderNode.hasDisplayList()) return
|
|
214
258
|
|
|
215
|
-
//
|
|
216
|
-
if (!blurNode.hasDisplayList()) return
|
|
217
|
-
|
|
218
|
-
// Step 1: save layer for progressive mask compositing
|
|
259
|
+
// Progressive mask requires a saved layer so DST_IN mask composites correctly
|
|
219
260
|
val saveCount = if (progressiveDirection != PROGRESSIVE_NONE) {
|
|
220
261
|
canvas.saveLayer(0f, 0f, w, h, null)
|
|
221
262
|
} else -1
|
|
222
263
|
|
|
223
|
-
//
|
|
224
|
-
canvas.drawRenderNode(
|
|
264
|
+
// Draw GPU-blurred + tinted result from RenderNode
|
|
265
|
+
canvas.drawRenderNode(renderNode)
|
|
225
266
|
|
|
226
|
-
//
|
|
267
|
+
// Progressive alpha mask — fades blur across the view
|
|
227
268
|
if (progressiveDirection != PROGRESSIVE_NONE && saveCount >= 0) {
|
|
228
269
|
buildProgressiveShader(w, h)?.let { shader ->
|
|
229
270
|
maskPaint.shader = shader
|
|
@@ -232,53 +273,43 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
|
|
|
232
273
|
canvas.restoreToCount(saveCount)
|
|
233
274
|
}
|
|
234
275
|
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
} else {
|
|
241
|
-
canvas.drawRect(0f, 0f, w, h, overlayPaint)
|
|
242
|
-
}
|
|
276
|
+
// Noise grain overlay
|
|
277
|
+
noiseBitmap?.takeIf { !it.isRecycled && noiseFactor > 0f }?.let { noise ->
|
|
278
|
+
noisePaintFinal.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
|
|
279
|
+
noisePaintFinal.shader = BitmapShader(noise, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
|
|
280
|
+
canvas.drawRect(0f, 0f, w, h, noisePaintFinal)
|
|
243
281
|
}
|
|
244
282
|
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
noisePaint.alpha = (noiseFactor * 255f).toInt().coerceIn(0, 255)
|
|
249
|
-
noisePaint.shader = BitmapShader(bmp, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
|
|
250
|
-
canvas.drawRect(0f, 0f, w, h, noisePaint)
|
|
251
|
-
}
|
|
252
|
-
}
|
|
283
|
+
// Let ReactViewGroup draw borders/background on top (handles borderRadius
|
|
284
|
+
// and all other RN style props natively — no conflict with our blur)
|
|
285
|
+
super.onDraw(canvas)
|
|
253
286
|
}
|
|
254
287
|
|
|
255
288
|
// ── Progressive shader ────────────────────────────────────────────────────
|
|
256
289
|
|
|
257
290
|
private fun buildProgressiveShader(w: Float, h: Float): Shader? {
|
|
258
|
-
val
|
|
259
|
-
val
|
|
291
|
+
val sc = Color.argb((progressiveStartIntensity.coerceIn(0f,1f)*255).toInt(),0,0,0)
|
|
292
|
+
val ec = Color.argb((progressiveEndIntensity.coerceIn(0f,1f)*255).toInt(),0,0,0)
|
|
260
293
|
return when (progressiveDirection) {
|
|
261
|
-
PROGRESSIVE_TOP_TO_BOTTOM -> LinearGradient(0f,0f,0f,h,
|
|
262
|
-
PROGRESSIVE_BOTTOM_TO_TOP -> LinearGradient(0f,h,0f,0f,
|
|
263
|
-
PROGRESSIVE_LEFT_TO_RIGHT -> LinearGradient(0f,0f,w,0f,
|
|
264
|
-
PROGRESSIVE_RIGHT_TO_LEFT -> LinearGradient(w,0f,0f,0f,
|
|
265
|
-
PROGRESSIVE_RADIAL -> RadialGradient(w/2f,h/2f,
|
|
266
|
-
else
|
|
294
|
+
PROGRESSIVE_TOP_TO_BOTTOM -> LinearGradient(0f,0f,0f,h,sc,ec,Shader.TileMode.CLAMP)
|
|
295
|
+
PROGRESSIVE_BOTTOM_TO_TOP -> LinearGradient(0f,h,0f,0f,sc,ec,Shader.TileMode.CLAMP)
|
|
296
|
+
PROGRESSIVE_LEFT_TO_RIGHT -> LinearGradient(0f,0f,w,0f,sc,ec,Shader.TileMode.CLAMP)
|
|
297
|
+
PROGRESSIVE_RIGHT_TO_LEFT -> LinearGradient(w,0f,0f,0f,sc,ec,Shader.TileMode.CLAMP)
|
|
298
|
+
PROGRESSIVE_RADIAL -> RadialGradient(w/2f,h/2f,min(w,h)/2f,sc,ec,Shader.TileMode.CLAMP)
|
|
299
|
+
else -> null
|
|
267
300
|
}
|
|
268
301
|
}
|
|
269
302
|
|
|
270
|
-
// ── Noise
|
|
303
|
+
// ── Noise ─────────────────────────────────────────────────────────────────
|
|
271
304
|
|
|
272
305
|
private fun generateNoiseBitmap() {
|
|
273
306
|
if (noiseBitmap?.isRecycled == false) return
|
|
274
307
|
val size = 64
|
|
275
308
|
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
276
309
|
val rng = Random(42)
|
|
277
|
-
for (x in 0 until size) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
bmp.setPixel(x, y, Color.argb(255, v, v, v))
|
|
281
|
-
}
|
|
310
|
+
for (x in 0 until size) for (y in 0 until size) {
|
|
311
|
+
val v = rng.nextInt(256)
|
|
312
|
+
bmp.setPixel(x, y, Color.argb(255, v, v, v))
|
|
282
313
|
}
|
|
283
314
|
noiseBitmap = bmp
|
|
284
315
|
}
|
|
@@ -286,75 +317,68 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
|
|
|
286
317
|
// ── Public setters ─────────────────────────────────────────────────────────
|
|
287
318
|
|
|
288
319
|
fun setBlurAmount(amount: Float) {
|
|
289
|
-
|
|
290
|
-
blurRadiusX = t * t * MAX_BLUR_RADIUS
|
|
291
|
-
blurRadiusY = blurRadiusX
|
|
292
|
-
applyBlurRenderEffect()
|
|
293
|
-
scheduleFrame()
|
|
320
|
+
blurAmount = amount.coerceIn(0f, 100f); scheduleFrame()
|
|
294
321
|
}
|
|
295
322
|
|
|
296
323
|
fun setOverlayColor(colorString: String?) {
|
|
297
324
|
overlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
|
|
298
|
-
|
|
325
|
+
scheduleFrame()
|
|
299
326
|
}
|
|
300
327
|
|
|
328
|
+
// borderRadius from JS style prop — handled natively by ReactViewGroup.
|
|
329
|
+
// applyBorderRadius is called by our @ReactProp "borderRadius" binding.
|
|
330
|
+
// We additionally set clipToOutline so the blur content is clipped correctly.
|
|
301
331
|
fun applyBorderRadius(radiusDp: Float) {
|
|
302
332
|
cornerRadiusPx = TypedValue.applyDimension(
|
|
303
333
|
TypedValue.COMPLEX_UNIT_DIP, radiusDp, context.resources.displayMetrics
|
|
304
334
|
)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
335
|
+
if (cornerRadiusPx > 0f) {
|
|
336
|
+
outlineProvider = object : ViewOutlineProvider() {
|
|
337
|
+
override fun getOutline(view: View, outline: Outline) {
|
|
338
|
+
outline.setRoundRect(0, 0, view.width, view.height, cornerRadiusPx)
|
|
339
|
+
}
|
|
308
340
|
}
|
|
341
|
+
clipToOutline = true
|
|
342
|
+
} else {
|
|
343
|
+
outlineProvider = ViewOutlineProvider.BACKGROUND
|
|
344
|
+
clipToOutline = false
|
|
309
345
|
}
|
|
310
|
-
clipToOutline = cornerRadiusPx > 0f
|
|
311
346
|
invalidate()
|
|
312
347
|
}
|
|
313
348
|
|
|
314
|
-
fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER")
|
|
349
|
+
fun setReducedTransparencyFallbackColor(@Suppress("UNUSED_PARAMETER") c: String?) {}
|
|
315
350
|
|
|
316
|
-
fun setProgressiveBlurDirection(
|
|
317
|
-
progressiveDirection = when (
|
|
351
|
+
fun setProgressiveBlurDirection(d: String?) {
|
|
352
|
+
progressiveDirection = when (d) {
|
|
318
353
|
"topToBottom" -> PROGRESSIVE_TOP_TO_BOTTOM
|
|
319
354
|
"bottomToTop" -> PROGRESSIVE_BOTTOM_TO_TOP
|
|
320
355
|
"leftToRight" -> PROGRESSIVE_LEFT_TO_RIGHT
|
|
321
356
|
"rightToLeft" -> PROGRESSIVE_RIGHT_TO_LEFT
|
|
322
357
|
"radial" -> PROGRESSIVE_RADIAL
|
|
323
358
|
else -> PROGRESSIVE_NONE
|
|
324
|
-
}
|
|
325
|
-
invalidate()
|
|
359
|
+
}; invalidate()
|
|
326
360
|
}
|
|
327
361
|
|
|
328
|
-
fun setProgressiveStartIntensity(
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
fun setProgressiveEndIntensity(intensity: Float) {
|
|
333
|
-
progressiveEndIntensity = intensity.coerceIn(0f, 1f); invalidate()
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
fun setNoiseFactor(factor: Float) {
|
|
337
|
-
noiseFactor = factor.coerceIn(0f, 1f); invalidate()
|
|
338
|
-
}
|
|
362
|
+
fun setProgressiveStartIntensity(v: Float) { progressiveStartIntensity = v.coerceIn(0f,1f); invalidate() }
|
|
363
|
+
fun setProgressiveEndIntensity(v: Float) { progressiveEndIntensity = v.coerceIn(0f,1f); invalidate() }
|
|
364
|
+
fun setNoiseFactor(v: Float) { noiseFactor = v.coerceIn(0f,1f); invalidate() }
|
|
339
365
|
|
|
340
366
|
fun applyBlurEnabled(enabled: Boolean) {
|
|
341
|
-
|
|
367
|
+
blurEnabled = enabled
|
|
368
|
+
if (enabled) { safeAddPreDrawListener(); scheduleFrame() }
|
|
369
|
+
else {
|
|
342
370
|
blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
|
|
343
371
|
Choreographer.getInstance().removeFrameCallback(frameCallback)
|
|
344
372
|
frameScheduled = false
|
|
345
|
-
|
|
346
|
-
contentNode.discardDisplayList()
|
|
373
|
+
renderNode.discardDisplayList()
|
|
347
374
|
invalidate()
|
|
348
|
-
} else {
|
|
349
|
-
blurRoot?.viewTreeObserver?.addOnPreDrawListener(preDrawListener)
|
|
350
|
-
scheduleFrame()
|
|
351
375
|
}
|
|
352
376
|
}
|
|
353
377
|
|
|
354
|
-
fun setAutoUpdate(
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
378
|
+
fun setAutoUpdate(update: Boolean) {
|
|
379
|
+
autoUpdate = update
|
|
380
|
+
if (update) safeAddPreDrawListener()
|
|
381
|
+
else {
|
|
358
382
|
blurRoot?.viewTreeObserver?.removeOnPreDrawListener(preDrawListener)
|
|
359
383
|
Choreographer.getInstance().removeFrameCallback(frameCallback)
|
|
360
384
|
frameScheduled = false
|
|
@@ -364,12 +388,15 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
|
|
|
364
388
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
365
389
|
|
|
366
390
|
private fun scheduleFrame() {
|
|
367
|
-
if (!frameScheduled) {
|
|
391
|
+
if (!frameScheduled && blurEnabled) {
|
|
368
392
|
frameScheduled = true
|
|
369
393
|
Choreographer.getInstance().postFrameCallback(frameCallback)
|
|
370
394
|
}
|
|
371
395
|
}
|
|
372
396
|
|
|
397
|
+
private fun blurRadiusFromAmount(amount: Float): Float =
|
|
398
|
+
((amount / 100f).let { it * it } * 25f).coerceIn(1f, 25f)
|
|
399
|
+
|
|
373
400
|
private fun findBlurRoot(): ViewGroup? {
|
|
374
401
|
var p = parent
|
|
375
402
|
while (p != null) {
|
|
@@ -406,16 +433,14 @@ class BlurVibeViewApi31(context: Context) : ReactViewGroup(context) {
|
|
|
406
433
|
} catch (_: NumberFormatException) { null }
|
|
407
434
|
}
|
|
408
435
|
|
|
409
|
-
override fun onLayout(changed: Boolean,
|
|
436
|
+
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
|
|
410
437
|
|
|
411
438
|
companion object {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const val
|
|
415
|
-
const val
|
|
416
|
-
const val
|
|
417
|
-
const val
|
|
418
|
-
const val PROGRESSIVE_RIGHT_TO_LEFT = 4
|
|
419
|
-
const val PROGRESSIVE_RADIAL = 5
|
|
439
|
+
const val PROGRESSIVE_NONE = 0
|
|
440
|
+
const val PROGRESSIVE_TOP_TO_BOTTOM = 1
|
|
441
|
+
const val PROGRESSIVE_BOTTOM_TO_TOP = 2
|
|
442
|
+
const val PROGRESSIVE_LEFT_TO_RIGHT = 3
|
|
443
|
+
const val PROGRESSIVE_RIGHT_TO_LEFT = 4
|
|
444
|
+
const val PROGRESSIVE_RADIAL = 5
|
|
420
445
|
}
|
|
421
446
|
}
|
|
@@ -6,7 +6,6 @@ import android.graphics.Color
|
|
|
6
6
|
import android.graphics.Paint
|
|
7
7
|
import android.graphics.PorterDuff
|
|
8
8
|
import android.graphics.Rect
|
|
9
|
-
import android.os.Build
|
|
10
9
|
import android.renderscript.Allocation
|
|
11
10
|
import android.renderscript.Element
|
|
12
11
|
import android.renderscript.RenderScript
|
|
@@ -19,41 +18,30 @@ import android.view.ViewTreeObserver
|
|
|
19
18
|
/**
|
|
20
19
|
* LegacyBlurController — zero-dependency backdrop blur for Android API 21–30.
|
|
21
20
|
*
|
|
22
|
-
*
|
|
23
|
-
* RenderScript is part of the Android SDK — no external library needed.
|
|
21
|
+
* Uses Android SDK RenderScript (ScriptIntrinsicBlur) — no external library.
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* → ScriptIntrinsicBlur.forEach() (RenderScript, GPU-accelerated)
|
|
31
|
-
* → view.invalidate()
|
|
32
|
-
* → onDraw: drawBitmap(scaledBitmap) + overlay tint
|
|
33
|
-
*
|
|
34
|
-
* Key optimisations vs naive implementation:
|
|
35
|
-
* - Choreographer gate: max 1 capture per vsync regardless of invalidation count
|
|
36
|
-
* - Bitmap pool: captureBitmap + scaledBitmap reused each frame (zero GC)
|
|
37
|
-
* - RenderScript Allocation pool: inputAlloc + outputAlloc reused (zero GC)
|
|
38
|
-
* - Blur rounds = 2: two passes for smooth spread without pixelation
|
|
39
|
-
* - Downsample factor = 4: captures at 1/16 resolution, blur hides pixel detail
|
|
23
|
+
* THE STATIC BLUR FIX:
|
|
24
|
+
* Before root.draw(), BlurVibeView.draw() is made a no-op via isCapturing flag.
|
|
25
|
+
* This means root.draw() skips the BlurView during capture, so we capture
|
|
26
|
+
* ONLY the content behind us — not our own previous blur output.
|
|
27
|
+
* Without this, each frame captures the previous blur → blur appears static.
|
|
40
28
|
*/
|
|
41
|
-
@Suppress("DEPRECATION")
|
|
29
|
+
@Suppress("DEPRECATION")
|
|
42
30
|
internal class LegacyBlurController(
|
|
43
|
-
private val view:
|
|
31
|
+
private val view: BlurVibeView,
|
|
44
32
|
private val rootView: ViewGroup
|
|
45
33
|
) {
|
|
46
34
|
|
|
47
35
|
companion object {
|
|
48
|
-
private const val DOWNSAMPLE_FACTOR = 4f
|
|
49
|
-
private const val BLUR_RADIUS = 8f
|
|
50
|
-
private const val BLUR_ROUNDS = 2
|
|
36
|
+
private const val DOWNSAMPLE_FACTOR = 4f
|
|
37
|
+
private const val BLUR_RADIUS = 8f
|
|
38
|
+
private const val BLUR_ROUNDS = 2
|
|
51
39
|
}
|
|
52
40
|
|
|
53
41
|
// ── Bitmap pool ────────────────────────────────────────────────────────────
|
|
54
42
|
|
|
55
|
-
private var captureBitmap: Bitmap? = null
|
|
56
|
-
private var scaledBitmap: Bitmap? = null
|
|
43
|
+
private var captureBitmap: Bitmap? = null
|
|
44
|
+
private var scaledBitmap: Bitmap? = null
|
|
57
45
|
private val capturePaint = Paint(Paint.FILTER_BITMAP_FLAG)
|
|
58
46
|
private val drawPaint = Paint(Paint.FILTER_BITMAP_FLAG)
|
|
59
47
|
|
|
@@ -66,19 +54,23 @@ internal class LegacyBlurController(
|
|
|
66
54
|
|
|
67
55
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
68
56
|
|
|
69
|
-
var overlayColor: Int
|
|
57
|
+
var overlayColor: Int = Color.TRANSPARENT
|
|
70
58
|
var blurRadius: Float = BLUR_RADIUS
|
|
71
59
|
var enabled: Boolean = true
|
|
72
60
|
set(value) { field = value; if (!value) invalidatePool() }
|
|
73
61
|
var autoUpdate: Boolean = true
|
|
74
62
|
set(value) {
|
|
75
63
|
field = value
|
|
76
|
-
if (value)
|
|
64
|
+
if (value) safeAddPreDrawListener()
|
|
77
65
|
else rootView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
|
|
78
66
|
}
|
|
79
67
|
|
|
68
|
+
// isCapturing: set true before root.draw() so BlurVibeView.draw() is a no-op
|
|
69
|
+
// preventing stale self-capture. Accessed by BlurVibeView.draw().
|
|
70
|
+
var isCapturing = false
|
|
71
|
+
private set
|
|
72
|
+
|
|
80
73
|
private var frameScheduled = false
|
|
81
|
-
private var isCapturing = false
|
|
82
74
|
|
|
83
75
|
// ── Choreographer gate ────────────────────────────────────────────────────
|
|
84
76
|
|
|
@@ -88,7 +80,7 @@ internal class LegacyBlurController(
|
|
|
88
80
|
}
|
|
89
81
|
|
|
90
82
|
private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
|
|
91
|
-
if (!frameScheduled && enabled) {
|
|
83
|
+
if (!frameScheduled && enabled && autoUpdate) {
|
|
92
84
|
frameScheduled = true
|
|
93
85
|
Choreographer.getInstance().postFrameCallback(frameCallback)
|
|
94
86
|
}
|
|
@@ -99,7 +91,7 @@ internal class LegacyBlurController(
|
|
|
99
91
|
|
|
100
92
|
init {
|
|
101
93
|
initRenderScript()
|
|
102
|
-
|
|
94
|
+
safeAddPreDrawListener()
|
|
103
95
|
}
|
|
104
96
|
|
|
105
97
|
private fun initRenderScript() {
|
|
@@ -112,7 +104,6 @@ internal class LegacyBlurController(
|
|
|
112
104
|
// ── Capture + blur ─────────────────────────────────────────────────────────
|
|
113
105
|
|
|
114
106
|
private fun captureAndBlur() {
|
|
115
|
-
if (isCapturing) return
|
|
116
107
|
val rw = rootView.width; if (rw <= 0) return
|
|
117
108
|
val rh = rootView.height; if (rh <= 0) return
|
|
118
109
|
val vw = view.width; if (vw <= 0) return
|
|
@@ -121,85 +112,88 @@ internal class LegacyBlurController(
|
|
|
121
112
|
val sw = (vw / DOWNSAMPLE_FACTOR).toInt().coerceAtLeast(1)
|
|
122
113
|
val sh = (vh / DOWNSAMPLE_FACTOR).toInt().coerceAtLeast(1)
|
|
123
114
|
|
|
115
|
+
val myLoc = IntArray(2); view.getLocationInWindow(myLoc)
|
|
116
|
+
val rootLoc = IntArray(2); rootView.getLocationInWindow(rootLoc)
|
|
117
|
+
val offsetX = (myLoc[0] - rootLoc[0]).toFloat()
|
|
118
|
+
val offsetY = (myLoc[1] - rootLoc[1]).toFloat()
|
|
119
|
+
|
|
120
|
+
val capture = reuseBitmap(captureBitmap, vw, vh).also { captureBitmap = it }
|
|
121
|
+
val scaled = reuseBitmap(scaledBitmap, sw, sh).also { scaledBitmap = it }
|
|
122
|
+
|
|
123
|
+
// Set isCapturing BEFORE root.draw() so BlurVibeView.draw() is skipped
|
|
124
124
|
isCapturing = true
|
|
125
|
+
val c = Canvas(capture)
|
|
126
|
+
c.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
|
127
|
+
c.translate(-offsetX, -offsetY)
|
|
125
128
|
try {
|
|
126
|
-
// ① Compute offset of view within root
|
|
127
|
-
val myLoc = IntArray(2); view.getLocationInWindow(myLoc)
|
|
128
|
-
val rootLoc = IntArray(2); rootView.getLocationInWindow(rootLoc)
|
|
129
|
-
val offsetX = myLoc[0] - rootLoc[0]
|
|
130
|
-
val offsetY = myLoc[1] - rootLoc[1]
|
|
131
|
-
|
|
132
|
-
// ② Allocate bitmaps (reuse if size matches)
|
|
133
|
-
val capture = reuseBitmap(captureBitmap, vw, vh).also { captureBitmap = it }
|
|
134
|
-
val scaled = reuseBitmap(scaledBitmap, sw, sh).also { scaledBitmap = it }
|
|
135
|
-
|
|
136
|
-
// ③ Capture just the region behind this view from root
|
|
137
|
-
val c = Canvas(capture)
|
|
138
|
-
c.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
|
139
|
-
c.translate(-offsetX.toFloat(), -offsetY.toFloat())
|
|
140
129
|
rootView.draw(c)
|
|
141
|
-
|
|
142
|
-
// ④ Downsample
|
|
143
|
-
val sc = Canvas(scaled)
|
|
144
|
-
sc.drawBitmap(capture,
|
|
145
|
-
Rect(0, 0, capture.width, capture.height),
|
|
146
|
-
Rect(0, 0, scaled.width, scaled.height),
|
|
147
|
-
capturePaint)
|
|
148
|
-
|
|
149
|
-
// ⑤ Blur (2 rounds for smooth spread)
|
|
150
|
-
repeat(BLUR_ROUNDS) { blurBitmap(scaled) }
|
|
151
|
-
|
|
152
|
-
// ⑥ Trigger redraw with new bitmap
|
|
153
|
-
view.invalidate()
|
|
154
|
-
|
|
155
130
|
} catch (_: Exception) {
|
|
156
|
-
} finally {
|
|
157
131
|
isCapturing = false
|
|
132
|
+
return
|
|
158
133
|
}
|
|
134
|
+
isCapturing = false
|
|
135
|
+
|
|
136
|
+
// Downsample
|
|
137
|
+
Canvas(scaled).drawBitmap(
|
|
138
|
+
capture,
|
|
139
|
+
Rect(0, 0, capture.width, capture.height),
|
|
140
|
+
Rect(0, 0, scaled.width, scaled.height),
|
|
141
|
+
capturePaint
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
// Blur (2 rounds)
|
|
145
|
+
repeat(BLUR_ROUNDS) { blurBitmap(scaled) }
|
|
146
|
+
|
|
147
|
+
view.invalidate()
|
|
159
148
|
}
|
|
160
149
|
|
|
161
150
|
private fun blurBitmap(bitmap: Bitmap) {
|
|
162
|
-
val
|
|
163
|
-
val sc =
|
|
151
|
+
val r = rs ?: return softwareBlur(bitmap)
|
|
152
|
+
val sc = blurScript ?: return softwareBlur(bitmap)
|
|
164
153
|
try {
|
|
165
|
-
val
|
|
166
|
-
val
|
|
167
|
-
|
|
154
|
+
val iA = reuseAlloc(inputAlloc, bitmap, r).also { inputAlloc = it }
|
|
155
|
+
val oA = reuseAlloc(outputAlloc, bitmap, r).also { outputAlloc = it }
|
|
156
|
+
iA.copyFrom(bitmap)
|
|
168
157
|
sc.setRadius(blurRadius.coerceIn(1f, 25f))
|
|
169
|
-
sc.setInput(
|
|
170
|
-
sc.forEach(
|
|
171
|
-
|
|
172
|
-
} catch (_: Exception) {
|
|
173
|
-
softwareBlur(bitmap)
|
|
174
|
-
}
|
|
158
|
+
sc.setInput(iA)
|
|
159
|
+
sc.forEach(oA)
|
|
160
|
+
oA.copyTo(bitmap)
|
|
161
|
+
} catch (_: Exception) { softwareBlur(bitmap) }
|
|
175
162
|
}
|
|
176
163
|
|
|
177
164
|
private fun softwareBlur(bitmap: Bitmap) {
|
|
178
|
-
// Pure software Gaussian fallback (slower but always works)
|
|
179
165
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
180
166
|
maskFilter = android.graphics.BlurMaskFilter(blurRadius, android.graphics.BlurMaskFilter.Blur.NORMAL)
|
|
181
167
|
}
|
|
182
168
|
Canvas(bitmap).drawBitmap(bitmap, 0f, 0f, paint)
|
|
183
169
|
}
|
|
184
170
|
|
|
185
|
-
// ── Draw
|
|
171
|
+
// ── Draw ─────────────────────────────────────────────────────────────────
|
|
186
172
|
|
|
187
173
|
fun draw(canvas: Canvas, viewWidth: Float, viewHeight: Float) {
|
|
188
174
|
scaledBitmap?.takeIf { !it.isRecycled }?.let { bmp ->
|
|
189
175
|
canvas.drawBitmap(bmp, null,
|
|
190
176
|
android.graphics.RectF(0f, 0f, viewWidth, viewHeight), drawPaint)
|
|
191
177
|
}
|
|
192
|
-
if (Color.alpha(overlayColor) > 0)
|
|
193
|
-
canvas.drawColor(overlayColor)
|
|
194
|
-
}
|
|
178
|
+
if (Color.alpha(overlayColor) > 0) canvas.drawColor(overlayColor)
|
|
195
179
|
}
|
|
196
180
|
|
|
197
|
-
// ──
|
|
181
|
+
// ── Multi-window ──────────────────────────────────────────────────────────
|
|
198
182
|
|
|
199
|
-
fun
|
|
200
|
-
|
|
183
|
+
fun reAttach() {
|
|
184
|
+
if (enabled && autoUpdate) safeAddPreDrawListener()
|
|
201
185
|
}
|
|
202
186
|
|
|
187
|
+
private fun safeAddPreDrawListener() {
|
|
188
|
+
val vto = rootView.viewTreeObserver
|
|
189
|
+
vto.removeOnPreDrawListener(preDrawListener)
|
|
190
|
+
if (vto.isAlive) vto.addOnPreDrawListener(preDrawListener)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
fun onSizeChanged() { invalidatePool() }
|
|
196
|
+
|
|
203
197
|
private fun invalidatePool() {
|
|
204
198
|
captureBitmap?.recycle(); captureBitmap = null
|
|
205
199
|
scaledBitmap?.recycle(); scaledBitmap = null
|
|
@@ -218,7 +212,7 @@ internal class LegacyBlurController(
|
|
|
218
212
|
scaledBitmap?.recycle()
|
|
219
213
|
}
|
|
220
214
|
|
|
221
|
-
// ──
|
|
215
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
222
216
|
|
|
223
217
|
private fun reuseBitmap(existing: Bitmap?, w: Int, h: Int): Bitmap {
|
|
224
218
|
if (existing != null && !existing.isRecycled
|
|
@@ -228,9 +222,8 @@ internal class LegacyBlurController(
|
|
|
228
222
|
}
|
|
229
223
|
|
|
230
224
|
private fun reuseAlloc(existing: Allocation?, src: Bitmap, rs: RenderScript): Allocation {
|
|
231
|
-
if (existing != null
|
|
232
|
-
|
|
233
|
-
&& existing.type.y == src.height) return existing
|
|
225
|
+
if (existing != null && existing.type.x == src.width && existing.type.y == src.height)
|
|
226
|
+
return existing
|
|
234
227
|
existing?.destroy()
|
|
235
228
|
return Allocation.createFromBitmap(rs, src,
|
|
236
229
|
Allocation.MipmapControl.MIPMAP_NONE, Allocation.USAGE_SCRIPT)
|
package/package.json
CHANGED