【详解】纯 React Native 代码自定义折线图组件(译)
- 本文为 Marno 翻译,转载必须保留出处!
- 公众号【 Marno 】,关注后回复 RN 加入交流群
- React Native 优秀开源项目大全:http://www.marno.cn
一、前言
在移动应用中制作折线图表是一件具有挑战性的事。本文将会教你如何只用 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
本文原作者说会在后续的文章中会介绍如对该折线图添加动画。如果文章更新了,我也会第一时间同步过来的。