-
-
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> |
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.
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!
Hey @nandorojo, any tips for using this in a nested
FlashList
?For context, I'm building nested comments (similar to what you'd find on i.e Reddit). a
<Comment>
renders a nested<FlashList>
which renders more<Comment>
s. the<Comment>
is wrapped inAnimateHeight
.it seems like on the initial load, things are quite sluggish and a lot of the
<Comment>
components are either rendering at odd heights or animating open (when I'd expect them to be initially open, and only animate on interaction). Any tips would be appreciated!