k_chart_widget.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:k_chart_plus/chart_translations.dart';
  4. import 'package:k_chart_plus/components/popup_info_view.dart';
  5. import 'package:k_chart_plus/k_chart_plus.dart';
  6. import 'renderer/base_dimension.dart';
  7. enum MainState { MA, BOLL, SAR }
  8. enum SecondaryState { MACD, KDJ, RSI, WR, CCI }
  9. class TimeFormat {
  10. static const List<String> YEAR_MONTH_DAY = [yyyy, '-', mm, '-', dd];
  11. static const List<String> YEAR_MONTH_DAY_WITH_HOUR = [
  12. yyyy,
  13. '-',
  14. mm,
  15. '-',
  16. dd,
  17. ' ',
  18. HH,
  19. ':',
  20. nn
  21. ];
  22. }
  23. class KChartWidget extends StatefulWidget {
  24. final List<KLineEntity>? datas;
  25. final Set<MainState> mainStateLi;
  26. final bool volHidden;
  27. final Set<SecondaryState> secondaryStateLi;
  28. // final Function()? onSecondaryTap;
  29. final bool isLine;
  30. final bool
  31. isTapShowInfoDialog; //Whether to enable click to display detailed data
  32. final bool hideGrid;
  33. final bool showNowPrice;
  34. final bool showInfoDialog;
  35. final bool materialInfoDialog; // Material Style Information Popup
  36. final ChartTranslations chartTranslations;
  37. final List<String> timeFormat;
  38. final double mBaseHeight;
  39. // It will be called when the screen scrolls to the end.
  40. // If true, it will be scrolled to the end of the right side of the screen.
  41. // If it is false, it will be scrolled to the end of the left side of the screen.
  42. final Function(bool)? onLoadMore;
  43. final int fixedLength;
  44. final List<int> maDayList;
  45. final int flingTime;
  46. final double flingRatio;
  47. final Curve flingCurve;
  48. final Function(bool)? isOnDrag;
  49. final ChartColors chartColors;
  50. final ChartStyle chartStyle;
  51. final VerticalTextAlignment verticalTextAlignment;
  52. final bool isTrendLine;
  53. final double xFrontPadding;
  54. /// 外部调用 .value = !.value 即可触发隐藏详情
  55. final ValueNotifier<bool>? dismissInfoNotifier;
  56. /// 当前价格字符串(来自 WS ticker),覆盖图表默认的 toStringAsFixed 显示
  57. final String? nowPriceStr;
  58. KChartWidget(
  59. this.datas,
  60. this.chartStyle,
  61. this.chartColors, {
  62. required this.isTrendLine,
  63. this.xFrontPadding = 100,
  64. this.dismissInfoNotifier,
  65. this.nowPriceStr,
  66. this.mainStateLi = const <MainState>{},
  67. this.secondaryStateLi = const <SecondaryState>{},
  68. // this.onSecondaryTap,
  69. this.volHidden = false,
  70. this.isLine = false,
  71. this.isTapShowInfoDialog = false,
  72. this.hideGrid = false,
  73. this.showNowPrice = true,
  74. this.showInfoDialog = true,
  75. this.materialInfoDialog = true,
  76. this.chartTranslations = const ChartTranslations(),
  77. this.timeFormat = TimeFormat.YEAR_MONTH_DAY,
  78. this.onLoadMore,
  79. this.fixedLength = 2,
  80. this.maDayList = const [5, 10, 20],
  81. this.flingTime = 600,
  82. this.flingRatio = 0.5,
  83. this.flingCurve = Curves.decelerate,
  84. this.isOnDrag,
  85. this.verticalTextAlignment = VerticalTextAlignment.left,
  86. this.mBaseHeight = 360,
  87. });
  88. @override
  89. _KChartWidgetState createState() => _KChartWidgetState();
  90. }
  91. class _KChartWidgetState extends State<KChartWidget>
  92. with TickerProviderStateMixin {
  93. final StreamController<InfoWindowEntity?> mInfoWindowStream =
  94. StreamController<InfoWindowEntity?>();
  95. double mScaleX = 1.0, mScrollX = 0.0, mSelectX = 0.0;
  96. double mHeight = 0, mWidth = 0;
  97. AnimationController? _controller;
  98. Animation<double>? aniX;
  99. //For TrendLine
  100. List<TrendLine> lines = [];
  101. double? changeinXposition;
  102. double? changeinYposition;
  103. double mSelectY = 0.0;
  104. bool waitingForOtherPairofCords = false;
  105. bool enableCordRecord = false;
  106. double getMinScrollX() {
  107. return mScaleX;
  108. }
  109. double _lastScale = 1.0;
  110. bool isScale = false, isDrag = false, isLongPress = false, isOnTap = false;
  111. @override
  112. void initState() {
  113. super.initState();
  114. widget.dismissInfoNotifier?.addListener(_onDismiss);
  115. }
  116. void _onDismiss() {
  117. if (isOnTap || isLongPress) {
  118. isOnTap = false;
  119. isLongPress = false;
  120. mInfoWindowStream.sink.add(null);
  121. notifyChanged();
  122. }
  123. }
  124. @override
  125. void didChangeDependencies() {
  126. super.didChangeDependencies();
  127. }
  128. @override
  129. void dispose() {
  130. widget.dismissInfoNotifier?.removeListener(_onDismiss);
  131. mInfoWindowStream.sink.close();
  132. mInfoWindowStream.close();
  133. _controller?.dispose();
  134. super.dispose();
  135. }
  136. @override
  137. Widget build(BuildContext context) {
  138. if (widget.datas != null && widget.datas!.isEmpty) {
  139. mScrollX = mSelectX = 0.0;
  140. mScaleX = 1.0;
  141. }
  142. final BaseDimension baseDimension = BaseDimension(
  143. mBaseHeight: widget.mBaseHeight,
  144. volHidden: widget.volHidden,
  145. secondaryStateLi: widget.secondaryStateLi,
  146. mainStateLi: widget.mainStateLi,
  147. );
  148. final _painter = ChartPainter(
  149. widget.chartStyle,
  150. widget.chartColors,
  151. baseDimension: baseDimension,
  152. lines: lines, //For TrendLine
  153. sink: mInfoWindowStream.sink,
  154. xFrontPadding: widget.xFrontPadding,
  155. isTrendLine: widget.isTrendLine, //For TrendLine
  156. selectY: mSelectY, //For TrendLine
  157. datas: widget.datas,
  158. scaleX: mScaleX,
  159. scrollX: mScrollX,
  160. selectX: mSelectX,
  161. isLongPass: isLongPress,
  162. isOnTap: isOnTap,
  163. isTapShowInfoDialog: widget.isTapShowInfoDialog,
  164. mainStateLi: widget.mainStateLi,
  165. volHidden: widget.volHidden,
  166. secondaryStateLi: widget.secondaryStateLi,
  167. isLine: widget.isLine,
  168. hideGrid: widget.hideGrid,
  169. showNowPrice: widget.showNowPrice,
  170. fixedLength: widget.fixedLength,
  171. maDayList: widget.maDayList,
  172. verticalTextAlignment: widget.verticalTextAlignment,
  173. nowPriceStr: widget.nowPriceStr,
  174. );
  175. return LayoutBuilder(
  176. builder: (context, constraints) {
  177. mHeight = constraints.maxHeight;
  178. mWidth = constraints.maxWidth;
  179. return GestureDetector(
  180. onTapUp: (details) {
  181. // if (!widget.isTrendLine && widget.onSecondaryTap != null && _painter.isInSecondaryRect(details.localPosition)) {
  182. // widget.onSecondaryTap!();
  183. // }
  184. if (!widget.isTrendLine &&
  185. _painter.isInMainRect(details.localPosition)) {
  186. // 在主图区域内任意点击:始终切换到点击位置对应的蜡烛详情,
  187. // 不再二次点击隐藏(用户期望点哪里就显示哪里的详情)
  188. isOnTap = true;
  189. if (widget.isTapShowInfoDialog) {
  190. mSelectX = details.localPosition.dx;
  191. notifyChanged();
  192. }
  193. } else if (isOnTap) {
  194. // 点击主图以外区域(副图/成交量等)→ 隐藏
  195. isOnTap = false;
  196. mInfoWindowStream.sink.add(null);
  197. notifyChanged();
  198. }
  199. if (widget.isTrendLine && !isLongPress && enableCordRecord) {
  200. enableCordRecord = false;
  201. Offset p1 = Offset(getTrendLineX(), mSelectY);
  202. if (!waitingForOtherPairofCords) {
  203. lines.add(TrendLine(
  204. p1, Offset(-1, -1), trendLineMax!, trendLineScale!));
  205. }
  206. if (waitingForOtherPairofCords) {
  207. var a = lines.last;
  208. lines.removeLast();
  209. lines.add(TrendLine(a.p1, p1, trendLineMax!, trendLineScale!));
  210. waitingForOtherPairofCords = false;
  211. } else {
  212. waitingForOtherPairofCords = true;
  213. }
  214. notifyChanged();
  215. }
  216. },
  217. // 统一使用 onScale 处理单指拖拽和双指缩放。
  218. // 在 onScaleUpdate 中动态检测 pointerCount 切换模式,
  219. // 不依赖 onScaleStart 的初始判断(第二个手指可能晚于 start 按下)。
  220. onScaleStart: (details) {
  221. isOnTap = false;
  222. _stopAnimation();
  223. _onDragChanged(true);
  224. },
  225. onScaleUpdate: (details) {
  226. if (isLongPress) return;
  227. if (details.scale != 1.0) {
  228. // 双指缩放(scale != 1.0 表示有缩放变化,不管角度)
  229. isScale = true;
  230. mScaleX = (_lastScale * details.scale).clamp(0.5, 2.2);
  231. notifyChanged();
  232. } else if (!isScale) {
  233. // 单指水平拖拽
  234. mScrollX = (details.focalPointDelta.dx / mScaleX + mScrollX)
  235. .clamp(0.0, ChartPainter.maxScrollX)
  236. .toDouble();
  237. // 滑到左端 90% 时提前触发加载更早数据
  238. if (ChartPainter.maxScrollX > 0 &&
  239. mScrollX >= ChartPainter.maxScrollX * 0.9 &&
  240. widget.onLoadMore != null) {
  241. widget.onLoadMore!(false);
  242. }
  243. notifyChanged();
  244. }
  245. },
  246. onScaleEnd: (details) {
  247. if (isScale) {
  248. isScale = false;
  249. _lastScale = mScaleX;
  250. }
  251. _onDragChanged(false);
  252. if (!isScale) {
  253. var velocity = details.velocity.pixelsPerSecond.dx;
  254. _onFling(velocity);
  255. }
  256. },
  257. onLongPressStart: (details) {
  258. isOnTap = false;
  259. isLongPress = true;
  260. if ((mSelectX != details.localPosition.dx ||
  261. mSelectY != details.globalPosition.dy) &&
  262. !widget.isTrendLine) {
  263. mSelectX = details.localPosition.dx;
  264. notifyChanged();
  265. }
  266. //For TrendLine
  267. if (widget.isTrendLine && changeinXposition == null) {
  268. mSelectX = changeinXposition = details.localPosition.dx;
  269. mSelectY = changeinYposition = details.globalPosition.dy;
  270. notifyChanged();
  271. }
  272. //For TrendLine
  273. if (widget.isTrendLine && changeinXposition != null) {
  274. changeinXposition = details.localPosition.dx;
  275. changeinYposition = details.globalPosition.dy;
  276. notifyChanged();
  277. }
  278. },
  279. onLongPressMoveUpdate: (details) {
  280. if ((mSelectX != details.localPosition.dx ||
  281. mSelectY != details.globalPosition.dy) &&
  282. !widget.isTrendLine) {
  283. mSelectX = details.localPosition.dx;
  284. mSelectY = details.localPosition.dy;
  285. notifyChanged();
  286. }
  287. if (widget.isTrendLine) {
  288. mSelectX =
  289. mSelectX + (details.localPosition.dx - changeinXposition!);
  290. changeinXposition = details.localPosition.dx;
  291. mSelectY =
  292. mSelectY + (details.globalPosition.dy - changeinYposition!);
  293. changeinYposition = details.globalPosition.dy;
  294. notifyChanged();
  295. }
  296. },
  297. onLongPressEnd: (details) {
  298. isLongPress = false;
  299. enableCordRecord = true;
  300. mInfoWindowStream.sink.add(null);
  301. notifyChanged();
  302. },
  303. child: Stack(
  304. children: <Widget>[
  305. CustomPaint(
  306. size: Size(double.infinity, baseDimension.mDisplayHeight),
  307. painter: _painter,
  308. ),
  309. if (widget.showInfoDialog) _buildInfoDialog()
  310. ],
  311. ),
  312. );
  313. },
  314. );
  315. }
  316. void _stopAnimation({bool needNotify = true}) {
  317. if (_controller != null && _controller!.isAnimating) {
  318. _controller!.stop();
  319. _onDragChanged(false);
  320. if (needNotify) {
  321. notifyChanged();
  322. }
  323. }
  324. }
  325. void _onDragChanged(bool isOnDrag) {
  326. isDrag = isOnDrag;
  327. if (widget.isOnDrag != null) {
  328. widget.isOnDrag!(isDrag);
  329. }
  330. }
  331. void _onFling(double x) {
  332. _controller = AnimationController(
  333. duration: Duration(milliseconds: widget.flingTime), vsync: this);
  334. aniX = null;
  335. aniX = Tween<double>(begin: mScrollX, end: x * widget.flingRatio + mScrollX)
  336. .animate(CurvedAnimation(
  337. parent: _controller!.view, curve: widget.flingCurve));
  338. aniX!.addListener(() {
  339. mScrollX = aniX!.value;
  340. if (mScrollX <= 0) {
  341. mScrollX = 0;
  342. if (widget.onLoadMore != null) {
  343. widget.onLoadMore!(true);
  344. }
  345. _stopAnimation();
  346. } else if (mScrollX >= ChartPainter.maxScrollX) {
  347. mScrollX = ChartPainter.maxScrollX;
  348. if (widget.onLoadMore != null) {
  349. widget.onLoadMore!(false);
  350. }
  351. _stopAnimation();
  352. }
  353. notifyChanged();
  354. });
  355. aniX!.addStatusListener((status) {
  356. if (status == AnimationStatus.completed ||
  357. status == AnimationStatus.dismissed) {
  358. _onDragChanged(false);
  359. notifyChanged();
  360. }
  361. });
  362. _controller!.forward();
  363. }
  364. void notifyChanged() => setState(() {});
  365. late List<String> infos;
  366. Widget _buildInfoDialog() {
  367. return StreamBuilder<InfoWindowEntity?>(
  368. stream: mInfoWindowStream.stream,
  369. builder: (context, snapshot) {
  370. if ((!isLongPress && !isOnTap) ||
  371. widget.isLine == true ||
  372. !snapshot.hasData ||
  373. snapshot.data?.kLineEntity == null) return SizedBox();
  374. KLineEntity entity = snapshot.data!.kLineEntity;
  375. final dialogWidth = mWidth / 3;
  376. if (snapshot.data!.isLeft) {
  377. return Positioned(
  378. top: 25,
  379. left: 10.0,
  380. child: PopupInfoView(
  381. entity: entity,
  382. width: dialogWidth,
  383. chartColors: widget.chartColors,
  384. chartTranslations: widget.chartTranslations,
  385. materialInfoDialog: widget.materialInfoDialog,
  386. timeFormat: widget.timeFormat,
  387. fixedLength: widget.fixedLength,
  388. ),
  389. );
  390. }
  391. return Positioned(
  392. top: 25,
  393. right: 10.0,
  394. child: PopupInfoView(
  395. entity: entity,
  396. width: dialogWidth,
  397. chartColors: widget.chartColors,
  398. chartTranslations: widget.chartTranslations,
  399. materialInfoDialog: widget.materialInfoDialog,
  400. timeFormat: widget.timeFormat,
  401. fixedLength: widget.fixedLength,
  402. ),
  403. );
  404. },
  405. );
  406. }
  407. }