Why is only one react native parallel animation running and not the other?

Viewed 1570

The goal:

create a dropdown view, which when collapsed, is 75px in height. when expanded, its 225px in height. When the button that starts the animations is pressed, it runs two animations in parallel, a timing function for height, and one for opacity. the second part of the goal is to animated the opacity of the lower part of the dropdown is made available on expanded == true.

the problem:

the animation runs as expected when expanding the view, no issues. but when collapsing the dropdown, seemingly only the opacity animation runs. the issue is with the height, not being properly reflected in the view. it stays the same height as when expanded.

here's the component:

const ListItem = (props) => {
    const [checkInModal, setCheckInModal] = useState(false);
    const [animatedHeight, setAnimatedHeight] = useState(new Animated.Value(0))
    const [animatedOpacity] = useState(new Animated.Value(0))
    const [expanded, setExpanded] = useState(false);

    const toggleDropdown = () => {
        console.log("BEFORE ANIMATION =>" + "\n" + "expanded: " + expanded + "\n" + "animatedHeight: " + JSON.stringify(animatedHeight) + "\n" + "animatedOpacity: " + JSON.stringify(animatedOpacity))
        if (expanded == true) {
            // collapse dropdown
            Animated.parallel([
                Animated.timing(animatedHeight, {
                    toValue: 0,
                    duration: 200,
                }),
                Animated.timing(animatedOpacity, {
                    toValue: 0,
                    duration: 400,
                })
            ]).start()
            setTimeout(() => console.log("AFTER ANIMATION =>" + "\n" + "animatedHeight: " + JSON.stringify(animatedHeight) + "\n" + "animatedOpacity: " + JSON.stringify(animatedOpacity)), 3000)
            // This alone works properly*
            // Animated.timing(animatedHeight, {
            //  toValue: 0,
            //  duration: 200,
            // }).start()

        } else {
            // expand dropdown
            Animated.sequence([
                Animated.timing(animatedHeight, {
                    toValue: 100,
                    duration: 200,
                }),
                Animated.timing(animatedOpacity, {
                    toValue: 100,
                    duration: 400,
                })
            ]).start()
            setTimeout(() => console.log("AFTER ANIMATION =>" + "\n" + "animatedHeight: " + JSON.stringify(animatedHeight) + "\n" + "animatedOpacity: " + JSON.stringify(animatedOpacity)), 3000)
            // This alone works properly*
            // Animated.timing(animatedHeight, {
            //  toValue: 100,
            //  duration: 200,
            // }).start()
        }
        setExpanded(!expanded)
    }

    const interpolatedHeight = animatedHeight.interpolate({
        inputRange: [0, 100],
        outputRange: [75, 225]
    })

    const interpolatedOpacity = animatedOpacity.interpolate({
        inputRange: [0, 100],
        outputRange: [0.0, 1.0]
    })

    return (
        <Animated.View
            style={[styles.container, { height: interpolatedHeight }]}
        >
            <View style={{ flexDirection: 'row', justifyContent: 'space-between', }}>
                <View style={styles.leftContainer}>
                    <View style={{ flexDirection: 'row', alignItems: 'center' }}>
                        <Text style={styles.title}>{props.title}</Text>
                    </View>
                    <Text style={styles.subtitle}>{time()}</Text>
                </View>

                <View style={styles.rightContainer}>
                    <TouchableOpacity onPress={() => toggleDropdown()} style={styles.toggleBtn}>
                        <Image source={require('../assets/img/chevron-down.png')} resizeMode={'contain'} style={styles.chevron} />
                    </TouchableOpacity>
                </View>
            </View>

            {expanded == true ? (
                <Animated.View style={[styles.bottomContainer, { opacity: interpolatedOpacity }]}>
                    <Components.BodyText text="Subject:" style={{ fontFamily: Fonts.OPENSANS_BOLD }} />

                    <Components.BodyText text={props.subject} />
                </Animated.View>
            ) : null}
        </Animated.View>
    );
};

const styles = StyleSheet.create({
    container: {
        backgroundColor: '#fff',
        borderRadius: 25,
        width: width * 0.95,
        marginBottom: 5,
        marginHorizontal: 5,
        paddingVertical: 15,
        paddingHorizontal: 15
    },
    leftContainer: {
        justifyContent: 'space-between',
    },
    rightContainer: {
        flexDirection: 'row',
        alignItems: 'center'
    },
    title: {
        fontFamily: Fonts.OPENSANS_BOLD,
        fontSize: 20,
        color: '#454A66'
    },
    subtitle: {
        color: '#454A66',
        fontSize: 14
    },
    typeIcon: {
        height: 25,
        width: 25
    },
    chevron: {
        height: 15,
        width: 15
    },
    toggleBtn: {
        borderWidth: 1,
        borderColor: Colors.PRIMARY_DARK,
        borderRadius: 7,
        paddingTop: 4,
        paddingBottom: 2.5,
        paddingHorizontal: 4,
        marginLeft: 10
    },
    bottomContainer: {
        marginVertical: 20
    },
    buttonContainer: {
        flexDirection: 'row',
        width: 250,
        justifyContent: 'space-between',
        alignSelf: 'center',
        marginVertical: 20
    },
    noShadow: {
        elevation: 0,
        shadowOffset: {
            width: 0,
            height: 0
        },
        shadowRadius: 0,
    }
});

export default ListItem;

The Console Logs:

When collapsed, pressing the button to expand logs this in the console:

 LOG  BEFORE ANIMATION =>
expanded: false
animatedHeight: 0
animatedOpacity: 0
 LOG  AFTER ANIMATION =>
animatedHeight: 100
animatedOpacity: 100

When expanded, pressing the button to collapse logs this in the console:

 LOG  BEFORE ANIMATION =>
expanded: true
animatedHeight: 100
animatedOpacity: 100
 LOG  AFTER ANIMATION =>
animatedHeight: 100
animatedOpacity: 100

Not exactly sure what thats about, given that like said, the opacity animation works on collapse and expansion. If the height animation isn't touched at all, except for taking it out of the Animated.parallel(), then the height animation works as expected on expand & collapse.

Any ideas on whats going on here?

3 Answers

The problem comes when you try to animate something that is no longer being rendered. Apparently the whole Animated.parallel block won't work if one of the elements are not present anymore. If one animation is stopped, all animations inside Animated.parallel will stop. You can override this behavior with the stopTogether flag:

    const toggleDropdown = () => {
        if (expanded === true) {
            // collapse dropdown
            Animated.parallel([
                Animated.timing(animatedHeight, {
                    toValue: 0,
                    duration: 200,
                }),
                Animated.timing(animatedOpacity, {
                    toValue: 0,
                    duration: 400,
                })
            ], { stopTogether: false }).start()
            // ...

When you try to collapse the dropdown the expanded flag is set to false, making the "subject" component not being rendered anymore and thus making the whole animation fail. When expanding the dropdown, the expanded flag is set to true and the component is now rendered, the animation works, but it doesn't matter since it previously didn't went back to the original value.

Try deleting the conditional rendering expanded == true ? part (I think it won't affect the actual output). I've tried your code without it and seems to work.

Another option is to setExpanded(true) before expanding the dropdown so the element is visible and ready to be animated (and set it to false when the collapse animation ends):

    const toggleDropdown = () => {
        if (expanded === true) {
            // collapse dropdown
            Animated.parallel([
                Animated.timing(animatedHeight, {
                    toValue: 0,
                    duration: 200,
                }),
                Animated.timing(animatedOpacity, {
                    toValue: 0,
                    duration: 400,
                })
            ]).start(() => setExpanded(false))
        }
        else {
            setExpanded(true)
            // expand dropdown
            Animated.sequence([
                Animated.timing(animatedHeight, {
                    toValue: 100,
                    duration: 200,
                }),
                Animated.timing(animatedOpacity, {
                    toValue: 100,
                    duration: 400,
                })
            ]).start()
        }
    }

You'll face some visual issues (the "Subject" part being displayed outside the component, perhaps you should add a overflow: hidden or change the timings). But the animation problem should be fixed.

Here you go

There are 2 issues with animation :

  1. While collapsing animation, making opacity 0 respected element is gone, so you can setTimeout while the collapsing is happening, once the animation is done you can remove the element, we need to so this only for removing, because on expanding we'll need the element asap, so there is no need of setTimeout while exapnding
  2. Animated.parallel By default, if one of the animations is stopped, they will all be stopped. You can override this with the stopTogether flag.
  // take the animation timeframe in array
  const animationDurations = [500, 1000];
  const [checkInModal, setCheckInModal] = useState(false);
  const [animatedHeight, setAnimatedHeight] = useState(new Animated.Value(0))
  const [animatedOpacity] = useState(new Animated.Value(0))
  const [expanded, setExpanded] = useState(false);

  const toggleDropdown = () => {
    if (expanded == true) {
      Animated.parallel([
        Animated.timing(animatedHeight, {
          toValue: 0,
          duration: animationDurations[0],
        }),
        Animated.timing(animatedOpacity, {
          toValue: 0,
          duration: animationDurations[1],
        })
      ], {
        stopTogether: false // <--- so that all animation get completed
      }).start()

      // get max animation time and then toggle the element out
      setTimeout(() => {
        setExpanded(!expanded)
      }, Math.max(...animationDurations));

    } else {
      // expand dropdown
      Animated.sequence([
        Animated.timing(animatedHeight, {
          toValue: 100,
          duration: animationDurations[0],
        }),
        Animated.timing(animatedOpacity, {
          toValue: 100,
          duration: animationDurations[1],
        })
      ]).start()

      setExpanded(!expanded) // <---- Toggle asap to for showing purpose
    }
  }

  const interpolatedHeight = animatedHeight.interpolate({
    inputRange: [0, 100],
    outputRange: ["0%", "100%"]
  })

WORKING DEMO

Although I marked correct and awarded bounty to another answer, im posting the code I implemented which resulted in the desired functionality. As @Vivek Doshi pointed out on another of my posts, useEffect() was massively helpful in my efforts.

the two main things I changed was instead toggle a simple boolean in my toggle functionality:

const toggleTest = () => {
    setExpanded(!expanded)
}

and implementing useEffect():

const ListItem = (props) => {

const [checkInModal, setCheckInModal] = useState(false);
const [animatedHeight] = useState(new Animated.Value(75))
const [animatedOpacity] = useState(new Animated.Value(0))
const [dynamicHeight, setDynamicHeight] = useState(0);
const [expanded, setExpanded] = useState(false);

useEffect(() => {
    if (expanded) {
        // expand dropdown
        Animated.parallel([
            Animated.timing(animatedHeight, {
                toValue: 225 + dynamicHeight,
                duration: 200,
            }),
            Animated.timing(animatedOpacity, {
                toValue: 100,
                duration: 200,
            })
        ]).start()
    } else {
        Animated.parallel([
            Animated.timing(animatedHeight, {
                toValue: 75,
                duration: 200,
            }),
            Animated.timing(animatedOpacity, {
                toValue: 0,
                duration: 400,
            })
        ]).start()
    }
}, [dynamicHeight, expanded])

Make sure to add the appropriate dependencies to the useEffect argument!

Related