0%

react-native 实现数字滚动动画


自从在github上开源了高仿韩寒的one一个项目之后,空闲时间仍旧保持项目的更新和维护,目前公司没有react-native的项目,但我一直对react-native很有兴趣,所以打算一直维护下去并借此机会学习相关的技术。one一个是资讯类的app,主界面是由3个分页组成的,底部有导航栏,是一个比较大众化的展示方式,其中one分页是用日期作为顶部标题,手指每向左滑动一次就往前翻页,向右滑动一次就往后翻页,每天的展示内容就是一页列表,标题就会随着手指的翻页而刷新,显示当前展示内容的发布日期,这里的日期刷新就是一个数字滚动的动画效果。


先上效果图:

效果图

绘制思路如下:

  1. 设置表示数字范围的数字数组
  2. 测量数字的绘制高度
  3. 对当前文本做切割,遍历每个字符,判断当前字符是否是数字,如果是数字,根据目标数字在数字数组中的下标,和当前数字在数字数组中的下标,以及数字绘制的高度,计算出垂直方向y上需要平移的距离,执行平移动画。如果不是数字,直接绘制这个字符文本
  4. 文本由父组件通过props传递,注意props值变化时重置动画属性值,刷新文本内容

    定义数字布局样式

    数字水平排列,需要先放置一个隐藏的text测量数字的高度
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const styles = StyleSheet.create({
    // 数字水平浮动排列
    row: {
    flexDirection: 'row',
    overflow: 'hidden',
    },
    // 隐藏
    hide: {
    position: 'absolute',
    left: 0,
    right: 0,
    opacity: 0,
    },
    });

    父组件需要传入的参数

    文本内容,样式,滚动时长
    其中文本内容可以有两种传递方式:
  5. 通过props.text进行传递
  6. 通过子组件设置成文本内容直接传递

    设置数字范围

    1
    2
    3
    4
    // 指定范围创建数组
    const range = length => Array.from({ length }, (x, i) => i);
    // 创建"0","1","2","3","4"..."9"的数组,默认绘制数据
    const numberRange = range(10).map(p => p + "");

    定义数字滚动动画组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    class Ticker extends Component {

    // 定义属性类型
    static propTypes = {
    text: PropTypes.string,
    textStyle: PropTypes.oneOfType([PropTypes.number, PropTypes.object, PropTypes.array]),
    };
    // 定义默认属性值
    static defaultProps = {
    rotateTime: 250, // 默认滚动时间
    };

    state = {
    measured: false, // 是否已测量
    height: 0, // 高度
    fontSize: StyleSheet.flatten(this.props.textStyle).fontSize, // 获取props中的字体大小
    };

    // props变动时回调
    componentWillReceiveProps(nextProps) {
    this.setState({
    fontSize: StyleSheet.flatten(nextProps.textStyle).fontSize,
    });
    }

    handleMeasure = e => {
    this.setState({
    measured: true, // 修改flag为已测量
    height: e.nativeEvent.layout.height, //测量高度
    });
    };

    /**
    * 渲染
    * @returns {*}
    */
    render() {
    // 获取文本内容,子组件,样式,滚动时长
    const { text, children, textStyle, style, rotateTime } = this.props;
    // 获取高度, 是否测量标记
    const { height, measured } = this.state;
    // 如果未测量则透明
    const opacity = measured ? 1 : 0;
    // 文本内容获取,读取text或子组件内容,两种方式配置文本内容
    const childs = text || children;
    // 如果子组件是字符串,字符串渲染,否则子组件渲染
    return (
    <View style={[styles.row, { height, opacity }, style]}>
    {/*渲染逻辑*/}
    {numberRenderer({
    children: childs,
    textStyle,
    height,
    rotateTime,
    rotateItems: numberRange,
    })}
    {/*测量text高度,不显示该组件*/}
    <Text style={[textStyle, styles.hide]} onLayout={this.handleMeasure} pointerEvents="none">
    0
    </Text>
    </View>
    );
    }
    }
    在render方法中,绘制了一个隐藏的text组件,是为了测量在当前样式下,绘制出的数字高度值
    1
    2
    3
    4
    {/*测量text高度,不显示该组件*/}
    <Text style={[textStyle, styles.hide]} onLayout={this.handleMeasure} pointerEvents="none">
    0
    </Text>
    其中view中的numberRenderer是数字滚动动画的渲染逻辑,这个view是在测量高度之后再显示的。
    在numberRenderer这个方法中,我们需要对当前的文本做切割得到包含文本中每个字符的字符数组,遍历切割后的字符数组,取出每一个字符,判断是否是数字,不是数字就直接绘制文本,Piece是封装的直接用text进行文本绘制的组件,如果是数字就绘制数字动画组件,Tick是封装的单个数字动画绘制的组件。
    实现如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    const numberRenderer = ({ children, textStyle, height, rotateTime, rotateItems }) => {
    // 切割子组件文本内容遍历
    return splitText(children).map((piece, i) => {
    if (!isNumber(piece)) { //取单个字符,如果不是数字,直接绘制文本
    return (
    <Piece key={i} style={{ height }} textStyle={textStyle}>
    {piece}
    </Piece>
    );
    }
    // 如果是数字,绘制单个数字
    return (
    <Tick
    duration={rotateTime}
    key={i}
    text={piece}
    textStyle={textStyle}
    height={height}
    rotateItems={rotateItems}
    />
    );
    });
    };
    文本切割和数字判断方法如下:
    1
    2
    3
    4
    // 切割
    const splitText = (text = "") => (text + "").split("");
    // 是十进制数字判断
    const isNumber = (text = "") => !isNaN(parseInt(text, 10));
    直接用text进行文本绘制的组件Piece,代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /**
    *
    * @param children 子组件(文本内容)
    * @param style 样式
    * @param height 高度
    * @param textStyle 文本样式
    * @returns 无动画绘制文本
    * @constructor
    */
    const Piece = ({ children, style, height, textStyle }) => {
    return (
    <View style={style}>
    <Text style={[textStyle, { height }]}>{children}</Text>
    </View>
    );
    };
    单个数字动画绘制的组件:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    class Tick extends Component {
    /**
    * 创建动画初始值
    * @type {{animation: Animated.Value}}
    */
    state = {
    animation: new Animated.Value(
    getPosition({
    text: this.props.text,
    items: this.props.rotateItems,
    height: this.props.height,
    }),
    ),
    };
    componentDidMount() {
    // 如果高度已测量,设置动画初始值
    if (this.props.height !== 0) {
    this.setState({
    animation: new Animated.Value(
    getPosition({
    text: this.props.text,
    items: this.props.rotateItems,
    height: this.props.height,
    }),
    ),
    });
    }
    }

    componentWillReceiveProps(nextProps) {
    // 高度变化,重置动画初始值
    if (nextProps.height !== this.props.height) {
    this.setState({
    animation: new Animated.Value(
    getPosition({
    text: nextProps.text,
    items: nextProps.rotateItems,
    height: nextProps.height,
    }),
    ),
    });
    }
    }

    componentDidUpdate(prevProps) {
    const { height, duration, rotateItems, text } = this.props;
    // 数字变化,用当前动画值和变化后的动画值进行插值,并启动动画
    if (prevProps.text !== text) {
    Animated.timing(this.state.animation, {
    toValue: getPosition({
    text: text,
    items: rotateItems,
    height,
    }),
    duration,
    useNativeDriver: true,
    }).start();
    }
    }

    render() {
    const { animation } = this.state;
    const { textStyle, height, rotateItems } = this.props;

    return (
    <View style={{ height }}>
    <Animated.View style={getAnimationStyle(animation)}>
    {/*遍历数字范围数组绘制数字*/}
    {rotateItems.map(v => (
    <Text key={v} style={[textStyle, { height }]}>
    {v}
    </Text>
    ))}
    </Animated.View>
    </View>
    );
    }
    }
    这里就封装了一个平移动画,绘制的时候是绘制了整个范围的数字,getPosition这个方法是用来计算目标数字的y轴坐标值,根据当前数字在数组中的下标乘以测量出的数字文本绘制高度取负值,得出坐标值。具体代码如下:
    1
    2
    3
    4
    5
    6
    const getPosition = ({ text, items, height }) => {
    // 获得文本在数组的下标
    const index = items.findIndex(p => p === text);
    // 返回文本绘制的y轴坐标
    return index * height * -1;
    };
    这里需要注意数字变化后的处理,一旦数字变化,就触发动画,当父组件传递的props的值变化了,就会调用子组件的componentDidUpdate方法,可以在componentDidUpdate方法中对文本内容做比对,如果与之前props的text值不一致,表示数字变化,根据目标数字计算目标y值,执行平移动画。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    componentDidUpdate(prevProps) {
    const { height, duration, rotateItems, text } = this.props;
    // 数字变化,用当前动画值和变化后的动画值进行插值,并启动动画
    if (prevProps.text !== text) {
    Animated.timing(this.state.animation, {
    toValue: getPosition({
    text: text,
    items: rotateItems,
    height,
    }),
    duration,
    useNativeDriver: true,
    }).start();
    }
    }
    注意规避高度变化带来的问题,一旦高度变化,重置动画的初始值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    componentWillReceiveProps(nextProps) {
    // 高度变化,重置动画初始值
    if (nextProps.height !== this.props.height) {
    this.setState({
    animation: new Animated.Value(
    getPosition({
    text: nextProps.text,
    items: nextProps.rotateItems,
    height: nextProps.height,
    }),
    ),
    });
    }
    }
    导出封装的组件:
    1
    2
    export { Tick, numberRange }; // 单个数字动画组件,数字范围
    export default Ticker; // 整个数字动画组件
    在项目中使用,展示格式为yyyy / MM / dd的日期:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <Ticker text={this.state.showDate === '0' ? '' : this.state.showDate.substring(0, 4)} textStyle={styles.dateText} rotateTime={1000} />

    <Text style={styles.dividerText}>{this.state.showDate === '0' ? '' : ' / '}</Text>

    <Ticker text={this.state.showDate === '0' ? '' : this.state.showDate.substring(5, 7)} textStyle={styles.dateText} rotateTime={1000} />

    <Text style={styles.dividerText}>{this.state.showDate === '0' ? '' : ' / '}</Text>

    <Ticker text={this.state.showDate === '0' ? '' : this.state.showDate.substring(8, 10)} textStyle={styles.dateText} rotateTime={1000} />