nandorojo/moti

Animations stops after UI thread dip

Closed this issue · 4 comments

Is there an existing issue for this?

  • I have searched the existing issues

Current Behavior

I see a dip in the UI thread and then it stops animating

May-11-2022 13-38-37

Expected Behavior

firstly that the UI thread doesn't dip and if it does that it won't get stuck in the animation.

Another expected behavior is that I messed something up since I'm new to animations in React Native 🙈

Steps To Reproduce

LoadingSpinner

import { Feather } from '@expo/vector-icons'
import { MotiText } from 'moti'
import React from 'react'
import type { TextStyle } from 'react-native'
import { Easing } from 'react-native-reanimated'

import { Colors, Typography } from '~/constants/Theme'

type Props = {
  color?: TextStyle['color']
  size?: number
  featherProps?: Partial<React.ComponentProps<typeof Feather>>
}

export function LoadingSpinner({ color = Colors['sand'], size = Typography.fontSize['md'] - 1, ...props }: Props) {
  return (
    <MotiText
      from={{
        rotate: '0deg',
      }}
      animate={{
        rotate: '360deg',
      }}
      transition={{
        loop: true,
        repeatReverse: false,
        type: 'timing',
        easing: Easing.linear,
        duration: 4000,
      }}
    >
      <Feather
        name="loader"
        size={size}
        style={{
          paddingBottom: 5,
          color,
        }}
        {...props.featherProps}
      />
    </MotiText>
  )
}

TextField

import { Feather } from '@expo/vector-icons'
import { MotiText, useAnimationState } from 'moti'
import React from 'react'
import { Text, View, TextInput, TextStyle, ViewStyle } from 'react-native'

import { styles, ClassNames } from './index.style'

import { LoadingSpinner } from '~/components/LoadingSpinner'
import { Colors } from '~/constants/Theme'
import { getTestId } from '~/test/getTestId/index'

type Props = {
  label: string
  placeholder?: string
  value?: string
  onChangeText?: TextInput['props']['onChangeText']
  loading?: boolean
  errorMessage?: string
  locked?: boolean
  testIdSuffix?: string
  viewProps?: View['props'] & { style?: ViewStyle }
  textProps?: Text['props'] & { style?: TextStyle }
  textInputProps?: Omit<TextInput['props'], 'onChangeText' | 'value'> & {
    style?: TextStyle
  }
  textInputAttributes?: React.ClassAttributes<TextInput>
}

export function TextField({ loading = false, ...props }: Props) {
  const testId = (elementStyle: keyof ClassNames) =>
    getTestId<ClassNames>('TextField', elementStyle, props.testIdSuffix ?? '')
  const [hasFocus, setHasFocus] = React.useState<boolean | undefined>()

  const revealLabelAnimation = useAnimationState({
    show: {
      opacity: 1,
      top: 0,
    },
    hide: {
      opacity: 0,
      top: 20,
    },
  })

  React.useEffect(() => {
    if (hasFocus || props.value?.length) {
      revealLabelAnimation.transitionTo('show')
    } else {
      revealLabelAnimation.transitionTo('hide')
    }
  }, [hasFocus, props.value, revealLabelAnimation])

  const iconProps = React.useMemo(
    () => ({
      size: 20,
      color: props?.textProps?.style?.color ?? styles.labelText.color,
    }),
    [props?.textProps?.style?.color]
  )

  return (
    <View style={styles.container} {...props.viewProps} testID={testId('container')}>
      <View style={styles.labelView} testID={testId('labelView')}>
        <MotiText
          state={revealLabelAnimation}
          {...props.textProps}
          style={[styles.labelText, props.textProps?.style]}
          testID={testId('labelText')}
        >
          <Text>{props.label}</Text>
        </MotiText>

        {props.locked && <Feather name="lock" {...iconProps} />}

        {loading && <LoadingSpinner featherProps={iconProps} />}

        {!!props.errorMessage && (
          <Feather name="alert-circle" {...iconProps} color={styles.validationErrorText.color} />
        )}
      </View>
      <TextInput
        accessibilityLabel={`${props.label} indtastningsfelt${props.locked ? ' (låst)' : ''}`}
        accessibilityHint={`Indtast ${props.label} her`}
        testID={testId('inputField')}
        placeholder={!hasFocus ? props.label.toUpperCase() : ''}
        placeholderTextColor={Colors['sand']}
        value={props.value}
        onFocus={() => setHasFocus(true)}
        onBlur={() => setHasFocus(false)}
        onChangeText={props.onChangeText}
        editable={!props.locked}
        selectTextOnFocus={!props.locked}
        style={[
          styles.inputField,
          hasFocus && styles.inputFieldFocus,
          props.locked && styles.inputFieldLocked,
          !!props.errorMessage && styles.inputFieldValidationError,
        ]}
        {...props.textInputProps}
        {...props.textInputAttributes}
      />
      {!!props.errorMessage && <Text style={styles.validationErrorText}>{props.errorMessage}</Text>}
    </View>
  )
}

Versions

- Moti: 0.17.1
- Reanimated: ~2.3.1
- React Native: 0.64.3
- expo: ~44.0.0

Screenshots

May-11-2022 13-38-37

Reproduction

Create an via create-react-native-app example here

https://github.com/Norfeldt/moti-loading-spinner-issue

The UI thread dip is likely due to frequent react renders. This should get fixed with transitions in react 18.

Try wrapping your sprinter in React.memo?

are you using hasFocus for anything other than animating? if not, you should just fire the animation state changes in onFocus/onBlur to avoid an extra render

The UI thread dip is likely due to frequent react renders. This should get fixed with transitions in react 18.

Try wrapping your sprinter in React.memo?

That worked like a charm 👍 thanks a ton!

export const LoadingSpinner = React.memo(
  ({ color = Colors['sand'], size = Typography.fontSize['md'] - 1, ...props }: Props) => {
    return (
      <MotiText
        from={{
          rotate: '0deg',
        }}
        animate={{
          rotate: '360deg',
        }}
        transition={{
          loop: true,
          repeatReverse: false,
          type: 'timing',
          easing: Easing.linear,
          duration: 4000,
        }}
      >
        <Feather
          name="loader"
          size={size}
          style={{
            paddingBottom: 5,
            color,
          }}
          {...props.featherProps}
        />
      </MotiText>
    )
  }
)

are you using hasFocus for anything other than animating? if not, you should just fire the animation state changes in onFocus/onBlur to avoid an extra render

I'm using it to hide the placeholder when it gets focus - I'm very open to suggestions.