普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月6日首页

Flutter 实现手势缩放丝滑的 K 线(内涵源码)

作者 比特鹰
2026年3月6日 11:14

本文基于 Flutter 框架,从 Canvas 绘制、K 线数据结构、蜡烛图核心绘制逻辑、MA 指标实现,到手势冲突优化,全方位拆解金融 APP K 线图开发流程,分享实战问题与解决方案,助力开发者快速实现流畅可落地的 K 线组件。

在金融类 APP 开发中,K 线图是必不可少的组件之一,体验直接可导致用户数量的流失

本文将通过 Flutter 框架,并结合实际的开发经验,从 Canvas 绘制基础、数据结构定义、核心绘制逻辑、技术指标实现到手势系统优化,全方位的拆解 K 线图的开发过程,分享我开发过程中遇到的问题以及解决方案,帮助你掌握 Flutter K 线图开发技巧

先看最终效果

925596b7e263610b1bab63b6fa1529cd.png

一、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<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 < 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<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<
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线图的小伙伴,少走弯路、快速落地!

源码:github.com/kian-lian/c…

参考:

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

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

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

以下岗位持续开放中:

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

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

❌
❌