【詳解】純 React Native 代碼自定義折線圖組件(譯)

  • 本文為 Marno 翻譯,轉載必須保留出處!
  • 公眾號【 Marno 】,關注后回復 RN 加入交流群
  • React Native 優(yōu)秀開源項目大全:http://www.marno.cn

一、前言

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

在移動應用中制作折線圖表是一件具有挑戰(zhàn)性的事。本文將會教你如何只用 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 開始。

使用一下上面代碼中封裝好的組件。(這里注意一下組件在傳遞的過程中名字發(fā)生了變化,如果沒有看懂,可以多看幾遍)

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 。為此,我們準備了一個簡單的試錯過程(如果你發(fā)現了自動完成詞步驟的方法,請一定要告訴我!)。

七、幾乎完成

最終,我們很簡單的給 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

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


最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發(fā)現了一具尸體,經...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容