Skip to content

Instantly share code, notes, and snippets.

@nandorojo
Last active April 1, 2024 18:40
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nandorojo/8fd2b0f5bd5e75073dcce5a17a6346e4 to your computer and use it in GitHub Desktop.
Save nandorojo/8fd2b0f5bd5e75073dcce5a17a6346e4 to your computer and use it in GitHub Desktop.
Moti Animate Height
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>
@giacomoalonzi
Copy link

this is amazing, thanks for sharing.

@ribas89
Copy link

ribas89 commented Nov 26, 2022

thanks for sharing!

@nandorojo
Copy link
Author

sure thing. i have a newer version without re-renders, i’ll try to share it soon.

@Jonatthu
Copy link

@nandorojo please do!

@tconroy
Copy link

tconroy commented Jun 23, 2023

Hey @nandorojo ! thanks for sharing. Wondering if you have any updates to this? :)

@nandorojo
Copy link
Author

nandorojo commented Jun 23, 2023

Just updated it!

@tconroy
Copy link

tconroy commented Jun 23, 2023

@nandorojo you're the real MVP! Thank you! ❤️

@tconroy
Copy link

tconroy commented Jun 25, 2023

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!

@nandorojo
Copy link
Author

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.

@tconroy
Copy link

tconroy commented Jun 30, 2023

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 😓

@nandorojo
Copy link
Author

Yeah, welcome lol. Maybe try reanimated v3 layout animations instead.

@dakshshah96
Copy link

@nandorojo Minor correction: runOnJS is not imported from react-native-reanimated in the gist

@jstheoriginal
Copy link

jstheoriginal commented Nov 10, 2023

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

Simulator Screen Recording - iPhone 15 - 2023-11-10 at 10 59 05

@hzmmohamed
Copy link

@jstheoriginal Thanks a lot for sharing!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment