Skip to content

Commit c2ba382

Browse files
authored
Merge branch 'main' into feat/add-blur-rounds-android
2 parents 756e924 + 3879838 commit c2ba382

12 files changed

Lines changed: 189 additions & 165 deletions

android/src/main/java/com/sbaiahmed1/reactnativeblur/BlurType.kt

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.sbaiahmed1.reactnativeblur
22

3+
import android.content.res.Configuration
34
import android.graphics.Color
45

56
/**
@@ -9,51 +10,52 @@ import android.graphics.Color
910
enum class BlurType(val overlayColor: Int) {
1011
XLIGHT(Color.argb(140, 240, 240, 240)),
1112
LIGHT(Color.argb(42, 255, 255, 255)),
12-
DARK(Color.argb(120, 25, 25, 25)),
13+
DARK(Color.argb(120, 26, 22, 22)),
1314
EXTRA_DARK(Color.argb(160, 35, 35, 35)),
14-
REGULAR(Color.argb(35, 255, 255, 255)),
15-
PROMINENT(Color.argb(130, 240, 240, 240)),
16-
SYSTEM_ULTRA_THIN_MATERIAL(Color.argb(75, 240, 240, 240)),
17-
SYSTEM_ULTRA_THIN_MATERIAL_LIGHT(Color.argb(77, 240, 240, 240)),
15+
REGULAR_LIGHT(Color.argb(35, 255, 255, 255)),
16+
REGULAR_DARK(Color.argb(35, 28, 28, 30)),
17+
PROMINENT_LIGHT(Color.argb(140, 240, 240, 240)),
18+
PROMINENT_DARK(Color.argb(140, 28, 28, 30)),
19+
SYSTEM_ULTRA_THIN_MATERIAL_LIGHT(Color.argb(75, 240, 240, 240)),
1820
SYSTEM_ULTRA_THIN_MATERIAL_DARK(Color.argb(65, 40, 40, 40)),
19-
SYSTEM_THIN_MATERIAL(Color.argb(102, 240, 240, 240)),
20-
SYSTEM_THIN_MATERIAL_LIGHT(Color.argb(105, 240, 240, 240)),
21+
SYSTEM_THIN_MATERIAL_LIGHT(Color.argb(102, 240, 240, 240)),
2122
SYSTEM_THIN_MATERIAL_DARK(Color.argb(102, 35, 35, 35)),
22-
SYSTEM_MATERIAL(Color.argb(130, 242, 242, 242)),
23-
SYSTEM_MATERIAL_LIGHT(Color.argb(130, 245, 245, 245)),
23+
SYSTEM_MATERIAL_LIGHT(Color.argb(140, 245, 245, 245)),
2424
SYSTEM_MATERIAL_DARK(Color.argb(215, 65, 60, 60)),
25-
SYSTEM_THICK_MATERIAL(Color.argb(160, 240, 240, 240)),
26-
SYSTEM_THICK_MATERIAL_LIGHT(Color.argb(160, 242, 242, 242)),
25+
SYSTEM_THICK_MATERIAL_LIGHT(Color.argb(210, 248, 248, 248)),
2726
SYSTEM_THICK_MATERIAL_DARK(Color.argb(160, 35, 35, 35)),
28-
SYSTEM_CHROME_MATERIAL(Color.argb(135, 240, 240, 240)),
29-
SYSTEM_CHROME_MATERIAL_LIGHT(Color.argb(135, 242, 242, 242)),
30-
SYSTEM_CHROME_MATERIAL_DARK(Color.argb(90, 32, 32, 32));
27+
SYSTEM_CHROME_MATERIAL_LIGHT(Color.argb(165, 248, 248, 248)),
28+
SYSTEM_CHROME_MATERIAL_DARK(Color.argb(100, 32, 32, 32));
3129

3230
companion object {
3331
/**
3432
* Get BlurType from string, with fallback to LIGHT for unknown types.
33+
* Uses the provided configuration to determine if dark mode is active for
34+
* appropriate defaults.
3535
*/
36-
fun fromString(type: String): BlurType {
36+
fun fromString(type: String, configuration: Configuration): BlurType {
37+
val isDarkMode = (configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
38+
3739
return when (type.lowercase()) {
3840
"xlight" -> XLIGHT
3941
"light" -> LIGHT
4042
"dark" -> DARK
4143
"extradark" -> EXTRA_DARK
42-
"regular" -> REGULAR
43-
"prominent" -> PROMINENT
44-
"systemultrathinmaterial" -> SYSTEM_ULTRA_THIN_MATERIAL
44+
"regular" -> if (isDarkMode) REGULAR_DARK else REGULAR_LIGHT
45+
"prominent" -> if (isDarkMode) PROMINENT_DARK else PROMINENT_LIGHT
46+
"systemultrathinmaterial" -> if (isDarkMode) SYSTEM_ULTRA_THIN_MATERIAL_DARK else SYSTEM_ULTRA_THIN_MATERIAL_LIGHT
4547
"systemultrathinmateriallight" -> SYSTEM_ULTRA_THIN_MATERIAL_LIGHT
4648
"systemultrathinmaterialdark" -> SYSTEM_ULTRA_THIN_MATERIAL_DARK
47-
"systemthinmaterial" -> SYSTEM_THIN_MATERIAL
49+
"systemthinmaterial" -> if (isDarkMode) SYSTEM_THIN_MATERIAL_DARK else SYSTEM_THIN_MATERIAL_LIGHT
4850
"systemthinmateriallight" -> SYSTEM_THIN_MATERIAL_LIGHT
4951
"systemthinmaterialdark" -> SYSTEM_THIN_MATERIAL_DARK
50-
"systemmaterial" -> SYSTEM_MATERIAL
52+
"systemmaterial" -> if (isDarkMode) SYSTEM_MATERIAL_DARK else SYSTEM_MATERIAL_LIGHT
5153
"systemmateriallight" -> SYSTEM_MATERIAL_LIGHT
5254
"systemmaterialdark" -> SYSTEM_MATERIAL_DARK
53-
"systemthickmaterial" -> SYSTEM_THICK_MATERIAL
55+
"systemthickmaterial" -> if (isDarkMode) SYSTEM_THICK_MATERIAL_DARK else SYSTEM_THICK_MATERIAL_LIGHT
5456
"systemthickmateriallight" -> SYSTEM_THICK_MATERIAL_LIGHT
5557
"systemthickmaterialdark" -> SYSTEM_THICK_MATERIAL_DARK
56-
"systemchromematerial" -> SYSTEM_CHROME_MATERIAL
58+
"systemchromematerial" -> if (isDarkMode) SYSTEM_CHROME_MATERIAL_DARK else SYSTEM_CHROME_MATERIAL_LIGHT
5759
"systemchromemateriallight" -> SYSTEM_CHROME_MATERIAL_LIGHT
5860
"systemchromematerialdark" -> SYSTEM_CHROME_MATERIAL_DARK
5961
else -> XLIGHT // default fallback

android/src/main/java/com/sbaiahmed1/reactnativeblur/ReactNativeBlurView.kt

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.sbaiahmed1.reactnativeblur
22

33
import android.content.Context
4+
import android.content.res.Configuration
45
import android.graphics.Color
56
import android.graphics.Outline
67
import android.util.AttributeSet
@@ -36,6 +37,7 @@ class ReactNativeBlurView : BlurViewGroup {
3637
private var glassOpacity: Float = 1.0f
3738
private var viewType: String = "blur"
3839
private var glassType: String = "clear"
40+
private var currentBlurType: String = "xlight"
3941
private var isBlurInitialized: Boolean = false
4042
private var initRunnable: Runnable? = null
4143

@@ -189,16 +191,18 @@ class ReactNativeBlurView : BlurViewGroup {
189191
/**
190192
* Finds the optimal view to use as blur capture root.
191193
*
192-
* Returns the nearest react-native-screens Screen ancestor if found, which scopes
193-
* the blur to the current screen and prevents capturing navigation transitions.
194-
*
195-
* Returns null when no Screen ancestor exists (e.g. modals, standalone usage).
196-
* A null return means swapBlurRootToScreenAncestor() is a no-op and QmBlurView
197-
* keeps its default decor view as the blur root — this is correct for modals
198-
* because they need to blur the content behind them (in the main activity window).
194+
* Priority:
195+
* 1. Nearest react-native-screens Screen ancestor — scopes blur to the current
196+
* screen and prevents capturing navigation transition artifacts.
197+
* 2. Nearest ReactRootView ancestor — scopes blur to the React Native root when
198+
* the component is not inside a Screen (e.g. plain View hierarchies). Without
199+
* this fallback, QmBlurView defaults to the activity decor view and blurs the
200+
* entire screen instead of just the component area (issue #89).
201+
* 3. null — returned for modals, which intentionally need to blur content from
202+
* the main activity window (decor view is correct there).
199203
*/
200204
private fun findOptimalBlurRoot(): ViewGroup? {
201-
return findNearestScreenAncestor()
205+
return findNearestScreenAncestor() ?: findNearestReactRootView()
202206
}
203207

204208
/**
@@ -216,6 +220,22 @@ class ReactNativeBlurView : BlurViewGroup {
216220
return null
217221
}
218222

223+
/**
224+
* Walks up the view hierarchy looking for the React Native root view.
225+
* Used as a fallback when no Screen ancestor exists, to scope the blur
226+
* capture to the RN root rather than the full activity decor view.
227+
*/
228+
private fun findNearestReactRootView(): ViewGroup? {
229+
var currentParent = this.parent
230+
while (currentParent != null) {
231+
if (currentParent.javaClass.name == "com.facebook.react.ReactRootView") {
232+
return currentParent as? ViewGroup
233+
}
234+
currentParent = currentParent.parent
235+
}
236+
return null
237+
}
238+
219239
/**
220240
* Initialize the blur view with current settings.
221241
* Called after the view is attached and the blur root has been swapped.
@@ -293,7 +313,8 @@ class ReactNativeBlurView : BlurViewGroup {
293313
* @param type The blur type string (case-insensitive)
294314
*/
295315
fun setBlurType(type: String) {
296-
val blurType = BlurType.fromString(type)
316+
currentBlurType = type
317+
val blurType = BlurType.fromString(type, resources.configuration)
297318
currentOverlayColor = blurType.overlayColor
298319
logDebug("setBlurType: $type -> ${blurType.name}")
299320

@@ -478,4 +499,18 @@ class ReactNativeBlurView : BlurViewGroup {
478499
// re-positioning children based on its own logic (e.g. gravity), which would
479500
// conflict with React Native's layout.
480501
}
502+
503+
/**
504+
* Handle configuration changes, such as dark mode or orientation changes.
505+
* This ensures the blur view updates its overlay color based on the new
506+
* configuration.
507+
*/
508+
override fun onConfigurationChanged(newConfig: Configuration) {
509+
super.onConfigurationChanged(newConfig)
510+
511+
if (viewType == "blur") {
512+
// Re-apply blur type to update overlay color based on new configuration
513+
setBlurType(currentBlurType)
514+
}
515+
}
481516
}

android/src/main/java/com/sbaiahmed1/reactnativeblur/ReactNativeProgressiveBlurView.kt

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.sbaiahmed1.reactnativeblur
22

33
import android.content.Context
4+
import android.content.res.Configuration
45
import android.graphics.Canvas
56
import android.graphics.Color
67
import android.graphics.LinearGradient
@@ -32,6 +33,7 @@ class ReactNativeProgressiveBlurView : FrameLayout {
3233
private var currentBlurRadius = DEFAULT_BLUR_RADIUS
3334
private var currentBlurRounds = DEFAULT_BLUR_ROUNDS
3435
private var currentOverlayColor = Color.TRANSPARENT
36+
private var currentBlurType = "xlight"
3537
private var currentDirection = "topToBottom"
3638
private var currentStartOffset = 0.0f
3739
private var hasExplicitBackground: Boolean = false
@@ -211,16 +213,18 @@ class ReactNativeProgressiveBlurView : FrameLayout {
211213
/**
212214
* Finds the optimal view to use as blur capture root.
213215
*
214-
* Returns the nearest react-native-screens Screen ancestor if found, which scopes
215-
* the blur to the current screen and prevents capturing navigation transitions.
216-
*
217-
* Returns null when no Screen ancestor exists (e.g. modals, standalone usage).
218-
* A null return means swapBlurRootToScreenAncestor() is a no-op and QmBlurView
219-
* keeps its default decor view as the blur root — this is correct for modals
220-
* because they need to blur the content behind them (in the main activity window).
216+
* Priority:
217+
* 1. Nearest react-native-screens Screen ancestor — scopes blur to the current
218+
* screen and prevents capturing navigation transition artifacts.
219+
* 2. Nearest ReactRootView ancestor — scopes blur to the React Native root when
220+
* the component is not inside a Screen (e.g. plain View hierarchies). Without
221+
* this fallback, QmBlurView defaults to the activity decor view and blurs the
222+
* entire screen instead of just the component area (issue #89).
223+
* 3. null — returned for modals, which intentionally need to blur content from
224+
* the main activity window (decor view is correct there).
221225
*/
222226
private fun findOptimalBlurRoot(): ViewGroup? {
223-
return findNearestScreenAncestor()
227+
return findNearestScreenAncestor() ?: findNearestReactRootView()
224228
}
225229

226230
/**
@@ -237,6 +241,22 @@ class ReactNativeProgressiveBlurView : FrameLayout {
237241
return null
238242
}
239243

244+
/**
245+
* Walks up the view hierarchy looking for the React Native root view.
246+
* Used as a fallback when no Screen ancestor exists, to scope the blur
247+
* capture to the RN root rather than the full activity decor view.
248+
*/
249+
private fun findNearestReactRootView(): ViewGroup? {
250+
var currentParent = this.parent
251+
while (currentParent != null) {
252+
if (currentParent.javaClass.name == "com.facebook.react.ReactRootView") {
253+
return currentParent as? ViewGroup
254+
}
255+
currentParent = currentParent.parent
256+
}
257+
return null
258+
}
259+
240260
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
241261
val width = MeasureSpec.getSize(widthMeasureSpec)
242262
val height = MeasureSpec.getSize(heightMeasureSpec)
@@ -377,6 +397,17 @@ class ReactNativeProgressiveBlurView : FrameLayout {
377397
cleanup()
378398
}
379399

400+
/**
401+
* Handle configuration changes, such as dark mode or orientation changes.
402+
* This ensures the blur view updates its overlay color based on the new
403+
* configuration.
404+
*/
405+
override fun onConfigurationChanged(newConfig: Configuration) {
406+
super.onConfigurationChanged(newConfig)
407+
408+
setBlurType(currentBlurType)
409+
}
410+
380411
/**
381412
* Cleanup method to prevent memory leaks.
382413
* Resets initialization state so blur is re-initialized on next attach.
@@ -494,7 +525,8 @@ class ReactNativeProgressiveBlurView : FrameLayout {
494525
* @param type The blur type string (case-insensitive)
495526
*/
496527
fun setBlurType(type: String) {
497-
val blurType = BlurType.fromString(type)
528+
currentBlurType = type
529+
val blurType = BlurType.fromString(type, resources.configuration)
498530
currentOverlayColor = blurType.overlayColor
499531
logDebug("setBlurType: $type -> ${blurType.name} -> ${Integer.toHexString(currentOverlayColor)}")
500532

example/app/(tabs)/index.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import {
55
ImageBackground,
66
ScrollView,
77
Pressable,
8-
Modal,
98
TouchableWithoutFeedback,
109
View,
1110
} from 'react-native';
11+
import { FullWindowOverlay } from 'react-native-screens';
1212
import { BlurView } from '@sbaiahmed1/react-native-blur';
1313
import { DEMO_IMAGES } from '@/constants/blur';
1414

@@ -65,21 +65,23 @@ export default function HomeScreen() {
6565
</BlurView>
6666
</ScrollView>
6767

68-
<Modal visible={isModalVisible} transparent statusBarTranslucent>
69-
<TouchableWithoutFeedback onPress={() => setIsModalVisible(false)}>
70-
<BlurView
71-
ignoreSafeArea
72-
blurType={'dark'}
73-
style={StyleSheet.absoluteFill}
74-
/>
75-
</TouchableWithoutFeedback>
68+
{isModalVisible && (
69+
<FullWindowOverlay>
70+
<TouchableWithoutFeedback onPress={() => setIsModalVisible(false)}>
71+
<BlurView
72+
ignoreSafeArea
73+
blurType={'dark'}
74+
style={StyleSheet.absoluteFill}
75+
/>
76+
</TouchableWithoutFeedback>
7677

77-
<View style={styles.modalContent}>
78-
<View style={styles.modalCard}>
79-
<Text>Hello this is a centred text in a modal</Text>
78+
<View style={styles.modalContent} pointerEvents="none">
79+
<View style={styles.modalCard}>
80+
<Text>Hello this is a centred text in a modal</Text>
81+
</View>
8082
</View>
81-
</View>
82-
</Modal>
83+
</FullWindowOverlay>
84+
)}
8385
</ImageBackground>
8486
);
8587
}

ios/Views/BlurEffectView.swift

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,41 +21,54 @@ class BlurEffectView: UIVisualEffectView {
2121
}
2222

2323
func updateBlur(style: UIBlurEffect.Style, intensity: Double) {
24+
// Skip expensive animator recreation when nothing changed.
25+
// During FlashList recycling, updateUIView fires on every layout pass
26+
// even when props are identical, causing jank (issue #100).
27+
guard style != self.blurStyle || intensity != self.intensity else { return }
2428
self.blurStyle = style
2529
self.intensity = intensity
2630
setupBlur()
2731
}
2832

33+
override func didMoveToWindow() {
34+
super.didMoveToWindow()
35+
guard window != nil else { return }
36+
// UIKit resumes paused CAAnimations when a view re-joins a window
37+
// (e.g. after modal dismiss + re-present). If the animation plays
38+
// toward its end state the blur drifts to full intensity. Re-pause
39+
// and re-set the fraction here to lock it back to our intended value.
40+
// pausesOnCompletion = true (set in setupBlur) ensures the animator
41+
// stays .active even if it reaches fraction 1.0, so this is always safe.
42+
animator?.pauseAnimation()
43+
animator?.fractionComplete = intensity
44+
}
45+
2946
private func setupBlur() {
30-
// Clean up existing animator
31-
if let animator = animator {
32-
animator.stopAnimation(true)
33-
animator.finishAnimation(at: .current)
47+
if let existing = animator, existing.state == .active {
48+
existing.stopAnimation(true)
3449
}
3550
animator = nil
3651

37-
// Reset effect
3852
effect = nil
3953

40-
// Create new animator
41-
animator = UIViewPropertyAnimator(duration: 1, curve: .linear)
42-
animator?.addAnimations { [weak self] in
54+
let newAnimator = UIViewPropertyAnimator(duration: 1, curve: .linear)
55+
newAnimator.addAnimations { [weak self] in
4356
self?.effect = UIBlurEffect(style: self?.blurStyle ?? .systemMaterial)
4457
}
45-
46-
// Set intensity
47-
animator?.fractionComplete = intensity
48-
// Stop the animation at the current state
49-
DispatchQueue.main.async { [weak self] in
50-
self?.animator?.stopAnimation(true)
51-
self?.animator?.finishAnimation(at: .current)
52-
}
58+
// pausesOnCompletion: if UIKit ever resumes and runs this to the end,
59+
// the animator stays .active (paused at 1.0) instead of going .inactive.
60+
// This guarantees didMoveToWindow can always call pauseAnimation() safely.
61+
newAnimator.pausesOnCompletion = true
62+
newAnimator.startAnimation()
63+
newAnimator.pauseAnimation()
64+
newAnimator.fractionComplete = intensity
65+
animator = newAnimator
5366
}
5467

5568
deinit {
56-
guard let animator = animator, animator.state == .active else { return }
57-
animator.stopAnimation(true)
58-
animator.finishAnimation(at: .current)
69+
if let animator = animator, animator.state == .active {
70+
animator.stopAnimation(true)
71+
}
5972
}
6073
}
6174

0 commit comments

Comments
 (0)