【詳解】純 React Native 程式碼自定義折線圖元件 (譯)

Marno發表於2017-05-22
  • 本文為 Marno 翻譯,轉載必須保留出處!
  • 公眾號【 aMarno 】,關注後回覆 RN 加入交流群
  • React Native 優秀開源專案大全:www.marno.cn

一、前言

原文地址:medium.com/wolox-drivi…

在移動應用中製作折線圖表是一件具有挑戰性的事。本文將會教你如何只用 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
  }
});複製程式碼

【詳解】純 React Native 程式碼自定義折線圖元件 (譯)
圖 1 ▲

三、繪製背景

重複使用 ,完成折線圖背景水平軸的繪製,為了以後方便呼叫,我們將這個過程封裝起來。

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 屬性,是如何傳遞給我們之前的那個 元件的。

至於 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。最終呈現的結果如下圖:

【詳解】純 React Native 程式碼自定義折線圖元件 (譯)

四、新增資料

現在來到了比較棘手的部分,在剛剛繪製好的背景上,繪製折線圖所需的 點 和 折線。這裡我們將會用到 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 的起點。你可以在這裡看到關於這部分的完整程式碼(公眾號使用者點選原文閱讀):gist.github.com/mvbattan/2c…

至此,我們已經完成了以下內容,如圖 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>
    );
  }
}複製程式碼

【詳解】純 React Native 程式碼自定義折線圖元件 (譯)

六、迭代內容

回顧一下我們上文中提到的有爭議的模組,Scaler.js,一旦我們完成了這些 points 和 lines 的繪製,我們需要校準 startingPoint 和 endingPoint 。為此,我們準備了一個簡單的試錯過程(如果你發現了自動完成詞步驟的方法,請一定要告訴我!)。

七、幾乎完成

最終,我們很簡單的給 X 軸加上了座標,具體程式碼如下。(實現效果如圖 4)。原始碼地址在這裡:gist.github.com/mvbattan/e2…

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 自定義元件的好文章比較少,我覺得這就是一篇不錯的文章,看完以後覺得整體思路還是比較簡單的。非常適合初學者學習 React Native 自定義元件,當然結合文中的原始碼練習一下是比較好的。原始碼地址:gist.github.com/mvbattan

本文原作者說會在後續的文章中會介紹如對該折線圖新增動畫。如果文章更新了,我也會第一時間同步過來的。


【詳解】純 React Native 程式碼自定義折線圖元件 (譯)

相關文章