React Native 动画小结

2,295 阅读7分钟

引言

三十功名尘与土,八千里路云和月。莫等闲,白了少年头,空悲切。——岳飞《满江红》

在脉脉做 React Native 也有一小段时间了,大概小半年吧,期间主要负责搜索相关的开发。和之前的 PC 端的开发不同的是,C 端(移动端)会更加注重用户的体验,而流程、有意义的动画对于移动用户的使用体验的提升,是非常重要的。

接下来,让我们来聊聊如何在 React Native 中,实现各种各样的小动画吧~

前置知识

如何完成一个基础的动画

创建动画,首先需要创建一个 Animated.Value ,将它连接到动画组件的一个或多个样式属性,然后使用Animated.timing()通过动画效果展示数据的变化。

动画组件就是 Animated仅封装了 6 个可以动画化的组件:ViewTextImageScrollViewFlatListSectionList,不过你也可以使用Animated.createAnimatedComponent()来封装你自己的组件。

不要直接修改 Animated.Value 的值,应该使用 useRef Hook 或者 state 返回一个动画值的引用,然后使用 xxx.setValue(val) 来设置那种不需要动画过程的值;使用 Animated.timing 等函数来设置需要过程的动画。更加详细的教程、API解析还请移步官方文档,这里不再赘述。

可以设置什么样的属性用来呈现动画

下面是我最近开发的时候碰到的一个报错:

大致就是说,在 React Native 中,Native 模块不支持使用 height 属性来实现动画。翻了一下官方的博客,在 Using Native Driver for Animated 中有提到:只能使用 transformopacity 这种非布局属性来实现动画,但是不支持使用 Flexboxposition 这种影响布局的样式属性来实现动画。

一些常用的示例

接下来,带大家看几个工作中常见的小动画:

最简单的动画:反馈提示 Message 组件

如上图,我们需要在进入这个页面的时候,在顶部弹出一个提示。下面是相关代码:

import React, { useEffect, useState, useRef } from 'react';
import { Animated, Easing, Text } from 'react-native';

function AnimatedMessage() {
  const translateY = useRef(new Animated.Value(0)); // 初始化动画
  const [display, setDisplay] = useState('flex');
  useEffect(() => {
    openAnimation(); // 成功加载组件之后,触发动画
  }, []);

  const openAnimation = () => {
    // 触发第一阶段展开提示信息的动画
    Animated.timing(translateY.current, {
      toValue: 36,
      duration: 1500,
      easing: Easing.elastic(0.8),
    }).start(closeAnimation);
  };

  const closeAnimation = () => {
    // 触发第二阶段收起提示信息的动画
    setTimeout(() => {
      Animated.timing(translateY.current, {
        toValue: 0,
        duration: 1500,
        easing: Easing.elastic(0.8),
      }).start(() => {
        // 彻底隐藏该元素
        setDisplay('none');
      });
    }, 800);
  };

  const wrapperStyles = {
    position: 'absolute',
    top: 0,
    left: 0,
    width: '100%',
    transform: [{ translateY: translateY.current }],
    display,
  };

  return (
    <Animated.View style={wrapperStyles}>
      <Text
        style={{
          fontFamily: 'PingFangSC-Regular',
          color: '#724804',
          fontSize: 13,
        }}
        numberOfLines={1}
      >
        会员搜索权益,本次为你找到 999+ 个人脉
      </Text>
    </Animated.View>
  );
}

export default AnimatedMessage;

我们来分解一些这个过程。

首先,我们有一个 Animated.View 组件,使用绝对定位,让它位于页面的顶部。我们可以通过修改 transform:translateY 属性来实现元素在 Y 轴的偏移。

组件完成初始化之后,调用 openAnimation 函数来触发动画的第一阶段。

在动画的第一阶段,我们使用 Animated.timing修改元素的 translateY 值,0 -> 36,使得整个过程呈一个线性的变化。这样,我们就能看到整个元素从列表顶部慢慢的冒出来了。接下来,我们需要再让组件原路返回,在其 start回调中,触发动画的第二阶段 closeAnimation

在动画的第二阶段,我们再次使用 Animated.timing修改元素元素的 translateY 值,36 -> 0,这样,我们就可以看到组件慢慢的收起了。最后在其 start回调中,修改 display:none,彻底的隐藏元素。

组合动画:弹窗 Modal 组件

在我们日常工作中,弹窗是一个更加常见的使用场景。而官方的 Modal 组件只是一个可以盖在整个视图上的组件,其他的效果还需要开发者自行实现。首先,看下最终效果:

如上图,当点击「职位要求」之后,需要出现一个黑色半透明的遮罩,以及一个盖在上面的内容区块。下面是相关代码:

import React, { useEffect, useState, useRef } from 'react';
import {
  Modal,
  Dimensions,
  Animated,
  TouchableWithoutFeedback,
  View,
  Easing,
} from 'react-native';

import _ from 'underscore';

const screenHeight = Dimensions.get('screen').height;

function BasicModal(props) {
  const {
    visible = false,
    // Wrapper
    style = {},
    // Body
    bodyStyle = {},
    // Mask
    maskStyle,
    onBodyLayout,
    onClose,
  } = props;

  const [animatedVisible, setAnimatedVisible] = useState(visible);
  // ========================= Anim =========================
  const animRef = useRef({
    backgroundOpacityValue: new Animated.Value(0),
    transformYValue: new Animated.Value(0),
  });

  const updateAnimValue = val => {
    animRef.current.backgroundOpacityValue.setValue(val);
    animRef.current.transformYValue.setValue(val);
  };

  const showAnimating = () => {
    updateAnimValue(0);

    Animated.parallel([
      Animated.timing(animRef.current.backgroundOpacityValue, {
        toValue: 1,
        duration: 300,
        easing: Easing.easeIn,
        useNativeDriver: true,
      }),
      Animated.spring(animRef.current.transformYValue, {
        toValue: 1,
        tension: 50,
        friction: 8,
        useNativeDriver: true,
      }),
    ]).start();

    setAnimatedVisible(true);
  };

  const hideAnimating = () => {
    Animated.parallel([
      Animated.timing(animRef.current.backgroundOpacityValue, {
        toValue: 0,
        duration: 100,
        easing: Easing.linear,
        useNativeDriver: true,
      }),

      Animated.spring(animRef.current.transformYValue, {
        toValue: 0,
        tension: 50,
        friction: 7,
        useNativeDriver: true,
      }),
    ]).start();

    setTimeout(() => {
      setAnimatedVisible(false);
    }, 200);
  };
  // ========================= Effect =========================

  useEffect(() => {
    if (visible) {
      showAnimating();
    } else {
      hideAnimating();
    }
    return () => {};
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [visible]);
  // ========================= Event =========================
  const handleClose = _.debounce(
    () => {
      hideAnimating();

      onClose?.();
    },
    100,
    true
  );

  // ========================= render =========================
  const renderBackground = () => {
    const bgOpacityAnim = animRef.current.backgroundOpacityValue.interpolate({
      inputRange: [0, 1],
      outputRange: [0, 0.8],
    });

    return (
      <TouchableWithoutFeedback onPress={handleClose}>
        <Animated.View
          style={{
            position: 'absolute',
            opacity: bgOpacityAnim,
            left: 0,
            top: 0,
            right: 0,
            bottom: 0,
            backgroundColor: '#333333',
            ...maskStyle,
          }}
        />
      </TouchableWithoutFeedback>
    );
  };

  const renderPanelContent = () => {
    const transformYValue = animRef.current.transformYValue.interpolate({
      inputRange: [0, 1],
      outputRange: [screenHeight, 0],
    });
    const contentStyle = {
      ...style,
    };

    return (
      <Animated.View
        style={{
          transform: [
            {
              translateY: transformYValue,
            },
          ],
          position: 'absolute',
          bottom: 30,
          left: 16,
          right: 16,
          flex: 1,
        }}
      >
        <Animated.View style={contentStyle}>

          {/* Body */}
          <View style={bodyStyle}>{props.children}</View>

        </Animated.View>
      </Animated.View>
    );
  };

  const renderPanel = () => {
    return (
      <View style={{ flex: 1 }}>
        {renderBackground()}
        {renderPanelContent()}
      </View>
    );
  };

  return (
    <Modal
      visible={animatedVisible}
      animationType={'none'}
      transparent={true}
      presentationStyle={'overFullScreen'}
      onRequestClose={handleClose}
      hardwareAccelerated={true}
    >
      <View
        style={{ top: 0, left: 0, bottom: 0, right: 0, position: 'absolute' }}
        onLayout={onBodyLayout}
      />
      {renderPanel()}
    </Modal>
  );
}

export default BasicModal;

我们来分解一些整个过程。

这个组件的实现思路,是通过监听 props.visible 的改变,来触发弹窗「出现」「收起」的动画。

props.visible = true 的时候,弹窗「出现」的动画 showAnimating 会被触发。这个动画由两部分组成,分别是底部的遮罩出现,透明度 0 -> 0.8;内容从底部弹出,Y轴 transform:translateY 屏幕高度 (Dimensions.get('screen').height)-> 0。

多个动画可以通过parallel(同时执行)来实现。底部的遮罩的出现是一个线性的过程,使用的 Animated.timing() 函数即可实现;而内容的出现,有一个弹簧的效果,可以使用 Animated.spring() 来实现。

任何动画的过程,都可以理解为一个 0 -> 1 的过程。因此,当我们在设置 Animated 的动画函数时,都可以将 toValue 设置为 0 或者 1 作为动画开始和结束的值。然后在 render 函数中,调用 interpolate 将这个“标准”的输入,映射成实际的值。

当我们点击遮罩、底部按钮、右上角关闭按钮的时候,会传入 props.visible = false 。弹窗「收起」的动画 hideAnimating 会被触发。

差值:跟随手势的 Tag

当我们在手机上浏览信息时,伴随着我们滑动的手势,在列表进行翻页的同时,也会触发一些动画。

如上图所示,当我们向下滑动时,会展开一些Tag;向上滑动时,会收起这些Tag。这里,就需要监听滚动事件,然后触发相关的动画,具体的代码如下:

import React, { useEffect, useState, useRef } from "react";
import {
  Modal,
  Dimensions,
  Animated,
  TouchableWithoutFeedback,
  View,
  Easing,
} from "react-native";

const screenHeight = Dimensions.get("screen").height;

function ScrollList(props) {
  const {
    visible = false,
    // Wrapper
    style = {},
    // Body
    bodyStyle = {},
    // Mask
    maskStyle,
    onBodyLayout,
    onClose,
  } = props;

  // ========================= Anim =========================
  const animRef = useRef({
    scrollY: new Animated.Value(0),
    transformYValue: new Animated.Value(0),
    translateY: null,
    translateYListener: null,
  });

  // ========================= Effect =========================

  useEffect(() => {
    const scrollY = animRef.current.scrollY;
    let tagHeight = 36;
    let tagHeightInput = tagHeight;
    // 根据不同平台,调整动画帧数
    if (Platform.OS === "android") {
      tagHeightInput *= 2;
    } else {
      tagHeightInput *= 1.2;
    }
    const scrollYClamped = diffClamp(scrollY, 0, tagHeightInput);

    const translateY = scrollYClamped.interpolate({
      inputRange: [0, tagHeightInput], // 修改动画帧数
      outputRange: [0, -tagHeight],
    });

    animRef.current.translateYListener = translateY.addListener(({ value }) => {
      animRef.current.translateYNumber.setValue(value);
    });

    animRef.current.translateY = translateY;

    return () => {
      animRef.current.translateY.removeListener(
        animRef.current.translateYListener
      );
    };
  }, []);
  // ========================= Event =========================

  // ========================= render =========================
  const tagStyles = {};

  const listStyles = {
    flex: 1,
    zIndex: -1,
  };
  // 当列表位置发生偏移的时候,需要增加一个负的 margin 来缩小列表的可视区域,否则会出现遮挡
  if (animRef.current.translateYNumber) {
    listStyles.marginBottom = animRef.current.translateYNumber;
  }
  tagStyles.transform = [{ translateY: animRef.current.translateY }];
  listStyles.transform = [{ translateY: animRef.current.translateY }];

  return (
    <View style={{ flex: 1 }}>
      <Animated.View style={tagStyles}>{tags}</Animated.View>
      <Animated.ScrollView // <-- 使用可动画化的ScrollView组件
        style={listStyles}
        scrollEventThrottle={1} // <-- 设为1以确保滚动事件的触发频率足够密集
        onScroll={Animated.event(
          [
            {
              nativeEvent: {
                contentOffset: { y: animRef.current.scrollY },
              },
            },
          ],
          { useNativeDriver: true } // <-- 加上这一行
        )}
      >
        {content}
      </Animated.ScrollView>
    </View>
  );
}

export default ScrollList;

我们来分解一些整个过程。

可以将整个页面分为2个区域,顶部的Tag,和下面的列表。

进入页面之后,为列表添加滚动事件的监听。因为列表以及顶部的Tag上下的偏移量有一个最大值,和最小值,如果超过这个值即使触发滚动事件也不会修改偏移量。

所以,可以使用 diffClamp 得到一个限制在最小、最大值的新的动画值。然后,使用 interpolation 计算出实际的偏移量。

需要注意的是,如果真机实际体验不佳,设置 interpolation 的时候,可以根据不同平台,调整动画帧数,增加 inputRange 的范围。

结语

在学习 React Native 之初,一个问题伴随着我,就是 React Native 都可以实现哪些动效?通过本文的梳理之后,也有了一个大致的答案。

本文更多的是抛砖引玉,和大家聊聊使用 React Native 实现动画的一些思路,上面例子中的代码,是从工程中脱敏之后总结的🌰 ,并不是可以直接运行的Demo。

PS:对脉脉感兴趣的小伙伴,欢迎发送简历到 496691544@qq.com ,我可以帮忙内推~

参考