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
126 changes: 121 additions & 5 deletions android/src/main/java/com/swmansion/rnscreens/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.swmansion.rnscreens.bottomsheet.SheetDetents
import com.swmansion.rnscreens.bottomsheet.isSheetFitToContents
import com.swmansion.rnscreens.bottomsheet.updateMetrics
import com.swmansion.rnscreens.bottomsheet.useSingleDetent
import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation
import com.swmansion.rnscreens.events.HeaderHeightChangeEvent
Expand Down Expand Up @@ -92,6 +93,7 @@ class Screen(
var sheetClosesOnTouchOutside = true
var sheetElevation: Float = 24F
var sheetShouldOverflowTopInset = false
var sheetContentDefaultResizeAnimationEnabled = true

/**
* On Paper, when using form sheet presentation we want to delay enter transition in order
Expand Down Expand Up @@ -147,12 +149,21 @@ class Screen(
) {
val height = bottom - top

val sheetBehavior = sheetBehavior
if (usesFormSheetPresentation()) {
if (isSheetFitToContents()) {
sheetBehavior?.useSingleDetent(height)
// During the initial call in `onCreateView`, insets are not yet available,
// so we need to request an additional layout pass later to account for them.
requestLayout()
if (isSheetFitToContents() && sheetBehavior != null) {
val oldHeight = sheetBehavior.maxHeight
val shouldAnimateContentHeightChange = oldHeight > 0 && oldHeight != height

if (shouldAnimateContentHeightChange) {
if (sheetContentDefaultResizeAnimationEnabled) {
animateSheetContentHeightChangeWithDefaultAnimation(sheetBehavior, oldHeight, height)
} else {
updateSheetDetentContentHeightAndLayout(sheetBehavior, height)
}
} else {
updateSheetDetentWithoutHeightChangeAnimation(sheetBehavior, height)
}
}

if (!BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
Expand All @@ -168,6 +179,111 @@ class Screen(
}
}

private fun animateSheetContentHeightChangeWithDefaultAnimation(
behavior: BottomSheetBehavior<Screen>,
oldHeight: Int,
newHeight: Int,
) {
val delta = (newHeight - oldHeight).toFloat()
val isContentExpanding = delta > 0

if (isContentExpanding) {
/*
* Expanding content animation:
*
* Before animation, we're updating the SheetBehavior - the maximum height is the new
* content height, then we're forcing a layout pass. This ensures the view calculates
* with its new bounds when the animation starts.
*
* In the animation, we're translating the Screen back to it's (newly calculated) origin
* position, providing an impression that FormSheet expands. It already has the final size,
* but some content is not yet visible on the screen.
*
* After animation, we just need to send a notification that ShadowTree state should be updated,
* as the positioning of pressables has changed due to the Y translation manipulation.
*/
this.translationY = delta
this
.animate()
.translationY(0f)
.withStartAction {
behavior.updateMetrics(newHeight)
layout(this.left, this.bottom - newHeight, this.right, this.bottom)
}.withEndAction {
// Force a layout pass on the CoordinatorLayout to synchronize BottomSheetBehavior's
// internal offsets with the new maxHeight. This prevents the sheet from snapping back
// to its old position when the user starts a gesture.
parent.requestLayout()
onSheetYTranslationChanged()
}.start()
} else {
/*
* Shrinking content animation:
*
* Before the animation, our Screen translationY is 0 - because its actual layout and visual position are equal.
*
* Before the animation, I'm updating sheet metrics to the target value - it won't update until the next layout pass,
* which is controlled by end action. This is done deliberately, to allow catching the case when quick combination
* of shrink & expand animation is detected.
*
* In the animation, we're translating the Screen down by the calculated height delta to the position (which will
* be new absolute 0 for the Screen, after ending the transition), providing an impression that FormSheet shrinks.
* FormSheet's size remains unchanged during the whole animation, therefore there is no view clipping.
*
* After animation, we can update the layout: the maximum FormSheet height is updated and we're forcing
* another layout pass. Additionally, since the actual layout and the target position are equal,
* we can reset translationY to 0.
*
* After animation, we need to send a notification that ShadowTree state should be updated,
* as the FormSheet size has changed and the positioning of pressables has changed due to the Y translation manipulation.
*/
this
.animate()
.translationY(-delta)
.withStartAction {
behavior.updateMetrics(newHeight)
}.withEndAction {
layout(this.left, this.bottom - newHeight, this.right, this.bottom)
this.translationY = 0f
// Force a layout pass on the CoordinatorLayout to synchronize BottomSheetBehavior's
// internal offsets with the new maxHeight. This prevents the sheet from snapping back
// to its old position when the user starts a gesture.
parent.requestLayout()
onSheetYTranslationChanged()
}.start()
}
}

private fun updateSheetDetentContentHeightAndLayout(
behavior: BottomSheetBehavior<Screen>,
height: Int,
) {
/*
* We're just updating sheets height and forcing Screen layout to be updated immediately.
* This allows custom animators in RN to work, as we do not interfere with these animations
* and we're just reacting to the sheet's content size changes.
*/
behavior.updateMetrics(height)
layout(this.left, this.bottom - height, this.right, this.bottom)
// Force a layout pass on the CoordinatorLayout to synchronize BottomSheetBehavior's
// internal offsets with the new maxHeight. This prevents the sheet from snapping back
// to its old position when the user starts a gesture.
parent.requestLayout()
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
updateScreenSizeFabric(width, height, top + translationY.toInt())
}
}

private fun updateSheetDetentWithoutHeightChangeAnimation(
behavior: BottomSheetBehavior<Screen>,
height: Int,
) {
behavior.useSingleDetent(height)
// During the initial call in `onCreateView`, insets are not yet available,
// so we need to request an additional layout pass later to account for them.
requestLayout()
}

fun registerLayoutCallbackForWrapper(wrapper: ScreenContentWrapper) {
wrapper.delegate = this
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ open class ScreenViewManager :
view?.sheetShouldOverflowTopInset = sheetShouldOverflowTopInset
}

@ReactProp(name = "sheetContentDefaultResizeAnimationEnabled")
override fun setSheetContentDefaultResizeAnimationEnabled(
view: Screen?,
sheetContentDefaultResizeAnimationEnabled: Boolean,
) {
view?.sheetContentDefaultResizeAnimationEnabled = sheetContentDefaultResizeAnimationEnabled
}

// mark: iOS-only
// these props are not available on Android, however we must override their setters
override fun setFullScreenSwipeEnabled(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public void setProperty(T view, String propName, @Nullable Object value) {
case "sheetShouldOverflowTopInset":
mViewManager.setSheetShouldOverflowTopInset(view, value == null ? false : (boolean) value);
break;
case "sheetContentDefaultResizeAnimationEnabled":
mViewManager.setSheetContentDefaultResizeAnimationEnabled(view, value == null ? true : (boolean) value);
break;
case "customAnimationOnSwipe":
mViewManager.setCustomAnimationOnSwipe(view, value == null ? false : (boolean) value);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public interface RNSScreenManagerInterface<T extends View> extends ViewManagerWi
void setSheetInitialDetent(T view, int value);
void setSheetElevation(T view, int value);
void setSheetShouldOverflowTopInset(T view, boolean value);
void setSheetContentDefaultResizeAnimationEnabled(T view, boolean value);
void setCustomAnimationOnSwipe(T view, boolean value);
void setFullScreenSwipeEnabled(T view, @Nullable String value);
void setFullScreenSwipeShadowEnabled(T view, boolean value);
Expand Down
175 changes: 175 additions & 0 deletions apps/src/tests/Test2560.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import React, { useRef, useState } from 'react';
import {
Button,
View,
Text,
StyleSheet,
TextInput,
Animated,
} from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import {
createNativeStackNavigator,
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import Colors from '../shared/styling/Colors';
import PressableWithFeedback from '../shared/PressableWithFeedback';

const USE_ANIMATED_COMPONENT = false;

type StackParamList = {
Home: undefined;
FormSheet: undefined;
};

const Stack = createNativeStackNavigator<StackParamList>();

const HomeScreen = ({ navigation }: NativeStackScreenProps<StackParamList>) => (
<View style={styles.screen}>
<Text style={styles.title}>Home Screen</Text>
<Button
title="Open Form Sheet"
onPress={() => navigation.navigate('FormSheet')}
/>
</View>
);

const FormSheetScreen = ({
navigation,
}: NativeStackScreenProps<StackParamList>) => {
const [showTopView, setShowTopView] = useState(false);
const [showBottomView, setShowBottomView] = useState(false);
const [rectangleHeight, setRectangleHeight] = useState(200);
const animatedRectangleHeight = useRef(new Animated.Value(200)).current;
const currentAnimatedRectangleHeight = useRef(200);

const toggleTopView = () => setShowTopView(prev => !prev);
const toggleBottomView = () => setShowBottomView(prev => !prev);
const toggleRectangleHeight = () =>
setRectangleHeight(prev => (prev === 200 ? 400 : 200));

const toggleAnimatedRectangleHeight = () => {
const newHeight =
currentAnimatedRectangleHeight.current === 200 ? 400 : 200;
Animated.timing(animatedRectangleHeight, {
toValue: newHeight,
duration: 300,
useNativeDriver: false,
}).start(() => {
currentAnimatedRectangleHeight.current = newHeight;
});
};

return (
<View style={styles.formSheetContainer}>
{showTopView && (
<View style={styles.rectangle}>
<TextInput style={styles.input} />
<PressableWithFeedback>
<Text style={styles.text}>Top View</Text>
</PressableWithFeedback>
</View>
)}
<Text style={styles.formSheetTitle}>Form Sheet Content</Text>
{USE_ANIMATED_COMPONENT ? (
<Animated.View
style={[styles.rectangle, { height: animatedRectangleHeight }]}
/>
) : (
<View style={[styles.rectangle, { height: rectangleHeight }]} />
)}

<Button
title={showTopView ? 'Hide Top View' : 'Show Top View'}
onPress={toggleTopView}
/>
{USE_ANIMATED_COMPONENT ? (
<Button
title={`Toggle Animated Rectangle Height (Current: ${currentAnimatedRectangleHeight.current}px)`}
onPress={toggleAnimatedRectangleHeight}
/>
) : (
<Button
title={`Toggle Height (Current: ${rectangleHeight}px)`}
onPress={toggleRectangleHeight}
/>
)}
<Button
title={showBottomView ? 'Hide Bottom View' : 'Show Bottom View'}
onPress={toggleBottomView}
/>
<Button title="Dismiss" onPress={() => navigation.goBack()} />
{showBottomView && (
<View style={styles.rectangle}>
<PressableWithFeedback>
<Text style={styles.text}>Bottom View</Text>
</PressableWithFeedback>
</View>
)}
</View>
);
};

export default function App() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen
name="FormSheet"
component={FormSheetScreen}
options={{
presentation: 'formSheet',
sheetAllowedDetents: 'fitToContents',
contentStyle: {
backgroundColor: Colors.YellowLight40,
},
// TODO(@t0maboro) - add `sheetContentDefaultResizeAnimationEnabled` prop here when possible
}}
/>
</Stack.Navigator>
</NavigationContainer>
);
}

const styles = StyleSheet.create({
screen: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 24,
marginBottom: 20,
},
formSheetContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 20,
gap: 20,
},
formSheetTitle: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 10,
},
text: {
fontSize: 16,
marginBottom: 8,
color: Colors.White,
},
rectangle: {
width: '100%',
backgroundColor: Colors.NavyLight80,
height: 200,
alignItems: 'center',
justifyContent: 'center',
},
input: {
width: 100,
height: 20,
borderColor: Colors.BlueDark100,
borderWidth: 1,
},
});
1 change: 1 addition & 0 deletions apps/src/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export { default as Test2522 } from './Test2522';
export { default as Test2538 } from './Test2538';
export { default as Test2543 } from './Test2543'; // [E2E created](iOS): issue related to iOS formSheet initial detent
export { default as Test2552 } from './Test2552';
export { default as Test2560 } from './Test2560';
export { default as Test2611 } from './Test2611';
export { default as Test2631 } from './Test2631';
export { default as Test2668 } from './Test2668';
Expand Down
10 changes: 10 additions & 0 deletions guides/GUIDE_FOR_LIBRARY_AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,16 @@ When set to `false`, the sheet's layout will be constrained by the inset from th

Defaults to `false`.

### `sheetContentDefaultResizeAnimationEnabled` (Android only)

Whether the default native animation should be used when the sheet's with `fitToContents` content size changes.

When set to `true`, the sheet uses internal logic to synchronize size updates and translation animations during entry, exit, or content updates. This ensures a smooth transition for standard, static content mounting/unmounting.

When set to `false`, the internal animation and translation logic is ignored. This allows the sheet to adjust its size dynamically based on the current dimensions of the content provided by the developer, allowing implementing custom resizing animations.

Defaults to `true`.

### `stackAnimation`

Allows for the customization of how the given screen should appear/disappear when pushed or popped at the top of the stack. The following values are currently supported:
Expand Down
Loading
Loading