React Native为滑动列表实现拉出\收回的抽屉动画

942 阅读2分钟

配图来自法国画家Martin Jarrie

一.需求背景

使用手势上滑、下拉,实现类似抽屉的弹出、收回效果。

截屏2024-11-14 13.46.03.png

二.设计思路

2.1 手势

一个基于手势的动画效果,必然要用到React Native官方组件PanResponder,并且使用onPanResponderRelease监听手势在y轴的滑动方向,根据不同的滑动方向控制列表向上拉出或者向下收回。

2.2 动画

既然涉及弹出、收回动画,那必然要用到react-native-reanimated,我所用到的interface如下:

import Animated, {
  useAnimatedStyle,
  withTiming,
  Easing,
  useSharedValue
} from "react-native-reanimated";

react-native-reanimated目前可以为<View /> <Text /> <Image /> <ScrollView /> <FlatList />5种组件添加动画效果,例如,你想为<View />添加动画效果,你需要将React Native的官方组件<View />替换成<Animated.View />

其中

useAnimatedStyle用来定义style,可以提供平移、旋转等动画。

useSharedValue用来定义一个当前实例内全局可用的单例值,例如y轴位移值。

Easing用于定义动画运行效果,例如线性、弹跳等

export declare const Easing: {
    linear: typeof linear;
    ease: typeof ease;
    quad: typeof quad;
    cubic: typeof cubic;
    poly: typeof poly;
    sin: typeof sin;
    circle: typeof circle;
    exp: typeof exp;
    elastic: typeof elastic;
    back: typeof back;
    bounce: typeof bounce;
    bezier: typeof bezier;
    bezierFn: typeof bezierFn;
    steps: typeof steps;
    in: typeof in_;
    out: typeof out;
    inOut: typeof inOut;
};

withTiming让你创建一个能够设置动画周期和运行效果的动画,该方法包含三个参数,toValue用来传入动画运行的位移,config用于传入动画效果配置,callback为你需要处理的回调

/**
 * Lets you create an animation based on duration and easing.
 *
 * @param toValue - The value on which the animation will come at rest - {@link AnimatableValue}.
 * @param config - The timing animation configuration - {@link TimingConfig}.
 * @param callback - A function called on animation complete - {@link AnimationCallback}.
 * @returns An [animation object](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/glossary#animation-object) which holds the current state of the animation.
 * @see https://docs.swmansion.com/react-native-reanimated/docs/animations/withTiming
 */
export declare const withTiming: withTimingType;

2.3 避免手势冲突

因为我当前需要添加上滑、下滑手势的组件中已经包含了一个具有滑动手势的FlashList,因此为了避免冲突,我选择在图中红色部分添加手势,去控制绿色部分的FlashList的父View整体动画位移,这样可以避免添加的手势和FlashList冲突。

三.核心代码

  const bottomViewHeight = 100
  const bottomViewAtTopPosition = 170
  const bottomTanslateY = windowHeight - bottomViewHeight - bottomViewAtTopPosition
  const y = useSharedValue(0);
  
  const panResponder = useRef(
    PanResponder.create({
      onMoveShouldSetPanResponder: (e, gestureState) => {
        return Math.abs(gestureState.dy) > Math.abs(gestureState.dx);
      },
      onPanResponderMove: (e, gestureState) => {
      },
      onPanResponderRelease: (e, gestureState) => {
        //向下滑动
        if (gestureState.dy > 0) {
          //bottomView已经位于页面下方了,不位移,反之才向下位移
          if(bottomViewAtTop == false) {
            y.value =  withTiming(0, { easing: Easing.linear });
          } else {
            y.value =  withTiming(bottomTanslateY, { easing: Easing.linear });
          }
          setBottomViewAtTop(false)
        } else {
          //向上滑动
          if(bottomViewAtTop == true) {
            //bottomView已经位于页面上方了,不位移
            y.value =  withTiming(0, { easing: Easing.linear });
          } else {
            y.value =  withTiming(-bottomTanslateY, { easing: Easing.linear });
          }
          setBottomViewAtTop(true)
        }
      },
    })
  ).current;
  
  const animatedJobListStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateY: withTiming(y.value, {
          duration: 50,
          easing: Easing.linear,
        }),
      },
    ],
  }));
  
  return (
          <Animated.View style={[styles.jobListView, animatedJobListStyle]}>
            <Animated.View {...panResponder.panHandlers}>
            <View style={styles.filterRoot}>
              <Text style={[textStyles.semibold18]}>
                职位
              </Text>
              <View style={styles.shortLineView}>
                <Image style={styles.shortLine} source={require('../../../assets/images/ShortLine.png')}></Image>
              </View>
              <Image style={styles.search} source={require('../../../assets/images/search_big.png')}></Image>
            </View>
            <View style={styles.filterBack}>
              <Pressable style={styles.filterButton} onPress={() => { }}>
                <Text style={styles.filterText}>职位</Text>
                <Image style={styles.downArrow} source={downArrowIcon}></Image>
              </Pressable>
              <Pressable style={styles.filterButton} onPress={() => { }}>
                <Text style={styles.filterText}>经验</Text>
                <Image style={styles.downArrow} source={downArrowIcon}></Image>
              </Pressable>
              <Pressable style={styles.filterButton} onPress={() => { }}>
                <Text style={styles.filterText}>城市</Text>
                <Image style={styles.downArrow} source={downArrowIcon}></Image>
              </Pressable>
              <Pressable style={styles.filterButton} onPress={() => { }}>
                <Text style={styles.filterText}>薪资</Text>
                <Image style={styles.downArrow} source={downArrowIcon}></Image>
              </Pressable>
            </View>
            </Animated.View>
            <View style={{ width: windowWidth, height: bottomTanslateY+30 }}>
              <FlashList
                showsVerticalScrollIndicator={false}
                ListEmptyComponent={renderEmpty}
                onEndReachedThreshold={0.3}
                onEndReached={onLoadMore}
                keyExtractor={item => `${item.id}`}
                data={jobs}
                renderItem={renderItem}
                contentContainerStyle={{ backgroundColor: 'rgb(243,245,250)' }}
                ListFooterComponent={renderFooter}
                extraData={jobData}
                estimatedItemSize={30}
                nestedScrollEnabled
              />
            </View>
        </Animated.View>
  )

四.实现效果

最后,看看最终的实现效果

RPReplay_Final173156 -middle-original.gif