-
-
Save nandorojo/8fd2b0f5bd5e75073dcce5a17a6346e4 to your computer and use it in GitHub Desktop.
| import { StyleSheet } from 'react-native' | |
| import Animated, { | |
| useAnimatedStyle, | |
| useSharedValue, | |
| withTiming, | |
| } from 'react-native-reanimated' | |
| import { AnimateHeightProps } from './index.types' | |
| const transition = { duration: 200 } as const | |
| function HeightTransition({ | |
| children, | |
| hide = !children, | |
| style, | |
| onHeightDidAnimate, | |
| initialHeight = 0, | |
| }: AnimateHeightProps) { | |
| const measuredHeight = useSharedValue(initialHeight) | |
| const childStyle = useAnimatedStyle( | |
| () => ({ | |
| opacity: withTiming(!measuredHeight.value || hide ? 0 : 1, transition), | |
| }), | |
| [hide, measuredHeight] | |
| ) | |
| const containerStyle = useAnimatedStyle(() => { | |
| return { | |
| height: withTiming(hide ? 0 : measuredHeight.value, transition, () => { | |
| if (onHeightDidAnimate) { | |
| runOnJS(onHeightDidAnimate)(measuredHeight.value) | |
| } | |
| }), | |
| } | |
| }, [hide, measuredHeight]) | |
| return ( | |
| <Animated.View style={[styles.hidden, style, containerStyle]}> | |
| <Animated.View | |
| style={[StyleSheet.absoluteFill, styles.autoBottom, childStyle]} | |
| onLayout={({ nativeEvent }) => { | |
| measuredHeight.value = Math.ceil(nativeEvent.layout.height) | |
| }} | |
| > | |
| {children} | |
| </Animated.View> | |
| </Animated.View> | |
| ) | |
| } | |
| const styles = StyleSheet.create({ | |
| autoBottom: { | |
| bottom: 'auto', | |
| }, | |
| hidden: { | |
| overflow: 'hidden', | |
| }, | |
| }) | |
| export { HeightTransition } |
| type AnimateHeightProps = { | |
| children?: React.ReactNode | |
| /** | |
| * If `true`, the height will automatically animate to 0. Default: `false`. | |
| */ | |
| hide?: boolean | |
| initialHeight?: number | |
| } & React.ComponentProps<typeof MotiView> |
Thanks @nandorojo, appreciate the help! I took a bit of a stab at it (snack here) but as you can see it doesn't perform particularly well, even moving the sharedValue out into a map.
What's interesting is in my actual application (far more complex than this) I am seeing really smooth animation for the "root" comments, but the nested comments are very jittery. I'm from a web background not native so this all feels like whack-o-mole right now 😓
Yeah, welcome lol. Maybe try reanimated v3 layout animations instead.
@nandorojo Minor correction: runOnJS is not imported from react-native-reanimated in the gist
Here's my current code to enable skipping the initial animation (if not hidden), and only animating subsequent animations (once the children's height has been determined).
/**
* Taken from https://gist.github.com/nandorojo/8fd2b0f5bd5e75073dcce5a17a6346e4
*/
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
WithTimingConfig,
} from 'react-native-reanimated'
type Props = {
children?: React.ReactNode
/**
* Custom transition for the outer View, which animates the `height`.
*
* Defaults to duration of of 200.
*/
heightTransition?: WithTimingConfig
/**
* Custom transition for the inner view that wraps the children, which animates the `opacity`.
* Defaults to duration of of 200.
*/
childrenTransition?: WithTimingConfig
/**
* If `true`, the height will automatically animate to 0. Default: `false`.
*/
hide?: boolean
/**
* If `true`, the initial height will animate in.
* Otherwise it will only animate subsequent height changes.
* Default: `false`.
*/
shouldAnimateInitialHeight?: boolean
/**
* Optionally provide an initial height. You use `shouldAnimateInitialHeight` instead
* if all you're trying to do is prevent the initial height from animating in.
*/
initialHeight?: number
onHeightDidAnimate?: (height: number) => void
style?: StyleProp<ViewStyle>
}
const styles = StyleSheet.create({
autoBottom: {
bottom: 'auto',
},
hidden: {
overflow: 'hidden',
},
})
const defaultTransition: WithTimingConfig = {
duration: 200,
} as const
/**
* Animates the height change of its children
*/
export function AnimateHeight({
children,
heightTransition = defaultTransition,
childrenTransition = defaultTransition,
hide = false,
initialHeight = 0,
onHeightDidAnimate,
style,
shouldAnimateInitialHeight = false,
}: Props) {
// as long as we should animate the initial height (or the content is hidden), we can animate the next height change
const canAnimateNext = React.useRef(hide || shouldAnimateInitialHeight)
const measuredHeight = useSharedValue(initialHeight)
const childStyle = useAnimatedStyle(
() => ({
opacity: withTiming(!measuredHeight.value || hide ? 0 : 1, childrenTransition),
}),
[hide, measuredHeight],
)
const containerStyle = useAnimatedStyle(() => {
return {
height: withTiming(hide ? 0 : measuredHeight.value, heightTransition, () => {
if (onHeightDidAnimate) {
runOnJS(onHeightDidAnimate)(measuredHeight.value)
}
}),
}
}, [hide, measuredHeight])
// just return a normal View with the children if we shouldn't animate yet
if (!canAnimateNext.current) {
return (
<View
style={[styles.hidden, style]}
onLayout={({nativeEvent}) => {
// once we have a height, we can animate the next height changes
if (nativeEvent.layout.height > 0) {
// make sure we set the correct height so the children don't jump
// on the first animation
measuredHeight.value = Math.ceil(nativeEvent.layout.height)
// give it a render loop since we need the containerStyle to update to the
// starting height or it'll animate initially still if a re-render is triggered
// (eg. this can happen if this is within a scrollview in a screen that is being pushed onto the stack.)
setTimeout(() => {
canAnimateNext.current = true
})
}
}}>
{children}
</View>
)
}
return (
<Animated.View style={[styles.hidden, style, containerStyle]}>
<Animated.View
style={[StyleSheet.absoluteFill, styles.autoBottom, childStyle]}
onLayout={({nativeEvent}) => {
measuredHeight.value = Math.ceil(nativeEvent.layout.height)
}}>
{children}
</Animated.View>
</Animated.View>
)
}
Demo
@jstheoriginal Thanks a lot for sharing!

This implementation doesn’t support initial visibility unless you pass an initial height.
I’m not exactly sure how we’d get around that, perhaps with a ref that tracks if a component has mounted. If it has, then you return this code. If it hasn’t, then you just return children directly
if (!shouldAnimateOnMount && !hide).FlashList has its own challenges. Please see their reanimated docs. Since they recycle views, I foresee issues with the shared value that measures height being wrong, since it’ll have measurements across views. You’d likely want to mount all measurements outside of the list in a single shared value
Map, where the keys are the element IDs, and values are the measured heights. You’d then pass down the entire shared value as a prop, and together with useDerviceValue, you’d set / get the measurement. It’s not as simple.Finally, consider: animating height is not efficient. It’s a sad truth. For expensive components, consider whether it’s a necessary UX. It’s possible that a fade + scrollTo animation is best. Or, if it’s just for iOS, LayoutAnimation.configureNext() from RN will likely perform better. Also consider trying reanimated v3 layout animations. Showtime has a FlashList example with those.