美文网首页React NativeReact NativeReact-Native
【详解】纯 React Native 代码自定义折线图组件(译)

【详解】纯 React Native 代码自定义折线图组件(译)

作者: Marno | 来源:发表于2017-05-22 08:24 被阅读617次
    • 本文为 Marno 翻译,转载必须保留出处!
    • 公众号【 Marno 】,关注后回复 RN 加入交流群
    • React Native 优秀开源项目大全:http://www.marno.cn

    一、前言

    原文地址:https://medium.com/wolox-driving-innovation/https-medium-com-wolox-driving-innovation-bring-your-data-to-life-278d97e454b9

    在移动应用中制作折线图表是一件具有挑战性的事。本文将会教你如何只用 Component 和 StyleSheet 在 React Native 中制作一个折线图。

    我们参考的是 《 Let’s drawing charts in React-Native without any library 》(需翻Q), 他介绍了如何在不引入三方库的情况下,在 React Native 中绘制柱状图和条形图。虽然在 react-native-chart这个库中已经有折线图了, 然而,今天我们要来定制我们自己的。

    二、开始动手

    首先,我们必须先绘制背景,为了显示水平轴,第一步要先绘制一些数字和直线。代码如下:

    import React from 'react';
    import { View, StyleSheet, Text } from 'react-native';
    
    export default function LevelSeparator({ label, height }) {
      return (
        <View style={[styles.container, { height }]}>
          <Text style={styles.label}>
            {label.toFixed(0)}
          </Text>
          <View style={styles.separatorRow}/>
        </View>
      );
    }
    
    LevelSeparator.propTypes = {
      label: React.PropTypes.number.isRequired,
      height: React.PropTypes.number.isRequired
    };
    
    export const styles = StyleSheet.create({
      container: {
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center'
      },
      label: {
        textAlign: 'right',
        width: 20
      },
      separatorRow: {
        width: 250,
        height: 1,
        borderWidth: 0.5,
        borderColor: 'rgba(0,0,0,0.3)',
        marginHorizontal: 5
      }
    });
    

    我们添加了一个 height 属性,因为我们会在下一步用到它。

    然后使用上面封装好的直线组件,得到下图 1。代码如下:

    export default class lineChartExample extends Component {
      render() {
        return (
          <View style={styles.container}>
            <LevelSeparator height={30} label={10} />
          </View>
        );
      }
    }
    
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: '#F5FCFF',
        height: 100
      }
    });
    
    图 1 ▲

    三、绘制背景

    重复使用 <LevelSeparators />,完成折线图背景水平轴的绘制,为了以后方便调用,我们将这个过程封装起来。

    import React from 'react';
    import { View, StyleSheet } from 'react-native';
    
    import LevelSeparator from './LevelSeparator';
    
    export const range = (n) => {
      return [...Array(n).keys()];
    };
    
    function createSeparator(totalCount, topValue, index, height) {
      return (
        <LevelSeparator
          key={index}
          label={topValue * (totalCount - index) / totalCount}
          height={height / totalCount}
        />
      );
    }
    
    function SeparatorsLayer({ topValue, separators, height, children, style }) {
      return (
        <View style={[styles.container, style]}>
          {range(separators + 1).map((separatorNumber) => {
            return createSeparator(separators, topValue, separatorNumber, height);
          })}
          {children}
        </View>
      );
    }
    
    SeparatorsLayer.propTypes = {
      topValue: React.PropTypes.number.isRequired,
      separators: React.PropTypes.number.isRequired,
      height: React.PropTypes.number.isRequired
    };
    
    const styles = StyleSheet.create({
      container: {
        position: 'absolute'
      }
    });
    
    export default SeparatorsLayer;
    

    请注意下,这里的接收到的 height 属性,是如何传递给我们之前的那个 <LevelSeparator /> 组件的。

    至于 label 值的计算,这里给出一个计算公式 topValue * (totalCount - index) / totalCount,需要注意的是 index 是从上到下排的序,下标从 0 开始。

    使用一下上面代码中封装好的组件。(这里注意一下组件在传递的过程中名字发生了变化,如果没有看懂,可以多看几遍)

    export default class lineChartExample extends Component {
      render() {
        return (
          <View style={styles.container}>
            <SeparatorsLayer topValue={10} separators={5} height={100} />
          </View>
        );
      }
    }
    

    这里设置: topValue 为 10 ,separators 为 5 ,计算得到的步距就是 10 / 5 = 2。最终呈现的结果如下图:

    四、添加数据

    现在来到了比较棘手的部分,在刚刚绘制好的背景上,绘制折线图所需的 点 和 折线。这里我们将会用到 Point 和 代数运算。

    import React from 'react';
    
    export const Point = (x, y) => {
      return { x, y };
    };
    
    export const dist = (pointA, pointB) => {
      return Math.sqrt(
        (pointA.x - pointB.x) * (pointA.x - pointB.x) +
        (pointA.y - pointB.y) * (pointA.y - pointB.y)
      );
    };
    
    export const diff = (pointA, pointB) => {
      return Point(pointB.x - pointA.x, pointB.y - pointA.y);
    };
    
    export const add = (pointA, pointB) => {
      return Point(pointA.x + pointB.x, pointA.y + pointB.y);
    };
    
    export const angle = (pointA, pointB) => {
    
      const euclideanDistance = dist(pointA, pointB);
    
      if (!euclideanDistance) {
        return 0;
      }
    
      return Math.asin((pointB.y - pointA.y) / euclideanDistance);
    };
    
    export const pointPropTypes = {
      x: React.PropTypes.number.isRequired,
      y: React.PropTypes.number.isRequired
    };
    

    在渲染时映射我们的 point 列表,这将有助于防止出现渲染警告。

    export const keyGen = (serializable, anotherSerializable) => {
      return `${JSON.stringify(serializable)}-${JSON.stringify(anotherSerializable)}`;
    };
    

    接下来是有争议的模块,我们将重新测量我们的 points:

    import { Point } from './pointUtils';
    
    export const startingPoint = Point(-20 , 8);
    const endingPoint = Point(242, 100);
    
    export function vectorTransform(point, maxValue, scaleCount) {
      return Point(
        point.x * (endingPoint.x / scaleCount) + endingPoint.x / scaleCount,
        point.y * (endingPoint.y / maxValue)
      );
    }
    

    ** startingPoint 和 endingPoint 的意义是什么呢?**
    这些点分别代表的是我们所用到的 layer 内的 (0,0)和(MAX-X,MAX-Y)坐标点。

    scaleCount 只是为了帮助我们调整 X 轴的大小。
    The scaleCount simply helps to resize the X-Axis (实现这一目的的另一种方法是处理 X 轴的最大值, 并且在坐标之间进行类似的计算)。

    五、折线图成型

    为了绘制 points ,我们需要:

    export const createPoint = (coordinates, color, size = 8) => {
      return {
        backgroundColor: color,
        left: coordinates.x - 3,
        bottom: coordinates.y - 2,
        position: 'absolute',
        borderRadius: 50,
        width: size,
        height: size
      };
    };
    

    我们通过 (-3,-2)定位我们的中心点坐标,这些值取决于点的大小,更准确的说,是点的半径。

    export const createLine = (dist, angle, color, opacity, startingPoint) => {
      return {
        backgroundColor: color,
        height: 4,
        width: dist,
        bottom: dist * Math.sin(angle) / 2 + startingPoint.y,
        left: -dist * (1 - Math.cos(angle)) / 2 + startingPoint.x,
        position: 'absolute',
        opacity,
        transform: [
          { rotate: `${(-1) * angle} rad` }
        ]
      };
    };
    

    starting point 有助于在屏幕上移动我们的 line。这个初始点将很方便的连接它们之间的点:我们只需要简单的将上一个点作为直线的起点即可。

    为此,我们必须需要接收一个指定的距离和角度才能绘制折线。可能出现的一个问题是 Transform API 按照顺时针旋转,但是我们计算了 Z 轴正轴上的值,即逆时针方向的值。因此我们需要使用于此角度相反的值。

    这里遇到的另一个问题是,如果我们旋转一个 View ,我们将需要确保旋转中心是从当前 line 的起点开始的。这个 API 方法对 View 的旋转是以该组件的中心点为轴心旋转的,换句话说,我们需要将旋转中心改为 line 的起点。你可以在这里看到关于这部分的完整代码(公众号用户点击原文阅读):https://gist.github.com/mvbattan/2c36db8f27f8691955bd8474620ba6e5

    至此,我们已经完成了以下内容,如图 3 。

    mport SeparatorsLayer from './SeparatorsLayer';
    import PointsPath from './PointsPath';
    import { Point } from './pointUtils';
    import { startingPoint, vectorTransform } from './Scaler';
    
    const lightBlue = '#40C4FE';
    const green = '#53E69D';
    
    const lightBluePoints = [Point(0, 0), Point(1, 2), Point(2, 3), Point(3, 6), Point(5, 6)];
    const greenPoints = [Point(0, 2), Point(3, 4), Point(4, 0), Point(5, 10)];
    
    const MAX_VALUE = 10;
    const Y_LEVELS = 5;
    const X_LEVELS = 5;
    
    export default class lineChartExample extends Component {
      render() {
        return (
          <View style={styles.container}>
            <SeparatorsLayer topValue={MAX_VALUE} separators={Y_LEVELS} height={100}>
              <PointsPath
                color={lightBlue}
                pointList={lightBluePoints.map(
                  (point) => vectorTransform(point, MAX_VALUE, X_LEVELS)
                )}
                opacity={0.5}
                startingPoint={startingPoint}
              />
              <PointsPath
                color={green}
                pointList={greenPoints.map(
                  (point) => vectorTransform(point, MAX_VALUE, X_LEVELS)
                )}
                opacity={0.5}
                startingPoint={startingPoint}
              />
            </SeparatorsLayer>
          </View>
        );
      }
    }
    

    六、迭代内容

    回顾一下我们上文中提到的有争议的模块,Scaler.js,一旦我们完成了这些 points 和 lines 的绘制,我们需要校准 startingPoint 和 endingPoint 。为此,我们准备了一个简单的试错过程(如果你发现了自动完成词步骤的方法,请一定要告诉我!)。

    七、几乎完成

    最终,我们很简单的给 X 轴加上了坐标,具体代码如下。(实现效果如图 4)。源码地址在这里:https://gist.github.com/mvbattan/e2498e6f487a068e180b83c3afc6162a

    import React, { Component } from 'react';
    import {
      AppRegistry,
      StyleSheet,
      Text,
      View
    } from 'react-native';
    
    import SeparatorsLayer from './SeparatorsLayer';
    import PointsPath from './PointsPath';
    import { Point } from './pointUtils';
    import { startingPoint, vectorTransform } from './Scaler';
    
    const lightBlue = '#40C4FE';
    const green = '#53E69D';
    const MAX_VALUE = 10;
    const Y_LEVELS = 5;
    const X_LEVELS = 5;
    
    const lightBluePoints = [Point(0, 0), Point(1, 2), Point(2, 3), Point(3, 6), Point(5, 6)];
    const greenPoints = [Point(0, 2), Point(3, 4), Point(4, 0), Point(5, 10)];
    
    export default class lineChartExample extends Component {
      render() {
        return (
          <View style={styles.container}>
            <SeparatorsLayer topValue={MAX_VALUE} separators={Y_LEVELS} height={100}>
              <PointsPath
                color={lightBlue}
                pointList={lightBluePoints.map(
                  (point) => vectorTransform(point, MAX_VALUE, X_LEVELS)
                )}
                opacity={0.5}
                startingPoint={startingPoint}
              />
              <PointsPath
                color={green}
                pointList={greenPoints.map(
                  (point) => vectorTransform(point, MAX_VALUE, X_LEVELS)
                )}
                opacity={0.5}
                startingPoint={startingPoint}
              />
            </SeparatorsLayer>
            <View style={styles.horizontalScale}>
              <Text>0</Text>
              <Text>1</Text>
              <Text>2</Text>
              <Text>3</Text>
              <Text>4</Text>
              <Text>5</Text>
            </View>
          </View>
        );
      }
    }
    
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: '#F5FCFF',
        height: 100
      },
      horizontalScale: {
        flexDirection: 'row',
        justifyContent: 'space-around',
        marginTop: 150,
        marginLeft: 20,
        width: 290
      }
    });
    
    AppRegistry.registerComponent('lineChartExample', () => lineChartExample);
    

    八、结语

    关于 React Native 自定义组件的好文章比较少,我觉得这就是一篇不错的文章,看完以后觉得整体思路还是比较简单的。非常适合初学者学习 React Native 自定义组件,当然结合文中的源码练习一下是比较好的。源码地址:https://gist.github.com/mvbattan

    本文原作者说会在后续的文章中会介绍如对该折线图添加动画。如果文章更新了,我也会第一时间同步过来的。


    相关文章

      网友评论

      本文标题:【详解】纯 React Native 代码自定义折线图组件(译)

      本文链接:https://www.haomeiwen.com/subject/znlvxxtx.html