Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 33 additions & 13 deletions android/src/main/java/com/swmansion/rnscreens/ScreenFooter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EX
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
import com.google.android.material.math.MathUtils
import com.swmansion.rnscreens.bottomsheet.SheetUtils
import java.lang.ref.WeakReference
import kotlin.math.max

@SuppressLint("ViewConstructor")
Expand Down Expand Up @@ -48,9 +49,13 @@ class ScreenFooter(

// Main goal of this callback implementation is to handle keyboard appearance. We use it to make sure
// that the footer respects keyboard during layout.
// Note `DISPATCH_MODE_STOP` is used here to avoid propagation of insets callback to footer subtree.
// Note `DISPATCH_MODE_CONTINUE_ON_SUBTREE` is required here: this callback is installed on the
// window's decor view, so `DISPATCH_MODE_STOP` would halt WindowInsetsAnimation dispatch for the
// entire window, silencing every other insets-animation consumer in the app (e.g. reanimated's
// useAnimatedKeyboard or react-native-keyboard-controller). We do not consume insets in onProgress,
// so continuing dispatch is safe.
private val insetsAnimation =
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
override fun onStart(
animation: WindowInsetsAnimationCompat,
bounds: WindowInsetsAnimationCompat.BoundsCompat,
Expand Down Expand Up @@ -92,17 +97,6 @@ class ScreenFooter(
}
}

init {
val rootView =
checkNotNull(reactContext.currentActivity) {
"[RNScreens] Context detached from activity while creating ScreenFooter"
}.window.decorView

// Note that we do override insets animation on given view. I can see it interfering e.g.
// with reanimated keyboard or even other places in our code. Need to test this.
ViewCompat.setWindowInsetsAnimationCallback(rootView, insetsAnimation)
}

private fun requireScreenParent(): Screen = requireNotNull(screenParent)

private fun requireSheetBehavior(): BottomSheetBehavior<Screen> = requireNotNull(sheetBehavior)
Expand Down Expand Up @@ -192,11 +186,33 @@ class ScreenFooter(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
sheetBehavior?.let { registerWithSheetBehavior(it) }
registerInsetsAnimationCallback()
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
sheetBehavior?.let { unregisterWithSheetBehavior(it) }
unregisterInsetsAnimationCallback()
}

private val decorView: View?
get() = reactContext.currentActivity?.window?.decorView

// Note that we do override insets animation callback on the decor view - a view we do not own.
// Android keeps a single callback slot per view, so we restore it to null when the footer
// detaches, guarded against clearing a callback installed by a more recently attached footer.
private fun registerInsetsAnimationCallback() {
val rootView = decorView ?: return
ViewCompat.setWindowInsetsAnimationCallback(rootView, insetsAnimation)
lastRegisteredFooter = WeakReference(this)
}

private fun unregisterInsetsAnimationCallback() {
if (lastRegisteredFooter?.get() !== this) {
return
}
lastRegisteredFooter = null
decorView?.let { ViewCompat.setWindowInsetsAnimationCallback(it, null) }
}

/**
Expand Down Expand Up @@ -295,5 +311,9 @@ class ScreenFooter(

companion object {
const val TAG = "ScreenFooter"

// Tracks which footer's callback currently occupies the decor view's single
// insets-animation callback slot.
private var lastRegisteredFooter: WeakReference<ScreenFooter>? = null
}
}
120 changes: 120 additions & 0 deletions apps/src/tests/issue-tests/TestScreenFooterKeyboardInsets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { NavigationContainer, RouteProp } from '@react-navigation/native';
import {
NativeStackNavigationProp,
createNativeStackNavigator,
} from '@react-navigation/native-stack';
import React from 'react';
import { Button, StyleSheet, Text, TextInput, View } from 'react-native';
import Animated, {
useAnimatedKeyboard,
useAnimatedStyle,
} from 'react-native-reanimated';

type RouteParamList = {
Home: undefined;
FormSheetWithFooter: undefined;
};

type RouteProps<RouteName extends keyof RouteParamList> = {
navigation: NativeStackNavigationProp<RouteParamList, RouteName>;
route: RouteProp<RouteParamList, RouteName>;
};

const Stack = createNativeStackNavigator<RouteParamList>();

function Home({ navigation }: RouteProps<'Home'>) {
const keyboard = useAnimatedKeyboard();
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: -keyboard.height.value }],
}));

return (
<View style={styles.container}>
<Text style={styles.instructions}>
1. Focus the input — it should translate up with the keyboard.{'\n'}
2. Dismiss the keyboard, open the form sheet (its footer mounts),
close the sheet.{'\n'}
3. Focus the input again — before the fix the keyboard animation no
longer runs and never recovers for the lifetime of the process.
</Text>
<Button
title="Open form sheet with footer"
onPress={() => navigation.navigate('FormSheetWithFooter')}
/>
<Animated.View style={animatedStyle}>
<TextInput placeholder="Focus me" style={styles.input} />
</Animated.View>
</View>
);
}

function FormSheetWithFooter({
navigation,
}: RouteProps<'FormSheetWithFooter'>) {
return (
<View style={styles.sheetContent}>
<Button title="Close" onPress={() => navigation.goBack()} />
</View>
);
}

function FormSheetFooter() {
return (
<View style={styles.footer}>
<Text>Sheet footer</Text>
</View>
);
}

function App() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={Home} />
<Stack.Screen
name="FormSheetWithFooter"
component={FormSheetWithFooter}
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.4],
sheetCornerRadius: 8,
headerShown: false,
contentStyle: {
backgroundColor: 'lightblue',
},
unstable_sheetFooter: FormSheetFooter,
}}
/>
</Stack.Navigator>
</NavigationContainer>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'flex-end',
gap: 16,
padding: 16,
},
instructions: {
fontSize: 14,
},
input: {
height: 48,
borderWidth: 1,
paddingHorizontal: 8,
},
sheetContent: {
height: 200,
justifyContent: 'center',
},
footer: {
height: 64,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'lightgray',
},
});

export default App;
1 change: 1 addition & 0 deletions apps/src/tests/issue-tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,4 @@ export { default as TestSplit } from './TestSplit';
export { default as TestSafeAreaViewIOS } from './TestSafeAreaViewIOS';
export { default as TestStackNesting } from './TestStackNesting';
export { default as Test4090 } from './Test4090';
export { default as TestScreenFooterKeyboardInsets } from './TestScreenFooterKeyboardInsets';