software-mansion / react-native-reanimated

React Native's Animated library reimplemented
https://docs.swmansion.com/react-native-reanimated/
MIT License
8.86k stars 1.29k forks source link

[v2.3+] SharedValue Height is not applied in some cases #2836

Closed Engazan closed 2 years ago

Engazan commented 2 years ago

Description

Sometimes my Animated content is not reacting to Height changes ( only in v2.3+, works fine in v2.2 )

first i thought its Modal plugin proble so i made my own simple Modal Component to try it with expo and without expo ( ejected ) and it looks like its new Reanimated problem ( working on expo 43 with reanimated 2.2 )

Expected behavior

when i set sharedValue i expect to reflect it on height

Actual behavior & steps to reproduce

  1. Open custom modal when i set HEIGHT from 0 to 500
  2. when i add space to code ( to force refresh ) its fixed ...

Preview:

https://user-images.githubusercontent.com/53254371/149488127-2bdae745-7b0c-4476-8c6a-bcf958da4253.mp4

Snack or minimal code example

<BottomModal
                id="orderFilter"
                visible={true}
                setVisible={() => {}} // useless cuz we are going BACK
                snapPoints={[Layout.window.height * .55, Layout.window.height * .90]}
                onAfterDismiss={() => {
                    navigation.goBack();
                }}
                title="test"
            ></BottomModal>
import React from "react";
import {BackHandler, Keyboard, Pressable, StyleSheet, Text, View, ViewStyle} from "react-native";
import Animated, {FadeIn, FadeOut, runOnJS, SlideInDown, useAnimatedGestureHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'
import {Portal, PortalHost} from "@gorhom/portal";
import {PanGestureHandler} from "react-native-gesture-handler";
// import ModalTitle from "./ModalTitle";

type GestureHandlerContextType = { startHeight: number }

interface BottomModalProps {
    id: string // should be unique if there is multiple modals at same time
    visible: boolean
    setVisible: React.Dispatch<boolean>
    snapPoints: number[]

    title: string

    contentViewContainerStyle?: ViewStyle
    contentViewStyle?: ViewStyle

    onPanDownDismiss?: boolean // default - true

    backdropOptions?: {
        color?: string // default - rgba(2,2,2,0.5)
        dismissOnPress?: boolean // default - true
    }

    onDismiss?: () => void // this will fire before animation starts
    onAfterDismiss?: () => void // this will fire when animation finish, before setVisible is called
}

const BottomModal: React.FC<BottomModalProps> = (props) => {

    const currentSnapIndex = useSharedValue<number>(-1); // contentHeight 0
    const contentHeight = useSharedValue(0); // snap index -1

    const afterDismiss = () => {
        if (props.onAfterDismiss) {
            props.onAfterDismiss();
        }
        props.setVisible(false)
    }
    const dismiss = () => {
        Keyboard.dismiss();

        if (props.onDismiss) {
            props.onDismiss();
        }

        contentHeight.value = withTiming(0, undefined, (finished) => {
            runOnJS(afterDismiss)()
        })
    }

    const expand = () => {
        contentHeight.value = props.snapPoints[props.snapPoints.length - 1]
        currentSnapIndex.value = props.snapPoints.length - 1
    }

    const snapToClosest = () => {
        let currentSnapHeight = props.snapPoints[currentSnapIndex.value];

        /* end when its on same position */
        if (contentHeight.value === currentSnapHeight) {
            return;
        }

        if (contentHeight.value > currentSnapHeight) {
            // ++
            contentHeight.value = withTiming(props.snapPoints[currentSnapIndex.value + 1])
            currentSnapIndex.value += 1
        } else {
            // --
            if (typeof props.snapPoints[currentSnapIndex.value - 1] !== 'undefined') { // 0 => -1 only in specific situations
                contentHeight.value = withTiming(props.snapPoints[currentSnapIndex.value - 1])
                currentSnapIndex.value -= 1
            } else {
                // back to current snap
                contentHeight.value = withTiming(props.snapPoints[currentSnapIndex.value])
            }
        }
    }

    /* SNAPING / RESIZE handle */
    const onGestureHandler = useAnimatedGestureHandler({
        onStart(_, context: GestureHandlerContextType) {
            context.startHeight = contentHeight.value
        },
        onActive(event, context: GestureHandlerContextType) {

            let newVal = context.startHeight + (-1 * event.translationY);

            /* disable when overdrag last snapPoint */
            if (props.snapPoints[props.snapPoints.length - 1] < newVal) {
                return
            }

            contentHeight.value = newVal
        },
        onEnd(_, context: GestureHandlerContextType) {
            if (contentHeight.value < context.startHeight / 1.3) {
                if (typeof props.onPanDownDismiss === 'undefined' || props.onPanDownDismiss) {
                    runOnJS(dismiss)()
                } else {
                    if (!props.onPanDownDismiss) {
                        runOnJS(snapToClosest)()
                    }
                }
            } else {

                /* snap to closest snapPoint */
                runOnJS(snapToClosest)()
            }
        }
    })

    /* ANDROID back button handle */
    const backAction = () => {
        dismiss();
        return true;
    }
    React.useEffect(() => {
        BackHandler.addEventListener('hardwareBackPress', backAction)
        return () => BackHandler.removeEventListener("hardwareBackPress", backAction);
    }, [])

    /* SHOW / HIDE modal content */
    React.useEffect(() => {
        if (props.visible) {
            contentHeight.value = props.snapPoints[0]
            currentSnapIndex.value = 0
        } else {
            contentHeight.value = 0
            currentSnapIndex.value = -1
        }

        console.log('Content Visible - ', props.visible)
        console.log('Content height - ', contentHeight.value)
    }, [props.visible])

    const rContentStyle = useAnimatedStyle(() => {
        return {
            height: contentHeight.value
        }
    })

    if (!props.visible) {
        return null
    }

    return (
        <>
            <Portal name={props.id}>
                <Animated.View
                    entering={FadeIn}
                    exiting={FadeOut}
                    style={[styles.container, {backgroundColor: props.backdropOptions?.color ? props.backdropOptions.color : 'rgba(2,2,2,0.5)',}]}
                >
                    <Pressable style={{flex: 1}} onPress={() => {
                        if (props.backdropOptions?.dismissOnPress || typeof props.backdropOptions?.dismissOnPress === 'undefined') {
                            dismiss();
                            // expand()
                        }
                    }}/>

                    {/* contentContainer */}
                    <Animated.View
                        entering={SlideInDown}
                        style={[styles.contentContainer, props.contentViewContainerStyle, rContentStyle]}
                    >

                        {/* Header - SNAPING / RESIZE */}
                        <PanGestureHandler onGestureEvent={onGestureHandler}>
                            {/* TODO */}
                            <Animated.View style={{flexDirection: 'row', justifyContent: 'center', alignItems: 'center'}}>

                                {/*<ModalTitle title={props.title} dismiss={dismiss} />*/}

                                {/* spacer */}
                                <View style={{position: 'absolute', top: -10, backgroundColor: '#969696', width: 50, height: 5, borderRadius: 10}}/>
                            </Animated.View>
                        </PanGestureHandler>

                        {/* content */}
                        <View style={[{flex: 1}, props.contentViewStyle]}>
                            {props.children}
                        </View>
                    </Animated.View>
                </Animated.View>
            </Portal>
            <PortalHost name={props.id}/>
        </>
    )
}

const styles = StyleSheet.create({
    container: {
        position: 'absolute',
        height: '100%',
        width: '100%',
        justifyContent: 'flex-end'
    },
    contentContainer: {
        backgroundColor: 'white',
        borderRadius: 10,
    }
})

export default BottomModal

Package versions

name version
react-native 0.64.3
react-native-reanimated ~2.3.1
NodeJS v17.0.1
Xcode 13.2.1 (13C100)
expo 44

Affected platforms

Engazan commented 2 years ago

btw fake FIX, until they fix it

InteractionManager.runAfterInteractions(() => {
            setTimeout(() => {
                if (props.visible) {
                    contentHeight.value = props.snapPoints[0]
                    currentSnapIndex.value = 0
                } else {
                    contentHeight.value = 0
                    currentSnapIndex.value = -1
                }
            }, 300) // fakeit
        })
hannojg commented 2 years ago

I have the feeling this could be related to this issue: https://github.com/software-mansion/react-native-reanimated/issues/2571 . Is that the case? (reproduction might be simpler there)

piaskowyk commented 2 years ago

@hannojg right your case looks similar

Engazan commented 2 years ago

@hannojg right your case looks similar

Btw idk if its same as my originál post but also another problem when changing state in modal passed true props it looks like shared values get wiped ( this is why height is 0 until i click outside to somehow enable refreshing this values ), even there is no listener to this state change

Btw even when changing redux state also trigger this bug

Small preview example from my test app

https://user-images.githubusercontent.com/53254371/150025628-01858664-28bd-4a6b-86eb-07655c5e8c5e.MOV

Engazan commented 2 years ago

@piaskowyk tried your fix "operations-order", but without success, somehow when component rerender animated values get overwritten until force refresh ( adding something to code )

"react-native-reanimated": "github:software-mansion/react-native-reanimated#@piaskowyk/operations-order"

workaround ( fakeit ) is setting React.memo to right places but its terrible fixing it this way

Engazan commented 2 years ago

@piaskowyk

update this pull will fix it for me ( tested on multiple apps )

"react-native-reanimated": "github:software-mansion/react-native-reanimated#@piaskowyk/initial-style-shared",

https://github.com/software-mansion/react-native-reanimated/pull/2851