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

  • 本文为 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

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


推荐阅读更多精彩内容