用 Flutter 做出来丝滑 K 线?实现方案、源码通通都给你

缩放卡、拖拽抖、指标算错,Flutter K 线图的这些坑要怎么避免呢?本文从 Canvas 原理讲到手势系统优化,手把手拆解每一个核心模块,附完整代码,让你少走三个月弯路

K 线图,是金融类 APP 最核心的组件,也是最难啃的一块骨头:坐标映射、手势冲突、指标计算,每一步都藏着坑,一不小心陷进去了,浪费大量时间

这篇文章,是我用 Flutter 从 0 落地 K 线图后的完整复盘

从 Canvas 绘制原理、数据结构设计、蜡烛绘制逻辑、MA 均线实线,到最让人头疼的缩放 + 拖拽手势冲突,我会把每个坑、每个解决方案,掰开揉碎给你讲清楚

看完,你能直接拿去用

先看看最终效果

一、Canvas 绘制基础

首先我们得先学习 Flutter 中的 Canvas 绘制

懂 Canvas 绘制基础可直接跳过这条段,想要在 Flutter 中自定义绘制,核心需要通过 CustomPaint + CustomPainter

在动手之前需要先把 Flutter Canvas 坐标系规规则给理解一下

  • 原点 (0,0) 在绘制区域的左上角
  • x 轴向右为正
  • y 轴向下为正

与我们日常认知的“y轴向上为正”不同,需要记住这一点,这是避免绘制错位的关键

简单 Demo

为快速熟悉Canvas的使用方式,我们先实现一个简单的Demo,绘制一个填充圆形和一根线条,掌握Paint配置、坐标计算及Canvas绘制方法:

import 'package:flutter/material.dart';

class CanvasApp extends StatefulWidget {
  const CanvasApp({super.key});

  @override
  State<CanvasApp> createState() => _CanvasAppState();
}

class _CanvasAppState extends State<CanvasApp> {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: CustomPaint(painter: DemoPainter()),
    );
  }
}

class DemoPainter extends CustomPainter {
  final fill = Paint()
    ..style = PaintingStyle.fill
    ..color = Colors.blue;
  final stroke = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 6
    ..color = Colors.black;

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    canvas.drawCircle(center, 60, fill); // 绘制填充圆形
    canvas.drawLine(center, center + Offset(80, -40), stroke); // 绘制线条
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

上面的 Demo中,通过Paint配置绘制样式(填充/描边、颜色、线宽)

在paint方法中通过Canvas的drawCircle、drawLine方法完成绘制

shouldRepaint 返回false表示不重复绘制,提升性能

二、K线图数据结构定义

接下来我们就需要先了解 K 线数据接口的定义,进入 K 线的开发

首先需定义规范的数据结构,存储单根K线的核心信息

一根完整的K线包含开盘价、最高价、最低价、收盘价、成交量和时间戳六大核心字段,对应的数据结构如下:

class CandleEntity {
  double open;    // 开盘价
  double high;    // 最高价
  double low;     // 最低价
  double close;   // 收盘价
  double vol;     // 成交量
  int? time;      // 时间戳(毫秒)
}

CandleEntity 类是K线图开发的“数据载体”,后面所有绘制逻辑(蜡烛、均线)均围绕该类的实例展开

实际开发中,也可以根据需求扩展字段,比如添加均线值列表(maValueList),用于存储单根K线对应的各类均线数据

三、单根K线绘制逻辑

K线图的核心是 Candle 的绘制,单根 Candle 由实体部分(开盘价与收盘价之间的矩形)和影线部分(最高价与最低价之间的线段)组成,而且需区分阳线(涨)和阴线(跌),绘制逻辑如下

价格与屏幕坐标映射

因为Canvas坐标系和实际价格维度不一样,所以得把价格转换成屏幕上的Y坐标。核心逻辑就是用当前K线数据集的最高价、最低价算缩放比例,再把价格映射成屏幕坐标,公式如下:

double getY(double y) => (maxValue - y) * scaleY + _contentRect.top;
  • maxValue 为当前K线数据集的最高价
  • scaleY 为价格维度的缩放比例
  • _contentRect.top 为绘制区域的顶部坐标

通过这个公式可确保价格越高,对应的屏幕Y坐标越小

单根蜡烛绘制逻辑

单根蜡烛的绘制需处理三个核心细节:阳线与阴线的颜色区分、实体部分的最小高度(避免十字星看不见)、动态影线宽度(根据缩放级别调整,提升视觉体验)

完整代码如下:

/// 绘制单根蜡烛图
/// [curPoint] 当前 K 线数据
/// [canvas] 画布
/// [curX] 当前 K 线的 X 坐标(中心点)
void drawCandle(CandleEntity curPoint, Canvas canvas, double curX) {
  // 将价格转换为屏幕 Y 坐标
  var high = getY(curPoint.high); // 最高价对应的 Y 坐标
  var low = getY(curPoint.low); // 最低价对应的 Y 坐标
  var open = getY(curPoint.open); // 开盘价对应的 Y 坐标
  var close = getY(curPoint.close); // 收盘价对应的 Y 坐标
  double r = mCandleWidth / 2; // 实体半宽

  // 动态影线宽度计算:根据缩放级别平滑调整影线宽度,缩放越小影线越粗
  double lineR = _calculateDynamicShadowWidth() / 2; // 影线半宽

  // 阳线(涨):开盘价 >= 收盘价
  if (open >= close) {
    // 确保实体有最小可见高度(避免十字星看不见)
    if (open - close < mCandleLineWidth) {
      open = close + mCandleLineWidth;
    }
    chartPaint.color = this.chartColors.upColor; // 阳线颜色(如红色)
    // 绘制实体矩形(从收盘价到开盘价)
    canvas.drawRect(
      Rect.fromLTRB(curX - r, close, curX + r, open), chartPaint);
    // 绘制上下影线(从最高价到最低价)
    canvas.drawRect(
      Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
  }
  // 阴线(跌):收盘价 > 开盘价
  else if (close > open) {
    // 确保实体有最小可见高度
    if (close - open < mCandleLineWidth) {
      open = close - mCandleLineWidth;
    }
    chartPaint.color = this.chartColors.dnColor; // 阴线颜色(如绿色)
    // 绘制实体矩形(从开盘价到收盘价)
    canvas.drawRect(
      Rect.fromLTRB(curX - r, open, curX + r, close), chartPaint);
    // 绘制上下影线
    canvas.drawRect(
      Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
  }
}

上面的代码中,通过判断开盘价与收盘价的大小区分阳阴线,动态调整实体高度和影线宽度,确保在不同缩放级别下,K线都能清晰显示,提升用户体验

四、技术指标实现

K线图除了蜡烛本身,还需展示各类技术指标,其中移动平均线(MA)是最常用的指标之一

MA的实现核心是滑动窗口算法,通过维护固定周期的收盘价累加和,计算每个周期的均值,时间复杂度为O(n)

MA均线计算逻辑

/// 计算移动平均线(Moving Average)
/// [dataList] K 线数据列表
/// [maDayList] 均线周期列表,例如 [5, 10, 20] 表示计算 MA5、MA10、MA20
static calcMA(List<KLineEntity> dataList, List<int> maDayList) {
  // ma[i] 保存第 i 个周期的累加和
  List<double> ma = List<double>.filled(maDayList.length, 0);
  if (dataList.isNotEmpty) {
    for (int i = 0; i < dataList.length; i++) {
      KLineEntity entity = dataList[i];
      final closePrice = entity.close;
      // 为每个 K 线创建 MA 值列表
      entity.maValueList = List<double>.filled(maDayList.length, 0);
      // 计算每个周期的 MA 值
      for (int j = 0; j < maDayList.length; j++) {
        ma[j] += closePrice; // 累加当前收盘价
        // 达到周期时开始计算均值
        if (i == maDayList[j] - 1) {
          entity.maValueList?[j] = ma[j] / maDayList[j];
        }
        // 滑动窗口:减去最早的值,保持窗口大小
        else if (i >= maDayList[j]) {
          ma[j] -= dataList[i - maDayList[j]].close;
          entity.maValueList?[j] = ma[j] / maDayList[j];
        }
      }
    }
  }
}

上面即是实现 MA均线计算的逻辑,通过双重循环实现多周期MA计算:外层循环遍历所有K线数据,内层循环针对每个均线周期,累加收盘价,当达到周期长度时计算均值,后续通过滑动窗口更新均值(减去滑出窗口的收盘价,加上新的收盘价),确保计算高效

MA均线绘制逻辑

当完成逻辑的计算之后,通过绘制线段实现绘制,核心是获取相邻两根K线的MA值对应的屏幕坐标,调用drawLine方法完成绘制

void drawMaLine(CandleEntity lastPoint, CandleEntity curPoint, Canvas canvas,
                              double lastX, double curX) {
  // 获取均线线条宽度
  final lineWidth = _calculateMainIndicatorWidth();
  for (int i = 0; i < (curPoint.maValueList?.length ?? 0); i++) {
    if (i == 3) break; // 控制均线显示数量(如只显示前3条)
    if (lastPoint.maValueList?[i] != 0) {
      // 绘制相邻两根K线的MA线段,区分不同均线颜色
      drawLine(lastPoint.maValueList?[i], curPoint.maValueList?[i], canvas,
                              lastX, curX, this.chartColors.getMAColor(i),
                              lineWidth: lineWidth);
    }
  }
}

五、手势系统

交互体验在 K 线图中是非常重要的,必须要支持缩放、拖拽、点击、长按这四个核心的手势

但是 Flutter 的手势系统有一个手势竞技场(Gesture Arena)的机制,导致有手势冲突的问题

下面我提供了解决方案

手势冲突解决方案

问题描述:如果同时用了 HorizontalDrag 拖拽 和 ScaleGesture 缩放,这两个手势会互相抢焦点,导致双指缩放时,水平滑动会被拖拽抢走,缩放就断了,有种卡顿的感觉

解决办法很简单:

用 Listener 组件处理先判断有几根手指在屏幕上,再自动切换是拖拽还是缩放,互不干扰:

  • 一根手指(_pointerCount < 2):只走拖拽逻辑,让 K 线图左右滑动,看更早的数据
  • 两根及以上手指(_pointerCount ≥ 2):只走缩放逻辑,让 K 线图放大缩小,看细节或看整体

缩放 + 拖拽

Listener(
  onPointerDown: (_) => setState(() => _pointerCount++),
  onPointerUp: (_) => setState(() => _pointerCount--),
  onPointerCancel: (_) => setState(() => _pointerCount--),
  child: RawGestureDetector(
    scaleGestureRecognizer: GestureRecognizerFactoryWithHandlers&lt;ScaleGestureRecognizer>(
      () => ScaleGestureRecognizer(),
      (instance) {
        instance
          ..onStart = (details) {
            // 保存基线值,用于缩放计算
            _scaleBase = 1.0;
            _scaleXBase = mScaleX;
            // 计算缩放锚点(焦点对应的 K 线索引)
            _anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
          }
          ..onUpdate = (details) {
            // 检测手指数量变化,重置基线
            if (_pointerCount != _lastPointerCount) {
              _scaleBase = details.scale;
              _scaleXBase = mScaleX;
              _anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
            }
            if (_pointerCount &lt; 2) {
              // 单指:拖拽,调整滑动偏移量
              final delta = details.focalPointDelta.dx / mScaleX;
              mScrollX = (mScrollX + delta).clamp(0.0, maxScrollX);
            } else {
              // 双指:缩放,控制缩放范围(0.2~4.0)
              final relativeScale = details.scale / _scaleBase;
              mScaleX = (_scaleXBase * relativeScale).clamp(0.2, 4.0);
              // 焦点锚定:保持缩放中心不动,提升体验
            }
          }
          ..onEnd = (details) {
            // 单指拖拽结束:启动惯性滚动
            if (_pointerCount == 0 && _lastPointerCount == 1) {
              _onFling(details.velocity.pixelsPerSecond.dx);
            }
          };
      },
    ),
    // 长按、点击手势配置
    longPressGestureRecognizer: ...,
    tapGestureRecognizer: ...
  ),
);

其它手势实现

(1)点击手势

点击手势点击主要做两件事:切换十字线显示、画趋势线,通过 TapGestureRecognizer 实现

  • 普通模式:点一下 K 线图,十字线就显示 / 隐藏,同时会显示这根 K 线的详细数据,比如开盘价、收盘价
  • 趋势线模式:点两下,第一下记起点,第二下记终点,就能画出趋势线

<!---->

TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers&lt;TapGestureRecognizer>(
    () => TapGestureRecognizer(),
    (TapGestureRecognizer instance) {
        instance.onTapUp = (details) {
            // 普通点击模式:切换十字线显示状态
            if (!widget.isTrendLine &&
                                            painter.isInMainRect(details.localPosition)) {
                if (_isCrossLocked) {
                    // 十字线已显示,点击则隐藏
                    _isCrossLocked = false;
                    isOnTap = false;
                    mInfoWindowStream.sink.add(null); // 清空信息弹窗
                } else {
                    // 十字线未显示,点击则显示并锁定
                    _isCrossLocked = true;
                    isOnTap = true;
                    mSelectX = details.localPosition.dx;
                }
                notifyChanged();
            }

            // 趋势线模式:记录点击的坐标点
            if (widget.isTrendLine && !isLongPress && enableCordRecord) {
                enableCordRecord = false;
                Offset p1 = Offset(getTrendLineX(), mSelectY);

                // 第一次点击:创建趋势线的起点
                if (!waitingForOtherPairofCords) {
                    lines.add(TrendLine(
                        p1, Offset(-1, -1), trendLineMax!, trendLineScale!));
                }
                // 第二次点击:完成趋势线的终点
                if (waitingForOtherPairofCords) {
                    var a = lines.last;
                    lines.removeLast();
                    lines.add(
                        TrendLine(a.p1, p1, trendLineMax!, trendLineScale!));
                    waitingForOtherPairofCords = false;
                } else {
                    waitingForOtherPairofCords = true;
                }
                notifyChanged();
            }
        };
    },
),

(2)长按手势

长按手势****长按用来移动十字线、调整趋势线,通过 LongPressGestureRecognizer 实习那

  • 普通模式:长按屏幕并移动手指,十字线会跟着手指走,实时显示指到哪里的 K 线信息
  • 趋势线模式:长按画好的趋势线,就能拖动调整位置,方便修改

<!---->

LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers&lt;
    LongPressGestureRecognizer>(
    () => LongPressGestureRecognizer(),
    (LongPressGestureRecognizer instance) {
        instance
            // 长按开始
            ..onLongPressStart = (details) {
            isOnTap = false;
            isLongPress = true;

            // 普通模式:记录十字线位置
            if ((mSelectX != details.localPosition.dx ||
                     mSelectY != details.globalPosition.dy) &&
                    !widget.isTrendLine) {
                mSelectX = details.localPosition.dx;
                notifyChanged();
            }

            // 趋势线模式:初始化位置记录
            if (widget.isTrendLine && changeinXposition == null) {
                mSelectX = changeinXposition = details.localPosition.dx;
                mSelectY = changeinYposition = details.globalPosition.dy;
                notifyChanged();
            }
            if (widget.isTrendLine && changeinXposition != null) {
                changeinXposition = details.localPosition.dx;
                changeinYposition = details.globalPosition.dy;
                notifyChanged();
            }
        }
        // 长按移动 - 更新十字线位置
        ..onLongPressMoveUpdate = (details) {
            // 普通模式:跟随手指移动十字线
            if ((mSelectX != details.localPosition.dx ||
                     mSelectY != details.globalPosition.dy) &&
                    !widget.isTrendLine) {
                mSelectX = details.localPosition.dx;
                mSelectY = details.localPosition.dy;
                notifyChanged();
            }

            // 趋势线模式:移动趋势线
            if (widget.isTrendLine) {
                // 计算相对移动距离
                mSelectX = mSelectX +
                    (details.localPosition.dx - changeinXposition!);
                changeinXposition = details.localPosition.dx;
                mSelectY = mSelectY +
                    (details.globalPosition.dy - changeinYposition!);
                changeinYposition = details.globalPosition.dy;
                notifyChanged();
            }
        }
        // 长按结束
        ..onLongPressEnd = (details) {
            isLongPress = false;
            enableCordRecord = true; // 启用趋势线坐标记录
            // 长按结束后锁定十字线,保持显示
            if (!widget.isTrendLine) {
                _isCrossLocked = true;
                isOnTap = true; // 保持 isOnTap 为 true 以显示十字线
            } else {
                mInfoWindowStream.sink.add(null); // 趋势线模式清空信息弹窗
            }
            notifyChanged();
        };
    },
),

double getY(double y)  => (maxValue - y) * scaleY + _contentRect.top;

六、总结

最费时间就是缩放和拖拽的冲突问题

后面借鉴 Interactive Chart 这个开源项目的实现思路,用“Listener + ScaleGesture”实现水平移动和缩放,解决了这个问题,缩放和拖拽都丝滑不卡顿

总结一下,本篇文章主要讲解 Canvas 绘制、坐标映射、K 线图绘制基础,并解决了手势冲突的问题

其实K线图开发看着复杂,只要把绘制、数据处理、手势这几个核心模块拆解开,逐一突破,就能轻松搞定,做出高效、流畅、能落地的组件

本文的思路和代码,大家可以直接用到实际项目里,也能根据业务需求扩展功能(比如MACD、RSI指标、成交量显示、行情标注等),希望能帮到正在做Flutter K线图的小伙伴,少走弯路、快速落地!

源码:<https://github.com/kian-lian/candlex>

参考:

团队招募 | 共同探索技术边界

AI 时代已经到来,当下最好的破局机会,就是加入一家有潜力的 AI 公司

比特鹰致力于将每位成员,打造成 AI 时代的超级个体,在为用户创造价值的同时实现人生梦想

以下岗位持续开放中:

  • 后端开发工程师
  • 前端开发工程师
  • AI 应用开发工程师
  • 爬虫工程师
  • 大数据开发工程师
  • HR 人事

如果您想在 AI 时代实现百倍的个人提升,欢迎加入我们\ 联系方式:join@biteagle.xyz

  • 原创
  • 学分: 1
  • 分类: 其他
  • 标签:
点赞 1
收藏 1
分享

0 条评论

请先 登录 后评论
比特鹰(有潜力AI公司招聘中)
比特鹰(有潜力AI公司招聘中)
0x18E5...7220
比特鹰是国内领先的AI产品型公司。 团队年轻有活力,技术大牛带队,2-3年内成为行业顶尖人才。 比特鹰会为每位成员,定制AI生产力进阶计划,致力于培养AI时代的超级个体 如果你正在找工作,欢迎投递简历至邮箱:join@biteagle.xyz