7

The goal: have a dropdown view that animates the height-expansion over time. the caveat is this: once the view is expanded, it needs to be able to dynamically handle whether or not there is additional view data present. if it is present, a couple extra text components will be rendered.

The problem: as it currently is, the animation the parent's height to a fixed height. so when the additionalContent is rendered, it surpasses the bounds of the parent, whose height is fixed. I dont want to not set the height of the parent explicitly, because then I cant animate that aspect the way I want. I want to maintain the height animation as-is, as well as dynamically size the parent to contain the children when the additionalContent is present

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

    const toggleDropdown = () => {
        if (expanded == true) {
            // collapse dropdown
            Animated.timing(animatedHeight, {
              toValue: 0,
              duration: 200,
            }).start()

        } else {
            // expand dropdown
             Animated.timing(animatedHeight, {
              toValue: 100,
              duration: 200,
            }).start()
        }
        setExpanded(!expanded)
    }

    const renderAdditionalContent = () => {
      setDynamicHeight(75);

      if (someVariable == true) {
        return (
          <View> <Text> Some Content </Text> </View>
        )
      }
    }

    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 + dynamicHeight }]}
        >
            <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} />

                    { renderAdditionalContent() }

                </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;

How can this be accomplished? So far ive tried creating a state variable dynamicHeight and setting it inside the function that renders additional content, but that hasn't worked.

Heres the snack: https://snack.expo.io/P6WKioG76

clarification edit: renderAdditionalContent function renders additional content (obviously), this content could be anywhere from one line of characters to multiple lines. regardless of the char count, the main parent container of the component needs to have all the children within its bounds. as it stands, if the additional-content-rendered has too much content, the content will spill over the border of the component's main parent container, which must be avoided. this can be done by simply not giving a height to the main component container, obviously. but the the idea is to have the animated height AND wrap the child content properly

Any suggestions?

Jim
  • 1,988
  • 6
  • 34
  • 68

3 Answers3

6

EDITED : easy and simple way

You can also apply the height 100%, in that way we don't need to calculate the height of inner content it will auto adjust as per the content provided

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

To make this happen I have added <View> tag around the <Animated.View> to get height 100% correct and few changes in css, this looks more elegant and provides a perfect solution to your problem.

WORKING DEMO


So, you can use onLayout

This event is fired immediately once the layout has been calculated

First step:

// setHeight 
const setHeight = (height) => {
    setDynamicHeight(prev => prev + height);
}

<View onLayout={(event) => {
    var { x, y, width, height } = event.nativeEvent.layout;
    setHeight(height); // <--- get the height and add it to total height
}}>
    <Text>Subject</Text>

    <Text>Subject Content</Text>

    {renderAdditionalContent()}
</View>

Second step:

useEffect(() => {
    // trigger if only expanded
    if (expanded) {
        // trigger height animation , whenever there is change in height
        Animated.timing(animatedHeight, {
            toValue: dynamicHeight, // <--- animate to the given height
            duration: 200,
        }).start();
    }
}, [dynamicHeight]); // <--- check of height change

WORKING DEMO (you can test it by adding remove text)

Vivek Doshi
  • 56,649
  • 12
  • 110
  • 122
2

Here's a working snack of your code with a dynamic dropdown height: https://snack.expo.io/4mT5Xj6qF

You can change the height by changing the thisIsAValue constant.

Honestly you were very close with your code, you just had a few bits and pieces missing:

  • When you're animating height you don't need to use interpolate, you can let the <Animated.View> straight up calculate the height. The interpolation is needed if you want to animate, for example, a rotation, and you need to calculate the degrees to which the element must move.
  • You need to pass your dynamic height into the animation, as your to: value
  • I set up a simple useEffect hook checking for changes in the someVariable and dynamicHeight prop

This solution will let you set the height dynamically through a prop. However if you want to calculate the height based on elements which are present in the View you may want to check @vivek-doshi 's answer

p-syche
  • 575
  • 1
  • 5
  • 17
1

You can use React refs. This allows you to access a component directly.

const el = React.useRef(null);

Assign el to the ref of the container div of whatever content you have. Then hide/show the div using the visibility style:

<div style={{ visibility: !expanded && "hidden" }} ref={el}>
   <View /* snip */>
      { renderAdditionalContent() }
      /* -- snip -- */
   </View>
</div>

Then in the toggleDropdown function you can access the height of the component via the .current.offsetHeight property:

Animated.timing(animatedHeight, {
  toValue: el.current.offsetHeight,
  duration: 200,
}).start()

Refs can be used on any html element and allow you to access the element's raw properties. Animations are probably one of the most common use cases for them.

Demo: https://snack.expo.io/heLsrZNpz

You can read more here: https://reactjs.org/docs/refs-and-the-dom.html

Dylan Kerler
  • 2,007
  • 1
  • 8
  • 23
  • 1
    Hi Dylan! You used a `
    ` element and you pointed to ReactJS docs, but the question is asked about React Native. Other than that, using the useRef hook seems like a good idea!
    – p-syche Jun 17 '20 at 21:54