react-native-blur-vibe 0.1.2 → 0.1.3
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 +7 -0
- package/android/src/main/java/com/blurvibe/BlurVibeView.kt +169 -116
- package/android/src/main/java/com/blurvibe/BlurVibeViewManager.kt +10 -11
- package/ios/BlurVibeView.swift +113 -72
- package/ios/Views/BlurEffectView.swift +85 -0
- package/ios/Views/BlurVibeSwiftUIView.swift +46 -0
- package/ios/react-native-blur-vibe.podspec +4 -2
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -1,162 +1,217 @@
|
|
|
1
1
|
package com.blurvibe
|
|
2
2
|
|
|
3
|
-
import android.annotation.SuppressLint
|
|
4
3
|
import android.content.Context
|
|
5
|
-
import android.graphics.Bitmap
|
|
6
|
-
import android.graphics.Canvas
|
|
7
4
|
import android.graphics.Color
|
|
8
|
-
import android.graphics.
|
|
9
|
-
import android.
|
|
10
|
-
import android.os.Build
|
|
11
|
-
import android.renderscript.Allocation
|
|
12
|
-
import android.renderscript.Element
|
|
13
|
-
import android.renderscript.RenderScript
|
|
14
|
-
import android.renderscript.ScriptIntrinsicBlur
|
|
5
|
+
import android.graphics.Outline
|
|
6
|
+
import android.util.TypedValue
|
|
15
7
|
import android.view.View
|
|
16
8
|
import android.view.ViewGroup
|
|
17
|
-
import android.
|
|
9
|
+
import android.view.ViewOutlineProvider
|
|
10
|
+
import android.view.ViewTreeObserver
|
|
11
|
+
import androidx.core.graphics.toColorInt
|
|
12
|
+
import com.qmdeve.blurview.base.BaseBlurViewGroup
|
|
13
|
+
import com.qmdeve.blurview.widget.BlurViewGroup
|
|
18
14
|
|
|
19
15
|
/**
|
|
20
|
-
* BlurVibeView
|
|
16
|
+
* BlurVibeView — Android backdrop blur implementation
|
|
21
17
|
*
|
|
22
|
-
* Extends
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
18
|
+
* Extends QmBlurView's BlurViewGroup — a high-performance blur library
|
|
19
|
+
* that correctly implements CSS backdrop-filter: blur() semantics:
|
|
20
|
+
* - Blurs content BEHIND the view, not the view itself
|
|
21
|
+
* - Hardware accelerated via native blur algorithms
|
|
22
|
+
* - Handles scroll, animation, zIndex, absolute positioning correctly
|
|
23
|
+
* - Never causes draw loops or bitmap capture on the JS thread
|
|
26
24
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* Uses reflection to redirect the blur capture root from the activity
|
|
26
|
+
* decor view to the nearest ReactRootView or react-native-screens Screen,
|
|
27
|
+
* preventing full-screen blur and navigation transition artifacts.
|
|
30
28
|
*
|
|
31
|
-
*
|
|
32
|
-
* Supports: "transparent", "#RGB", "#RRGGBB", "#RRGGBBAA"
|
|
29
|
+
* Credit: approach adapted from sbaiahmed1/react-native-blur
|
|
33
30
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
private var
|
|
39
|
-
private var
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
class BlurVibeView(context: Context) : BlurViewGroup(context, null) {
|
|
32
|
+
|
|
33
|
+
private var currentBlurRadius = DEFAULT_BLUR_RADIUS
|
|
34
|
+
private var currentOverlayColor = Color.TRANSPARENT
|
|
35
|
+
private var currentCornerRadius = 0f
|
|
36
|
+
private var isBlurInitialized = false
|
|
37
|
+
|
|
38
|
+
companion object {
|
|
39
|
+
private const val DEFAULT_BLUR_RADIUS = 10f
|
|
40
|
+
private const val MIN_BLUR_AMOUNT = 0f
|
|
41
|
+
private const val MAX_BLUR_AMOUNT = 100f
|
|
42
|
+
private const val MAX_BLUR_RADIUS = 100f
|
|
43
|
+
|
|
44
|
+
// Maps 0–100 blurAmount to 0–25 QmBlurView radius range
|
|
45
|
+
private fun mapBlurAmountToRadius(amount: Float): Float {
|
|
46
|
+
val clamped = amount.coerceIn(MIN_BLUR_AMOUNT, MAX_BLUR_AMOUNT)
|
|
47
|
+
return (clamped / MAX_BLUR_AMOUNT) * MAX_BLUR_RADIUS
|
|
48
|
+
}
|
|
49
|
+
}
|
|
42
50
|
|
|
43
51
|
init {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
super.
|
|
52
|
+
super.setBackgroundColor(currentOverlayColor)
|
|
53
|
+
clipChildren = true
|
|
54
|
+
clipToOutline = true
|
|
55
|
+
blurRounds = 5
|
|
56
|
+
super.setDownsampleFactor(6.0f)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override fun onAttachedToWindow() {
|
|
60
|
+
super.onAttachedToWindow()
|
|
61
|
+
if (isBlurInitialized) return
|
|
62
|
+
swapBlurRootToOptimalAncestor()
|
|
63
|
+
initializeBlur()
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
override fun onDetachedFromWindow() {
|
|
67
|
+
super.onDetachedFromWindow()
|
|
68
|
+
isBlurInitialized = false
|
|
69
|
+
}
|
|
70
|
+
|
|
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
|
|
57
87
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
val decorViewField = baseClass.getDeclaredField("mDecorView")
|
|
89
|
+
decorViewField.isAccessible = true
|
|
90
|
+
val oldDecorView = decorViewField.get(baseBlurViewGroup) as? View
|
|
91
|
+
|
|
92
|
+
val preDrawListenerField = baseClass.getDeclaredField("preDrawListener")
|
|
93
|
+
preDrawListenerField.isAccessible = true
|
|
94
|
+
val preDrawListener = preDrawListenerField.get(baseBlurViewGroup)
|
|
95
|
+
as? ViewTreeObserver.OnPreDrawListener
|
|
96
|
+
|
|
97
|
+
if (oldDecorView != null && preDrawListener != null) {
|
|
98
|
+
// Remove listener from old root
|
|
99
|
+
oldDecorView.viewTreeObserver.removeOnPreDrawListener(preDrawListener)
|
|
100
|
+
|
|
101
|
+
// Set new root
|
|
102
|
+
decorViewField.set(baseBlurViewGroup, newRoot)
|
|
103
|
+
|
|
104
|
+
// Add listener to new root
|
|
105
|
+
newRoot.viewTreeObserver.addOnPreDrawListener(preDrawListener)
|
|
106
|
+
|
|
107
|
+
// Update mDifferentRoot flag
|
|
108
|
+
val differentRootField = baseClass.getDeclaredField("mDifferentRoot")
|
|
109
|
+
differentRootField.isAccessible = true
|
|
110
|
+
differentRootField.setBoolean(baseBlurViewGroup, newRoot.rootView != this.rootView)
|
|
111
|
+
|
|
112
|
+
// Force redraw
|
|
113
|
+
val forceRedrawField = baseClass.getDeclaredField("mForceRedraw")
|
|
114
|
+
forceRedrawField.isAccessible = true
|
|
115
|
+
forceRedrawField.setBoolean(baseBlurViewGroup, true)
|
|
116
|
+
}
|
|
117
|
+
} catch (e: Exception) {
|
|
118
|
+
// Reflection failed — QmBlurView internals changed
|
|
119
|
+
// Fall back gracefully to default decor view blur root
|
|
62
120
|
}
|
|
63
|
-
// React children always go on top of overlay
|
|
64
|
-
super.addView(child, childCount)
|
|
65
121
|
}
|
|
66
122
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
123
|
+
private fun findNearestScreenAncestor(): ViewGroup? {
|
|
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
|
|
71
130
|
}
|
|
72
|
-
|
|
131
|
+
return null
|
|
73
132
|
}
|
|
74
133
|
|
|
75
|
-
|
|
134
|
+
private fun findNearestReactRootView(): ViewGroup? {
|
|
135
|
+
var current = parent
|
|
136
|
+
while (current != null) {
|
|
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
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// MARK: - Public setters
|
|
76
158
|
|
|
77
159
|
fun setBlurAmount(amount: Float) {
|
|
78
|
-
|
|
79
|
-
|
|
160
|
+
currentBlurRadius = mapBlurAmountToRadius(amount)
|
|
161
|
+
try { super.setBlurRadius(currentBlurRadius) } catch (e: Exception) {}
|
|
80
162
|
}
|
|
81
163
|
|
|
82
164
|
fun setOverlayColor(colorString: String?) {
|
|
83
|
-
|
|
84
|
-
|
|
165
|
+
currentOverlayColor = parseHexColor(colorString ?: "transparent") ?: Color.TRANSPARENT
|
|
166
|
+
try {
|
|
167
|
+
super.setBackgroundColor(currentOverlayColor)
|
|
168
|
+
super.setOverlayColor(currentOverlayColor)
|
|
169
|
+
} catch (e: Exception) {}
|
|
85
170
|
}
|
|
86
171
|
|
|
87
172
|
fun setReducedTransparencyFallbackColor(colorString: String?) {
|
|
88
|
-
|
|
173
|
+
// Stored for future use — QmBlurView handles accessibility fallback internally
|
|
89
174
|
}
|
|
90
175
|
|
|
91
176
|
fun setBlurRadius(radius: Int) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
177
|
+
// blurRadius is the Android downscale factor — map to QmBlurView's downsample factor
|
|
178
|
+
val downsample = radius.coerceIn(1, 8).toFloat()
|
|
179
|
+
try { super.setDownsampleFactor(downsample) } catch (e: Exception) {}
|
|
96
180
|
}
|
|
97
181
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
102
|
-
applyRenderEffect()
|
|
103
|
-
} else {
|
|
104
|
-
post { renderScriptBlur() }
|
|
105
|
-
}
|
|
182
|
+
fun setBorderRadius(radius: Float) {
|
|
183
|
+
currentCornerRadius = radius
|
|
184
|
+
updateCornerRadius()
|
|
106
185
|
}
|
|
107
186
|
|
|
108
|
-
private fun
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
private fun renderScriptBlur() {
|
|
119
|
-
val parentView = parent as? ViewGroup ?: return
|
|
120
|
-
if (width <= 0 || height <= 0) return
|
|
121
|
-
try {
|
|
122
|
-
val scaledW = (width / blurRadiusDownscale).coerceAtLeast(1)
|
|
123
|
-
val scaledH = (height / blurRadiusDownscale).coerceAtLeast(1)
|
|
124
|
-
val bitmap = Bitmap.createBitmap(scaledW, scaledH, Bitmap.Config.ARGB_8888)
|
|
125
|
-
val canvas = Canvas(bitmap)
|
|
126
|
-
canvas.scale(1f / blurRadiusDownscale, 1f / blurRadiusDownscale)
|
|
127
|
-
canvas.translate(-left.toFloat(), -top.toFloat())
|
|
128
|
-
parentView.draw(canvas)
|
|
129
|
-
|
|
130
|
-
val rs = RenderScript.create(context)
|
|
131
|
-
val input = Allocation.createFromBitmap(rs, bitmap)
|
|
132
|
-
val output = Allocation.createTyped(rs, input.type)
|
|
133
|
-
val script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
|
|
134
|
-
val sigma = (blurAmountValue / 100f * 25f).coerceIn(1f, 25f)
|
|
135
|
-
script.setRadius(sigma)
|
|
136
|
-
script.setInput(input)
|
|
137
|
-
script.forEach(output)
|
|
138
|
-
output.copyTo(bitmap)
|
|
139
|
-
rs.destroy()
|
|
140
|
-
|
|
141
|
-
background = android.graphics.drawable.BitmapDrawable(resources, bitmap)
|
|
142
|
-
} catch (e: Exception) {
|
|
143
|
-
setBackgroundColor(fallbackColorValue)
|
|
187
|
+
private fun updateCornerRadius() {
|
|
188
|
+
val radiusPx = TypedValue.applyDimension(
|
|
189
|
+
TypedValue.COMPLEX_UNIT_DIP,
|
|
190
|
+
currentCornerRadius,
|
|
191
|
+
context.resources.displayMetrics
|
|
192
|
+
)
|
|
193
|
+
outlineProvider = object : ViewOutlineProvider() {
|
|
194
|
+
override fun getOutline(view: View, outline: Outline) {
|
|
195
|
+
outline.setRoundRect(0, 0, view.width, view.height, radiusPx)
|
|
196
|
+
}
|
|
144
197
|
}
|
|
198
|
+
clipToOutline = true
|
|
199
|
+
try { super.setCornerRadius(radiusPx) } catch (e: Exception) {}
|
|
145
200
|
}
|
|
146
201
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
post { renderScriptBlur() }
|
|
151
|
-
}
|
|
202
|
+
// React Native handles layout — prevent superclass from interfering
|
|
203
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
204
|
+
// No-op: layout handled by React Native's Yoga engine
|
|
152
205
|
}
|
|
153
206
|
|
|
154
|
-
// MARK: -
|
|
207
|
+
// MARK: - Color parser
|
|
155
208
|
// Supports: "transparent", "#RGB", "#RRGGBB", "#RRGGBBAA"
|
|
156
209
|
private fun parseHexColor(colorString: String): Int? {
|
|
157
210
|
val s = colorString.trim()
|
|
158
211
|
if (s.equals("transparent", ignoreCase = true)) return Color.TRANSPARENT
|
|
159
|
-
if (!s.startsWith("#"))
|
|
212
|
+
if (!s.startsWith("#")) {
|
|
213
|
+
return try { s.toColorInt() } catch (e: Exception) { null }
|
|
214
|
+
}
|
|
160
215
|
val hex = s.removePrefix("#")
|
|
161
216
|
return try {
|
|
162
217
|
when (hex.length) {
|
|
@@ -173,15 +228,13 @@ class BlurVibeView(context: Context) : FrameLayout(context) {
|
|
|
173
228
|
hex.substring(4, 6).toInt(16)
|
|
174
229
|
)
|
|
175
230
|
8 -> Color.argb(
|
|
176
|
-
hex.substring(6, 8).toInt(16), //
|
|
231
|
+
hex.substring(6, 8).toInt(16), // AA is last in #RRGGBBAA
|
|
177
232
|
hex.substring(0, 2).toInt(16),
|
|
178
233
|
hex.substring(2, 4).toInt(16),
|
|
179
234
|
hex.substring(4, 6).toInt(16)
|
|
180
235
|
)
|
|
181
236
|
else -> null
|
|
182
237
|
}
|
|
183
|
-
} catch (e: NumberFormatException) {
|
|
184
|
-
null
|
|
185
|
-
}
|
|
238
|
+
} catch (e: NumberFormatException) { null }
|
|
186
239
|
}
|
|
187
240
|
}
|
|
@@ -7,10 +7,8 @@ import com.facebook.react.uimanager.annotations.ReactProp
|
|
|
7
7
|
/**
|
|
8
8
|
* BlurVibeViewManager
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* SimpleViewManager cast to IViewGroupManager crashes at runtime.
|
|
13
|
-
* ViewGroupManager correctly implements IViewGroupManager interface.
|
|
10
|
+
* ViewGroupManager — BlurVibeView (which extends BlurViewGroup/FrameLayout)
|
|
11
|
+
* hosts React children, so we must use ViewGroupManager, not SimpleViewManager.
|
|
14
12
|
*/
|
|
15
13
|
class BlurVibeViewManager : ViewGroupManager<BlurVibeView>() {
|
|
16
14
|
|
|
@@ -18,34 +16,35 @@ class BlurVibeViewManager : ViewGroupManager<BlurVibeView>() {
|
|
|
18
16
|
|
|
19
17
|
override fun createViewInstance(context: ThemedReactContext) = BlurVibeView(context)
|
|
20
18
|
|
|
21
|
-
// Float — matches TS NativeComponent Float
|
|
22
19
|
@ReactProp(name = "blurAmount", defaultFloat = 10f)
|
|
23
20
|
fun setBlurAmount(view: BlurVibeView, amount: Float) {
|
|
24
21
|
view.setBlurAmount(amount)
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
// String — matches TS NativeComponent string (no-op on Android)
|
|
28
24
|
@ReactProp(name = "blurType")
|
|
29
25
|
fun setBlurType(view: BlurVibeView, type: String?) {
|
|
30
|
-
// No-op — blurType maps to iOS UIBlurEffectStyle only
|
|
26
|
+
// No-op on Android — blurType maps to iOS UIBlurEffectStyle only
|
|
31
27
|
}
|
|
32
28
|
|
|
33
|
-
// String — matches TS NativeComponent string
|
|
34
|
-
// Parsed as hex in BlurVibeView — no customType="Color" needed
|
|
35
29
|
@ReactProp(name = "overlayColor")
|
|
36
30
|
fun setOverlayColor(view: BlurVibeView, color: String?) {
|
|
37
31
|
view.setOverlayColor(color)
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
// String — matches TS NativeComponent string
|
|
41
34
|
@ReactProp(name = "reducedTransparencyFallbackColor")
|
|
42
35
|
fun setReducedTransparencyFallbackColor(view: BlurVibeView, color: String?) {
|
|
43
36
|
view.setReducedTransparencyFallbackColor(color)
|
|
44
37
|
}
|
|
45
38
|
|
|
46
|
-
// Int — matches TS NativeComponent Int32
|
|
47
39
|
@ReactProp(name = "blurRadius", defaultInt = 4)
|
|
48
40
|
fun setBlurRadius(view: BlurVibeView, radius: Int) {
|
|
49
41
|
view.setBlurRadius(radius)
|
|
50
42
|
}
|
|
43
|
+
|
|
44
|
+
override fun onDropViewInstance(view: BlurVibeView) {
|
|
45
|
+
super.onDropViewInstance(view)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// React Native's Yoga handles child layout — return false
|
|
49
|
+
override fun needsCustomLayoutForChildren(): Boolean = false
|
|
51
50
|
}
|
package/ios/BlurVibeView.swift
CHANGED
|
@@ -1,92 +1,106 @@
|
|
|
1
|
+
// BlurVibeView.swift
|
|
2
|
+
// UIKit wrapper that hosts the SwiftUI blur view via UIHostingController.
|
|
3
|
+
// Approach mirrors sbaiahmed1/react-native-blur's AdvancedBlurView.
|
|
4
|
+
|
|
5
|
+
import SwiftUI
|
|
1
6
|
import UIKit
|
|
2
7
|
|
|
3
8
|
@objc(BlurVibeView)
|
|
4
9
|
class BlurVibeView: UIView {
|
|
5
10
|
|
|
6
|
-
// MARK: - Private
|
|
7
|
-
private var blurEffectView: UIVisualEffectView?
|
|
8
|
-
private let overlayView = UIView()
|
|
11
|
+
// MARK: - Private
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
private var hostingController: UIHostingController<BlurVibeSwiftUIView>?
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
@objc var blurAmount: NSNumber = 10 { didSet { updateBlur() } }
|
|
15
|
+
// MARK: - Props
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
@objc var blurType: NSString = "light" { didSet {
|
|
17
|
+
@objc var blurAmount: NSNumber = 10 { didSet { updateView() } }
|
|
18
|
+
@objc var blurType: NSString = "light" { didSet { updateView() } }
|
|
17
19
|
|
|
18
|
-
///
|
|
19
|
-
///
|
|
20
|
-
|
|
21
|
-
@objc var overlayColor: NSString = "transparent" { didSet { updateOverlay() } }
|
|
20
|
+
/// Hex overlay color on top of blur — works on iOS AND Android.
|
|
21
|
+
/// "#00000000" = transparent (pure blur), "#00000080" = tinted blur
|
|
22
|
+
@objc var overlayColor: NSString = "transparent" { didSet { updateView() } }
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
@objc var reducedTransparencyFallbackColor: NSString = "#F2F2F2" { didSet { updateBlur() } }
|
|
24
|
+
@objc var reducedTransparencyFallbackColor: NSString = "#F2F2F2" { didSet { updateView() } }
|
|
25
25
|
|
|
26
|
-
/// Android-only downscale factor
|
|
26
|
+
/// Android-only downscale factor — accepted here as no-op to avoid prop warning
|
|
27
27
|
@objc var blurRadius: NSNumber = 4
|
|
28
28
|
|
|
29
29
|
// MARK: - Init
|
|
30
|
-
override init(frame: CGRect) { super.init(frame: frame); commonInit() }
|
|
31
|
-
required init?(coder: NSCoder) { super.init(coder: coder); commonInit() }
|
|
32
30
|
|
|
33
|
-
|
|
31
|
+
override init(frame: CGRect) {
|
|
32
|
+
super.init(frame: frame)
|
|
33
|
+
backgroundColor = .clear
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
required init?(coder: NSCoder) {
|
|
37
|
+
super.init(coder: coder)
|
|
34
38
|
backgroundColor = .clear
|
|
35
|
-
clipsToBounds = true
|
|
36
|
-
overlayView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
37
|
-
overlayView.isUserInteractionEnabled = false
|
|
38
|
-
updateBlur()
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// MARK: - Layout
|
|
42
|
+
|
|
42
43
|
override func layoutSubviews() {
|
|
43
44
|
super.layoutSubviews()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
// Defer hosting controller setup until we have a valid frame
|
|
46
|
+
// Prevents issues with initial render in complex layouts (e.g. FlashList)
|
|
47
|
+
if hostingController == nil && bounds.width > 0 && bounds.height > 0 {
|
|
48
|
+
setupHostingController()
|
|
49
|
+
} else {
|
|
50
|
+
hostingController?.view.frame = bounds
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
// MARK: -
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return
|
|
54
|
+
// MARK: - Hosting Controller
|
|
55
|
+
|
|
56
|
+
private func setupHostingController() {
|
|
57
|
+
// Remove existing hosting controller cleanly
|
|
58
|
+
if let old = hostingController {
|
|
59
|
+
old.view.removeFromSuperview()
|
|
60
|
+
old.removeFromParent()
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
hostingController = nil
|
|
63
|
+
|
|
64
|
+
let swiftUIView = makeSwiftUIView()
|
|
65
|
+
let hosting = UIHostingController(rootView: swiftUIView)
|
|
66
|
+
hosting.view.backgroundColor = .clear
|
|
67
|
+
hosting.view.frame = bounds
|
|
68
|
+
hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
69
|
+
|
|
70
|
+
// Insert at index 0 — stays behind React children
|
|
71
|
+
if !subviews.isEmpty {
|
|
72
|
+
insertSubview(hosting.view, at: 0)
|
|
65
73
|
} else {
|
|
66
|
-
|
|
67
|
-
newBlurView.frame = bounds
|
|
68
|
-
newBlurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
69
|
-
blurEffectView = newBlurView
|
|
70
|
-
insertSubview(newBlurView, at: 0)
|
|
71
|
-
}
|
|
72
|
-
if overlayView.superview == nil {
|
|
73
|
-
insertSubview(overlayView, aboveSubview: blurEffectView!)
|
|
74
|
+
addSubview(hosting.view)
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
+
|
|
77
|
+
hostingController = hosting
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
private func
|
|
79
|
-
let
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
} else {
|
|
83
|
-
|
|
80
|
+
private func updateView() {
|
|
81
|
+
if let hosting = hostingController {
|
|
82
|
+
// Update root view without recreating the controller — avoids jank
|
|
83
|
+
hosting.rootView = makeSwiftUIView()
|
|
84
|
+
} else if bounds.width > 0 && bounds.height > 0 {
|
|
85
|
+
setupHostingController()
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
private func makeSwiftUIView() -> BlurVibeSwiftUIView {
|
|
90
|
+
return BlurVibeSwiftUIView(
|
|
91
|
+
blurAmount: Double(truncating: blurAmount),
|
|
92
|
+
blurStyle: blurStyleFromString(blurType as String),
|
|
93
|
+
overlayColor: parseColor(overlayColor as String) ?? .clear,
|
|
94
|
+
reducedTransparencyFallbackColor: parseColor(reducedTransparencyFallbackColor as String)
|
|
95
|
+
?? UIColor(white: 0.95, alpha: 1)
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
// MARK: - Blur Style Map
|
|
88
|
-
|
|
100
|
+
|
|
101
|
+
private func blurStyleFromString(_ type: String) -> UIBlurEffect.Style {
|
|
89
102
|
switch type {
|
|
103
|
+
case "xlight": return .extraLight
|
|
90
104
|
case "dark": return .dark
|
|
91
105
|
case "extraLight": return .extraLight
|
|
92
106
|
case "regular": return .regular
|
|
@@ -111,34 +125,61 @@ class BlurVibeView: UIView {
|
|
|
111
125
|
}
|
|
112
126
|
|
|
113
127
|
// MARK: - Color Parser
|
|
114
|
-
// Supports: "transparent", "#RGB", "#RRGGBB", "#RRGGBBAA"
|
|
128
|
+
// Supports: "transparent", named colors, "#RGB", "#RRGGBB", "#RRGGBBAA"
|
|
129
|
+
|
|
115
130
|
private func parseColor(_ colorString: String) -> UIColor? {
|
|
131
|
+
let s = colorString.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
132
|
+
|
|
133
|
+
// Named colors
|
|
134
|
+
let namedColors: [String: UIColor] = [
|
|
135
|
+
"transparent": .clear, "clear": .clear,
|
|
136
|
+
"white": .white, "black": .black,
|
|
137
|
+
"red": .red, "green": .green, "blue": .blue,
|
|
138
|
+
"gray": .gray, "grey": .gray,
|
|
139
|
+
]
|
|
140
|
+
if let named = namedColors[s] { return named }
|
|
141
|
+
|
|
142
|
+
// Hex colors
|
|
116
143
|
var hex = colorString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
117
144
|
guard hex.hasPrefix("#") else { return nil }
|
|
118
145
|
hex.removeFirst()
|
|
146
|
+
|
|
147
|
+
// Validate hex chars
|
|
148
|
+
let validHex = CharacterSet(charactersIn: "0123456789ABCDEFabcdef")
|
|
149
|
+
guard hex.unicodeScalars.allSatisfy({ validHex.contains($0) }) else { return nil }
|
|
150
|
+
|
|
119
151
|
var rgbValue: UInt64 = 0
|
|
120
152
|
Scanner(string: hex).scanHexInt64(&rgbValue)
|
|
153
|
+
|
|
121
154
|
switch hex.count {
|
|
122
|
-
case 3: // #RGB
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
155
|
+
case 3: // #RGB → expand
|
|
156
|
+
let r = (rgbValue & 0xF00) >> 8; let g = (rgbValue & 0x0F0) >> 4; let b = rgbValue & 0x00F
|
|
157
|
+
return UIColor(red: CGFloat(r | (r << 4)) / 255, green: CGFloat(g | (g << 4)) / 255,
|
|
158
|
+
blue: CGFloat(b | (b << 4)) / 255, alpha: 1)
|
|
159
|
+
case 4: // #RGBA → expand
|
|
160
|
+
let r = (rgbValue & 0xF000) >> 12; let g = (rgbValue & 0x0F00) >> 8
|
|
161
|
+
let b = (rgbValue & 0x00F0) >> 4; let a = rgbValue & 0x000F
|
|
162
|
+
return UIColor(red: CGFloat(r | (r << 4)) / 255, green: CGFloat(g | (g << 4)) / 255,
|
|
163
|
+
blue: CGFloat(b | (b << 4)) / 255, alpha: CGFloat(a | (a << 4)) / 255)
|
|
128
164
|
case 6: // #RRGGBB
|
|
129
|
-
return UIColor(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
blue: CGFloat( rgbValue & 0x0000FF ) / 255,
|
|
133
|
-
alpha: 1)
|
|
165
|
+
return UIColor(red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255,
|
|
166
|
+
green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255,
|
|
167
|
+
blue: CGFloat(rgbValue & 0x0000FF) / 255, alpha: 1)
|
|
134
168
|
case 8: // #RRGGBBAA
|
|
135
|
-
return UIColor(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
alpha: CGFloat( rgbValue & 0x000000FF ) / 255)
|
|
169
|
+
return UIColor(red: CGFloat((rgbValue & 0xFF000000) >> 24) / 255,
|
|
170
|
+
green: CGFloat((rgbValue & 0x00FF0000) >> 16) / 255,
|
|
171
|
+
blue: CGFloat((rgbValue & 0x0000FF00) >> 8) / 255,
|
|
172
|
+
alpha: CGFloat(rgbValue & 0x000000FF) / 255)
|
|
140
173
|
default:
|
|
141
174
|
return nil
|
|
142
175
|
}
|
|
143
176
|
}
|
|
177
|
+
|
|
178
|
+
// MARK: - Cleanup
|
|
179
|
+
|
|
180
|
+
deinit {
|
|
181
|
+
hostingController?.view.removeFromSuperview()
|
|
182
|
+
hostingController?.removeFromParent()
|
|
183
|
+
hostingController = nil
|
|
184
|
+
}
|
|
144
185
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// BlurEffectView.swift
|
|
2
|
+
// UIVisualEffectView with UIViewPropertyAnimator for custom blur intensity.
|
|
3
|
+
// This is the ONLY correct way to achieve custom blur radius on iOS.
|
|
4
|
+
// UIBlurEffect itself ignores any custom intensity — the animator interpolates it.
|
|
5
|
+
|
|
6
|
+
import SwiftUI
|
|
7
|
+
import UIKit
|
|
8
|
+
|
|
9
|
+
// MARK: - UIKit blur with custom intensity
|
|
10
|
+
|
|
11
|
+
class BlurEffectView: UIVisualEffectView {
|
|
12
|
+
private var animator: UIViewPropertyAnimator?
|
|
13
|
+
private var blurStyle: UIBlurEffect.Style = .systemMaterial
|
|
14
|
+
private var intensity: Double = 1.0
|
|
15
|
+
|
|
16
|
+
override init(effect: UIVisualEffect?) {
|
|
17
|
+
super.init(effect: effect)
|
|
18
|
+
setupBlur()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
required init?(coder: NSCoder) {
|
|
22
|
+
super.init(coder: coder)
|
|
23
|
+
setupBlur()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func updateBlur(style: UIBlurEffect.Style, intensity: Double) {
|
|
27
|
+
// Skip expensive animator recreation when nothing changed
|
|
28
|
+
guard style != self.blurStyle || intensity != self.intensity else { return }
|
|
29
|
+
self.blurStyle = style
|
|
30
|
+
self.intensity = intensity
|
|
31
|
+
setupBlur()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
override func didMoveToWindow() {
|
|
35
|
+
super.didMoveToWindow()
|
|
36
|
+
guard window != nil else { return }
|
|
37
|
+
// UIKit resumes paused CAAnimations when view re-joins a window.
|
|
38
|
+
// Re-pause and re-lock the fraction to prevent blur drifting to full intensity.
|
|
39
|
+
animator?.pauseAnimation()
|
|
40
|
+
animator?.fractionComplete = intensity
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private func setupBlur() {
|
|
44
|
+
if let existing = animator, existing.state == .active {
|
|
45
|
+
existing.stopAnimation(true)
|
|
46
|
+
}
|
|
47
|
+
animator = nil
|
|
48
|
+
effect = nil
|
|
49
|
+
|
|
50
|
+
let newAnimator = UIViewPropertyAnimator(duration: 1, curve: .linear)
|
|
51
|
+
newAnimator.addAnimations { [weak self] in
|
|
52
|
+
self?.effect = UIBlurEffect(style: self?.blurStyle ?? .systemMaterial)
|
|
53
|
+
}
|
|
54
|
+
// pausesOnCompletion: keeps animator .active even at fraction 1.0
|
|
55
|
+
// so didMoveToWindow can always safely call pauseAnimation()
|
|
56
|
+
newAnimator.pausesOnCompletion = true
|
|
57
|
+
newAnimator.startAnimation()
|
|
58
|
+
newAnimator.pauseAnimation()
|
|
59
|
+
newAnimator.fractionComplete = intensity
|
|
60
|
+
animator = newAnimator
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
deinit {
|
|
64
|
+
if let animator = animator, animator.state == .active {
|
|
65
|
+
animator.stopAnimation(true)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// MARK: - SwiftUI wrapper for BlurEffectView
|
|
71
|
+
|
|
72
|
+
struct BlurVibeEffect: UIViewRepresentable {
|
|
73
|
+
var style: UIBlurEffect.Style = .systemMaterial
|
|
74
|
+
var intensity: Double = 1.0
|
|
75
|
+
|
|
76
|
+
func makeUIView(context: Context) -> BlurEffectView {
|
|
77
|
+
let view = BlurEffectView(effect: nil)
|
|
78
|
+
view.updateBlur(style: style, intensity: intensity)
|
|
79
|
+
return view
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func updateUIView(_ uiView: BlurEffectView, context: Context) {
|
|
83
|
+
uiView.updateBlur(style: style, intensity: intensity)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// BlurVibeSwiftUIView.swift
|
|
2
|
+
// SwiftUI view that composes blur effect + overlay color.
|
|
3
|
+
|
|
4
|
+
import SwiftUI
|
|
5
|
+
import UIKit
|
|
6
|
+
|
|
7
|
+
struct BlurVibeSwiftUIView: View {
|
|
8
|
+
let blurAmount: Double
|
|
9
|
+
let blurStyle: UIBlurEffect.Style
|
|
10
|
+
let overlayColor: UIColor
|
|
11
|
+
let reducedTransparencyFallbackColor: UIColor
|
|
12
|
+
|
|
13
|
+
private let isReducedTransparencyEnabled = UIAccessibility.isReduceTransparencyEnabled
|
|
14
|
+
|
|
15
|
+
// Map 0–100 blurAmount to 0.0–1.0 animator fraction
|
|
16
|
+
private var blurIntensity: Double {
|
|
17
|
+
(blurAmount / 100.0).clamped(to: 0.0...1.0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var body: some View {
|
|
21
|
+
if isReducedTransparencyEnabled {
|
|
22
|
+
// Accessibility: Reduce Transparency is ON — show solid fallback color
|
|
23
|
+
Rectangle()
|
|
24
|
+
.fill(Color(reducedTransparencyFallbackColor))
|
|
25
|
+
} else {
|
|
26
|
+
ZStack {
|
|
27
|
+
// Layer 1: backdrop blur (what's behind this view gets blurred)
|
|
28
|
+
BlurVibeEffect(style: blurStyle, intensity: blurIntensity)
|
|
29
|
+
|
|
30
|
+
// Layer 2: overlay color with alpha on top of blur
|
|
31
|
+
// This is our overlayColor prop — same as CSS background-color with alpha
|
|
32
|
+
// "#00000000" = transparent = pure blur shows through
|
|
33
|
+
// "#00000080" = 50% black tint over blur
|
|
34
|
+
// "#000000FF" = fully opaque = blur hidden
|
|
35
|
+
Rectangle()
|
|
36
|
+
.fill(Color(overlayColor))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
extension Comparable {
|
|
43
|
+
func clamped(to range: ClosedRange<Self>) -> Self {
|
|
44
|
+
min(max(self, range.lowerBound), range.upperBound)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -9,13 +9,15 @@ Pod::Spec.new do |s|
|
|
|
9
9
|
s.homepage = package["homepage"]
|
|
10
10
|
s.license = package["license"]
|
|
11
11
|
s.authors = package["author"]
|
|
12
|
-
s.platforms = { :ios => "
|
|
12
|
+
s.platforms = { :ios => "14.0" }
|
|
13
13
|
s.source = { :git => "https://github.com/I-am-Pritam-20/react-native-blur-vibe.git", :tag => "#{s.version}" }
|
|
14
|
+
|
|
15
|
+
# Include all Swift and ObjC files in ios/ and ios/Views/
|
|
14
16
|
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
|
17
|
+
|
|
15
18
|
s.requires_arc = true
|
|
16
19
|
|
|
17
20
|
s.dependency "React-Core"
|
|
18
21
|
|
|
19
|
-
# New Architecture (Fabric) support
|
|
20
22
|
install_modules_dependencies(s)
|
|
21
23
|
end
|