效果展示
概述
整个方案完全是纯Javascript的。借助RN
PanResponder
API,动态获取用户触碰的坐标。使用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;