-
-
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 for sharing!
sure thing. i have a newer version without re-renders, i’ll try to share it soon.
@nandorojo please do!
Hey @nandorojo ! thanks for sharing. Wondering if you have any updates to this? :)
Just updated it!
@nandorojo you're the real MVP! Thank you! ❤️
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 in AnimateHeight
.
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!
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!
this is amazing, thanks for sharing.