You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter如何用CustomPaint按绘制速度实现平滑签名绘制

手写流畅签名效果参考

实现方案

你原有代码用固定线宽+drawPoints画折线的方式没法模拟手写笔锋,要实现自然流畅的签名效果,核心要做3件事:用贝塞尔曲线做线段平滑、根据触摸速度/压力动态调整线宽、维护独立的笔触点位数据方便自定义扩展。

核心逻辑说明

  • 平滑处理:相邻点位之间用二次贝塞尔曲线连接,完全消除折线棱角
  • 动态线宽规则:支持压感的设备优先读取触摸事件的pressure值映射线宽,无压感设备通过「两点间距/两点时间差」计算移动速度,移动速度越快线条越细,速度越慢线条越粗,完全贴合真实书写的笔锋效果
  • 点位过滤:过滤距离过近的重复采样点,减少冗余绘制计算
  • 笔触配置:保留圆头线帽、圆角连接配置,避免线段衔接处出现断口

完整实现示例

import 'package:flutter/material.dart';

// 自定义笔触点位,存储坐标、压感、时间戳
class StrokePoint {
  final Offset position;
  final double pressure;
  final int timestamp;

  StrokePoint({
    required this.position,
    required this.pressure,
    required this.timestamp,
  });
}

// 单条笔画数据结构
class Stroke {
  final List<StrokePoint> points = [];
  final Color color;
  // 可自定义的线宽范围
  final double minWidth;
  final double maxWidth;

  Stroke({
    required this.color,
    this.minWidth = 1.0,
    this.maxWidth = 7.0,
  });
}

class SignaturePainter extends CustomPainter {
  final List<Stroke> strokes;

  SignaturePainter(this.strokes);

  @override
  void paint(Canvas canvas, Size size) {
    for (final stroke in strokes) {
      if (stroke.points.length < 2) continue;

      final paint = Paint()
        ..color = stroke.color
        ..isAntiAlias = true
        ..style = PaintingStyle.stroke
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round;

      // 遍历点位逐段绘制,动态设置每段线宽
      for (int i = 1; i < stroke.points.length; i++) {
        final prev = stroke.points[i-1];
        final curr = stroke.points[i];
        
        // 计算当前段的线宽
        double strokeWidth;
        // 优先用压感值计算
        if (curr.pressure > 0 && curr.pressure <= 1) {
          strokeWidth = stroke.minWidth + (stroke.maxWidth - stroke.minWidth) * curr.pressure;
        } else {
          // 无压感设备用移动速度计算:速度越快线越细
          final distance = (curr.position - prev.position).distance;
          final timeDelta = (curr.timestamp - prev.timestamp).abs();
          final speed = timeDelta == 0 ? 0 : distance / timeDelta;
          // 0.3为灵敏度系数,数值越大笔锋变化越明显
          final widthFactor = 1 / (1 + speed * 0.3);
          strokeWidth = stroke.minWidth + (stroke.maxWidth - stroke.minWidth) * widthFactor;
        }
        paint.strokeWidth = strokeWidth;

        // 取两点中点作为贝塞尔曲线控制点,实现平滑衔接
        final midPoint = Offset(
          (prev.position.dx + curr.position.dx) / 2,
          (prev.position.dy + curr.position.dy) / 2,
        );

        if (i == 1) {
          canvas.drawLine(prev.position, midPoint, paint);
        } else {
          final prevMid = Offset(
            (stroke.points[i-2].position.dx + prev.position.dx)/2,
            (stroke.points[i-2].position.dy + prev.position.dy)/2,
          );
          canvas.drawPath(
            Path()..quadraticBezierTo(prev.position.dx, prev.position.dy, midPoint.dx, midPoint.dy),
            paint
          );
        }

        // 最后一段直接连接到终点
        if (i == stroke.points.length - 1) {
          canvas.drawLine(midPoint, curr.position, paint);
        }
      }
    }
  }

  @override
  bool shouldRepaint(covariant SignaturePainter oldDelegate) => true;
}

// 可直接复用的签名画布组件
class SignatureCanvas extends StatefulWidget {
  const SignatureCanvas({super.key});

  @override
  State<SignatureCanvas> createState() => _SignatureCanvasState();
}

class _SignatureCanvasState extends State<SignatureCanvas> {
  final List<Stroke> _strokes = [];
  Stroke? _currentStroke;
  final Color _penColor = Colors.black;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanStart: (details) {
        setState(() {
          _currentStroke = Stroke(color: _penColor);
          _currentStroke!.points.add(StrokePoint(
            position: details.localPosition,
            pressure: details.pressure,
            timestamp: DateTime.now().millisecondsSinceEpoch,
          ));
          _strokes.add(_currentStroke!);
        });
      },
      onPanUpdate: (details) {
        if (_currentStroke == null) return;
        setState(() {
          final newPoint = StrokePoint(
            position: details.localPosition,
            pressure: details.pressure,
            timestamp: DateTime.now().millisecondsSinceEpoch,
          );
          // 过滤距离小于1.5像素的过近点位,减少冗余计算
          final lastPoint = _currentStroke!.points.last;
          if ((newPoint.position - lastPoint.position).distance < 1.5) return;
          _currentStroke!.points.add(newPoint);
        });
      },
      onPanEnd: (details) {
        _currentStroke = null;
      },
      child: CustomPaint(
        painter: SignaturePainter(_strokes),
        size: Size.infinite,
      ),
    );
  }
}

// 调用示例
void main() {
  runApp(MaterialApp(
    home: Scaffold(
      appBar: AppBar(title: const Text('手写签名')),
      body: const SignatureCanvas(),
    ),
  ));
}

自定义扩展方向

  • 调整minWidth/maxWidth参数、速度映射系数,可以模拟钢笔、毛笔等不同笔触的书写效果
  • 给Paint添加shader、blendMode属性,可以实现马克笔、荧光笔的半透明叠加效果
  • 所有笔画点位都独立存储在Stroke列表中,可以很方便的扩展撤销、重做、橡皮擦、导出为图片等功能
  • 带触控笔的设备、支持3D Touch的iOS设备可以直接读取pressure值,实现更精准的压感笔锋效果

内容的提问来源于stack exchange,提问作者Kevin Yang

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 13:45:33