React-Native-Svg加PanResponder实现K线图

1,730 阅读3分钟

效果展示

image.png

概述

整个方案完全是纯Javascript的。借助RNPanResponderAPI,动态获取用户触碰的坐标。使用react-native-svg绘制图形。

源码

import React, { useState, useRef, useEffect } from 'react';
import {
  View,
  PanResponder, Text, ActivityIndicator
} from 'react-native';
import Svg, {
  Defs,
  LinearGradient,
  Stop,
  Path,
  Line,
  Image as SvgImage,
} from 'react-native-svg';
import _ from 'lodash';
import ConfigStrings from '@config/strings';
import { dateFormat } from '@utils/common';

/**
 * RN API 实现的K线图
 * 存在问题:
 * @returns
 */
const KLineBase = ({ data = [50, 60, 100, 99, 80, 45, 39, 77, 88], headTipHeight = 30, isLoading = true }) => {
  // k线图 绘制区域高度
  const componentHeight = 248;
  // k线图 绘制区域宽度
  const componentWidth = global.windowWidth - 20;

  // path路径
  const [pathD, setPathD] = useState('');// 折线图
  const [pathDBackground, setPathDBackground] = useState('');// 背景

  // 十字
  const crossTipsWidth = 80;
  const crossTipsHeight = 17;
  const [touchX, setTouchX] = useState(0);
  // 真实的数据和坐标点的数据对集合
  const [isShowCross, setIsShowCross] = useState(false);
  // 十字的x y 坐标
  const [crossData, setCrossData] = useState([]);
  const [crossXy, setCrossXy] = useState(null);
  // 左边列表
  const [leftList, setLeftList] = useState([]);

  // 当前坐标单价
  const [textValue, setTextValue] = useState('当前还没有移动坐标');
  // 当前坐标时间
  const [textDate, setTextDate] = useState('');

  //最大值X、Y的位置
  const [maxXy, setMaxXy] = useState({})
  const [maxY, setMaxY] = useState(0)
  const [minXy, setMinXy] = useState({})
  const [minY, setMinY] = useState(0)


  useEffect(() => {
    if (_.isEmpty(data)) {
      return;
    }
    kLineData(data);
  }, [data]);

  useEffect(() => {
    // 从数组中取最接近的数据。
    const lessArr = crossData.filter((item) => {
      return item.valueX >= touchX;
    }).sort();

    if (_.isEmpty(lessArr)) {
      console.log('没有找到匹配的值!');
      return
    }

    // 设置十字的坐标值
    setCrossXy({
      x: lessArr[0].valueX,
      y: lessArr[0].valueY,
    });
    setTextValue(lessArr[0].value);
    setTextDate(lessArr[0].time);
  }, [touchX]);

  /**
 * 折线图数据处理
 */
  const kLineData = (data) => {
    if (_.isEmpty(data)) {
      return;
    }
    const tempPathD = [];
    // 筛选数据,取y轴数据
    const dataArr = data.map((item) => {
      return item[1];
    });

    // 取最大值
    const maxNumber = Math.max.apply(null, dataArr);
    // 取最小值
    const minNumber = Math.min.apply(null, dataArr);
    // 计算最大值减去最小值的结果
    const v = maxNumber - minNumber;
    // 计算缩小倍率
    const rate = (v / componentHeight) * 1.1;

    setLeftList([maxNumber, minNumber]);

    // 计算每个点之间的间隔
    let kLineInterval = componentWidth / dataArr.length;
    // 生成K线图的path
    let pathX = 0;
    let crossDataTemp = [];
    data.map((item, index) => {
      let tempY = (maxNumber - item[1]) / rate;

      // 时间戳格式化失败参考:https://blog.csdn.net/elichan/article/details/80545429
      const oDate = new Date(_.parseInt(item[0]));

      //得到最大和最小点的位置
      if (item[1] === maxNumber) {
        setMaxXy({ x: pathX, y: tempY })
        setMaxY(tempY - 8)
      }
      if (item[1] === minNumber) {
        setMinXy({ x: pathX, y: tempY })
        setMinY(tempY - 8)
      }

      crossDataTemp.push({
        value: item[1], time: dateFormat(oDate), valueY: tempY, valueX: pathX,
      });

      if (index === 0) {
        tempPathD.push('M', pathX, tempY);
      } else {
        tempPathD.push('L', pathX, tempY);
      }
      pathX += kLineInterval;
    });

    setCrossData(crossDataTemp);

    // K线图
    setPathD(tempPathD.join(' '));

    // K线图的背景
    tempPathD.push('L', pathX - kLineInterval, componentHeight);
    tempPathD.push('L', 0, componentHeight);
    const beginY = (maxNumber - data[0][1]) / rate;
    tempPathD.push('L', 0, beginY);
    setPathDBackground(tempPathD.join(' '));
  };

  /**
     * 触摸显示十字
     */
  const panResponder
    = useRef(PanResponder.create({
      // 要求成为响应者:
      onStartShouldSetPanResponder: (evt, gestureState) => true,
      onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
      onMoveShouldSetPanResponder: (evt, gestureState) => true,
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
      onPanResponderGrant: (evt, gestureState) => {
        // 开始手势操作。给用户一些视觉反馈,让他们知道发生了什么事情!
        // gestureState.{x,y} 现在会被设置为0
        setIsShowCross(true);
        console.log('触摸开始了!');
        // 获取触摸点相对于父元素的x、y轴坐标
        setTouchX(evt.nativeEvent.locationX);
      },
      onPanResponderMove: (evt, gestureState) => {
        // 获取触摸点相对于父元素的x、y轴坐标
        setTouchX(evt.nativeEvent.locationX);
      },
      onPanResponderRelease: (evt, gestureState) => {
        // 用户放开了所有的触摸点,且此时视图已经成为了响应者。
        // 一般来说这意味着一个手势操作已经成功完成。
        setIsShowCross(false);
      },
    })).current;

  // ----------------------------------------------------------------View

  /**
   * 十字交叉点 旧
   * @returns
   */
  const crosssPointView_old = (tipHeight) => {
    return (
      <View style={{ backgroundColor: '#FFF', height: tipHeight }}>
        {/* 第一行 */}
        <View style={{ flexDirection: 'row', justifyContent: 'space-around' }}>
          <Text style={{
            fontFamily: ConfigStrings.fontFamilyNumber, color: '#8A8A8A', fontSize: 12, width: 100,
          }}
          >2021-10-12 00:00
          </Text>
          <Text style={{
            fontFamily: ConfigStrings.fontFamilyText, fontSize: 12, color: '#8A8A8A', minWidth: 130,
          }}
          >流通市值
            <Text style={{ fontWeight: 'bold', color: '#4E5255' }}>¥6.89万亿万</Text>
          </Text>
          <Text style={{
            fontFamily: ConfigStrings.fontFamilyText, fontSize: 12, color: '#8A8A8A', minWidth: 110,
          }}
          >24H额
            <Text style={{ fontWeight: 'bold', color: '#4E5255' }}>¥6222.89亿</Text>
          </Text>
        </View>

        {/* 第二行 */}
        <View style={{ flexDirection: 'row', justifyContent: 'space-around' }}>
          <Text style={{
            fontFamily: ConfigStrings.fontFamilyText, fontSize: 12, color: '#8A8A8A', width: 100,
          }}
          >BTC{' '}
            <Text style={{ fontWeight: 'bold', color: '#4E5255' }}>1</Text>
          </Text>

          <Text style={{
            fontFamily: ConfigStrings.fontFamilyText, fontSize: 12, color: '#8A8A8A', minWidth: 130,
          }}
          >USD{' '}
            <Text style={{ fontWeight: 'bold', color: '#4E5255' }}>$6222.89</Text>
          </Text>

          <Text style={{
            fontFamily: ConfigStrings.fontFamilyText, fontSize: 12, color: '#8A8A8A', minWidth: 110,
          }}
          >CNY{' '}
            <Text style={{ fontWeight: 'bold', color: '#4E5255' }}>¥{textValue}</Text>
          </Text>
        </View>
      </View>
    );
  };

  /**
   * 十字View
   * @returns
   */
  const crossXyView = () => {
    const lineWidth = 0.5;
    const strokeColor = '#8696ba';
    return (
      <>
        {/* x轴 左边半截 */}
        <Line
          x1={crossTipsWidth}
          y1={crossXy.y}
          x2={crossXy.x}
          y2={crossXy.y}
          stroke={strokeColor}
          strokeWidth={lineWidth}
        />

        {/* x轴 右边半截 */}
        <Line
          x1={crossXy.x}
          y1={crossXy.y}
          x2={componentWidth}
          y2={crossXy.y}
          stroke={strokeColor}
          strokeWidth={lineWidth}
        />

        {/* y轴上半截 */}
        <Line
          x1={crossXy.x}
          y1="0"
          x2={crossXy.x}
          y2={crossXy.y}
          stroke={strokeColor}
          strokeWidth={lineWidth}
        />

        {/* y轴下半截 */}
        <Line
          x1={crossXy.x}
          y1={crossXy.y}
          x2={crossXy.x}
          y2={componentHeight - crossTipsHeight}
          stroke={strokeColor}
          strokeWidth={lineWidth}
        />
      </>
    );
  };

  /**
   * 十字交叉点
   * @param {*} tipHeight
   * @returns
   */
  const crosssPointView = () => {
    return (
      <View style={{
        backgroundColor: '#FFF', height: headTipHeight, width: global.windowWidth, flexDirection: 'row', justifyContent: 'space-around', alignItems: 'center', position: "absolute"
      }}
      >
        <Text style={{
          fontFamily: ConfigStrings.fontFamilyNumber, color: '#8A8A8A', fontSize: 12,
        }}
        >{textDate}
        </Text>

        <Text style={{
          fontFamily: ConfigStrings.fontFamilyText, fontSize: 12, color: '#8A8A8A',
        }}
        >USD{' '}
          <Text style={{ fontWeight: 'bold', color: '#4E5255' }}>${textValue}</Text>
        </Text>

        <Text style={{
          fontFamily: ConfigStrings.fontFamilyText, fontSize: 12, color: '#8A8A8A',
        }}
        >CNY{' '}
          <Text style={{ fontWeight: 'bold', color: '#4E5255' }}>¥{Number(textValue * global.susdcny).toFixed(2)}</Text>
        </Text>
      </View>
    );
  };

  return (
    <View style={{ flex: 1, marginTop: -headTipHeight }}>
      {/* 十字交叉点的数据 */}
      {isShowCross && crossXy && crosssPointView()}

      {/* k线图实现 */}
      <View style={{
        marginTop: 32,
        justifyContent: 'center',
        alignItems: 'center',
      }}
      >
        <View
          style={{
            height: componentHeight,
            width: componentWidth,
          }}
          {...panResponder.panHandlers}
        >
          <Svg style={{ flex: 1 }}>
            {/* K 线图 */}
            <Path
              d={pathD}
              stroke="#2B60A6"
              strokeWidth="1"
              fill="none"
              strokeLinecap="round"
            />

            {/* K 线图背景 */}
            {/* 颜色渐变 */}
            <Defs>
              <LinearGradient id="grad" x1="0" y1="1" x2="0" y2="0">
                <Stop offset="1" stopColor="#7eb0fc" stopOpacity="1" />
                <Stop offset="0" stopColor="#d0e2fe" stopOpacity="1" />
              </LinearGradient>
            </Defs>
            <Path
              d={pathDBackground}
              stroke="none"
              fill="url(#grad)"
            />

            {/* 最大 */}
            {maxXy && <Line x1="0" y1={maxXy.y} x2={maxXy.x} y2={maxXy.y} stroke="red" strokeWidth="1" strokeDasharray="2 2" />}

            {/* 最小 */}
            {minXy && <Line x1="0" y1={minXy.y} x2={minXy.x} y2={minXy.y} stroke="green" strokeWidth="1" strokeDasharray="2 2" />}


            <SvgImage
              x="44%"
              y="50%"
              preserveAspectRatio="xMidYMid slice"
              opacity="0.7"
              href={require('@assets/comm/bc_icon.png')}
              clipPath="url(#clip)"
            />

            {/* 十字 */}
            {isShowCross && crossXy && crossXyView()}
          </Svg>
        </View>

        {/* K线图上的浮层 */}
        <View style={{
          height: componentHeight,
          width: componentWidth,
          alignSelf: 'center',
          position: 'absolute',
        }}
        >
          {/* 最大 */}
          <Text style={{ fontSize: 12, fontFamily: ConfigStrings.fontFamilyNumber, position: 'absolute', top: maxY, backgroundColor: 'red', color: '#FFF', paddingHorizontal: 2 }}>{leftList[0]}</Text>
          {/* 最小 */}
          <Text style={{ fontSize: 12, fontFamily: ConfigStrings.fontFamilyNumber, position: 'absolute', top: minY, backgroundColor: 'green', color: '#FFF', paddingHorizontal: 2 }}>{leftList[1]}</Text>


          {/* 左边tips */}
          {isShowCross && crossXy && (
            <View style={{
              alignItems: 'flex-start', position: 'absolute', transform: [{ translateY: crossXy.y - 8 }], backgroundColor: '#0D6AED', width: crossTipsWidth, height: crossTipsHeight,
            }}
            >
              <Text style={{
                color: '#FFF', fontFamily: ConfigStrings.fontFamilyNumber, paddingHorizontal: 5, fontSize: 12,
              }}
              >{`$${Number(textValue).toFixed(2)}`}
              </Text>
            </View>
          )}

          {/* 底部tips */}
          {isShowCross && crossXy && (
            <View style={{
              position: 'absolute',
              bottom: 0,
              transform: [{ translateX: crossXy.x - crossTipsWidth + 40 / 2 }],
              backgroundColor: '#0D6AED',
              width: crossTipsWidth + 40,
              height: crossTipsHeight,
              alignItems: 'center',
              justifyContent: 'center',
            }}
            >
              <Text style={{ color: '#FFF', fontFamily: ConfigStrings.fontFamilyNumber, fontSize: 12 }}>{textDate}</Text>
            </View>
          )}
        </View>

        {/* Loading */}
        <ActivityIndicator size="large" animating={isLoading} color="#cfcfcf" style={{ position: 'absolute' }} />
      </View>
    </View>
  );
};

export default KLineBase;