Skip to content

Commit 292fbde

Browse files
authored
Merge pull request #102 from sbaiahmed1/fix/blur-issues-85-89-100-101
fix: resolve blur animator race, scroll lag, and Android full-screen blur
2 parents 23797fd + 8284975 commit 292fbde

4 files changed

Lines changed: 100 additions & 49 deletions

File tree

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

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

202204
/**
@@ -214,6 +216,22 @@ class ReactNativeBlurView : BlurViewGroup {
214216
return null
215217
}
216218

219+
/**
220+
* Walks up the view hierarchy looking for the React Native root view.
221+
* Used as a fallback when no Screen ancestor exists, to scope the blur
222+
* capture to the RN root rather than the full activity decor view.
223+
*/
224+
private fun findNearestReactRootView(): ViewGroup? {
225+
var currentParent = this.parent
226+
while (currentParent != null) {
227+
if (currentParent.javaClass.name == "com.facebook.react.ReactRootView") {
228+
return currentParent as? ViewGroup
229+
}
230+
currentParent = currentParent.parent
231+
}
232+
return null
233+
}
234+
217235
/**
218236
* Initialize the blur view with current settings.
219237
* Called after the view is attached and the blur root has been swapped.

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -209,16 +209,18 @@ class ReactNativeProgressiveBlurView : FrameLayout {
209209
/**
210210
* Finds the optimal view to use as blur capture root.
211211
*
212-
* Returns the nearest react-native-screens Screen ancestor if found, which scopes
213-
* the blur to the current screen and prevents capturing navigation transitions.
214-
*
215-
* Returns null when no Screen ancestor exists (e.g. modals, standalone usage).
216-
* A null return means swapBlurRootToScreenAncestor() is a no-op and QmBlurView
217-
* keeps its default decor view as the blur root — this is correct for modals
218-
* because they need to blur the content behind them (in the main activity window).
212+
* Priority:
213+
* 1. Nearest react-native-screens Screen ancestor — scopes blur to the current
214+
* screen and prevents capturing navigation transition artifacts.
215+
* 2. Nearest ReactRootView ancestor — scopes blur to the React Native root when
216+
* the component is not inside a Screen (e.g. plain View hierarchies). Without
217+
* this fallback, QmBlurView defaults to the activity decor view and blurs the
218+
* entire screen instead of just the component area (issue #89).
219+
* 3. null — returned for modals, which intentionally need to blur content from
220+
* the main activity window (decor view is correct there).
219221
*/
220222
private fun findOptimalBlurRoot(): ViewGroup? {
221-
return findNearestScreenAncestor()
223+
return findNearestScreenAncestor() ?: findNearestReactRootView()
222224
}
223225

224226
/**
@@ -235,6 +237,22 @@ class ReactNativeProgressiveBlurView : FrameLayout {
235237
return null
236238
}
237239

240+
/**
241+
* Walks up the view hierarchy looking for the React Native root view.
242+
* Used as a fallback when no Screen ancestor exists, to scope the blur
243+
* capture to the RN root rather than the full activity decor view.
244+
*/
245+
private fun findNearestReactRootView(): ViewGroup? {
246+
var currentParent = this.parent
247+
while (currentParent != null) {
248+
if (currentParent.javaClass.name == "com.facebook.react.ReactRootView") {
249+
return currentParent as? ViewGroup
250+
}
251+
currentParent = currentParent.parent
252+
}
253+
return null
254+
}
255+
238256
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
239257
val width = MeasureSpec.getSize(widthMeasureSpec)
240258
val height = MeasureSpec.getSize(heightMeasureSpec)

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)