记录几个flutter自定义组件

尽意
2026-06-18 / 0 评论 / 1 阅读 / 正在检测是否收录...

波浪效果

mqj8pvzb.png


class SineWaveWidget extends StatefulWidget {
  final double amplitude; // 振幅(波浪的高度)
  final double frequency; // 频率(波浪的密集程度)
  final Color color; // 波浪颜色
  final Duration duration; // 流动一圈的周期

  const SineWaveWidget({
    super.key,
    this.amplitude = 20.0,
    this.frequency = 2.0,
    this.color = Colors.blueAccent,
    this.duration = const Duration(seconds: 1),
  });

  @override
  State<SineWaveWidget> createState() => _SineWaveWidgetState();
}

class _SineWaveWidgetState extends State<SineWaveWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  bool _isForward = true;

  @override
  void initState() {
    super.initState();
    // 创建一个无限循环的动画控制器,用于驱动波浪的相位变化
    _controller = AnimationController(
      vsync: this,
      duration: widget.duration,
      lowerBound: 0.0,
      upperBound: 2 * pi,
    )..repeat(); // 自动无限循环
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        _isForward = !_isForward;
      },
      child: AnimatedBuilder(
        animation: _controller /*.drive(Tween(begin: 0, end: 2 * pi))*/,
        // 将 0~1 的进度映射为 0~2π 的相位
        builder: (context, child) {
          return CustomPaint(
            size: Size.infinite, // 填满父容器
            painter: SineWavePainter(
              amplitude: widget.amplitude,
              frequency: widget.frequency,
              color: widget.color,
              phase: _controller.value * (_isForward ? 1 : -1),
            ),
          );
        },
      ),
    );
  }
}

class SineWavePainter extends CustomPainter {
  final double amplitude;
  final double frequency;
  final Color color;
  final double phase;

  SineWavePainter({
    required this.amplitude,
    required this.frequency,
    required this.color,
    required this.phase,
  });

  @override
  void paint(Canvas canvas, Size size) {
    // final paint = Paint()
    //   ..color = color
    //   ..style = PaintingStyle.fill
    //   ..blendMode = BlendMode.xor;
    //
    // final path = Path();
    //
    // // 将波浪的基准线设置在垂直中心
    // final centerY = size.height / 2;
    //
    // // 1. 起点:从左下角开始
    // path.moveTo(0, size.height);
    //
    // // 2. 绘制正弦曲线
    // // 遍历画布的每一个像素宽度
    // for (double x = 0; x <= size.width; x++) {
    //   // 核心公式:y = A * sin(ω * x + φ)
    //   // 乘以 2π 是为了让 frequency 代表“画布宽度内包含的完整波浪数量”
    //   final y = centerY +
    //       amplitude * sin((x / size.width) * frequency * 2 * pi + phase);
    //   path.lineTo(x, y);
    // }
    //
    // // 3. 闭合路径:连接到右下角,再回到左下角,形成封闭的填充区域
    // path.lineTo(size.width, size.height);
    // path.close();
    //
    // final path2 = Path();
    // path2.moveTo(0, size.height);
    // for (double x = 0; x <= size.width; x++) {
    //   final y = centerY +
    //       amplitude * sin((x / size.width) * frequency * 2 * pi + (phase + pi));
    //   path2.lineTo(x, y);
    // }
    // path2.lineTo(size.width, size.height);
    // path2.close();
    //
    // // 4. 绘制到画布上
    // canvas.drawPath(path, paint);
    // canvas.drawPath(path2, paint);

    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill
      ..blendMode = BlendMode.multiply;

    final centerY = size.height / 2;
    final omega = frequency * 2 * pi / size.width;

    // 将波浪划分为固定的段数(例如 16 段),确保动画循环时首尾完美衔接
    final segments = (frequency * 4).toInt();
    final step = size.width / segments;

    for (int wave = 0; wave < 2; wave++) {
      final currentPhase = phase + (wave * pi);
      final path = Path();

      // 起点:左下角
      path.moveTo(0, size.height);

      // 遍历每个分段点
      for (int i = 0; i <= segments; i++) {
        final x = i * step;
        final y = centerY + amplitude * sin(omega * x + currentPhase);

        if (i == 0) {
          path.lineTo(x, y);
        } else {
          // 前一个点的坐标和 Y 值
          final prevX = (i - 1) * step;
          final prevY = centerY + amplitude * sin(omega * prevX + currentPhase);

          // 计算前一个点的切线斜率(导数)
          final prevSlope =
              amplitude * omega * cos(omega * prevX + currentPhase);

          // 计算控制点(基于前一点的切线推算)
          final controlX = prevX + step / 2;
          final controlY = prevY + (prevSlope * step) / 2;

          // 使用二次贝塞尔曲线连接
          path.quadraticBezierTo(controlX, controlY, x, y);
        }
      }

      // 严格闭合路径:确保右下角和左下角完美贴合
      path.lineTo(size.width, size.height);
      path.close();

      canvas.drawPath(path, paint);
    }
  }

  @override
  bool shouldRepaint(covariant SineWavePainter oldDelegate) {
    // 只要相位(phase)发生变化,就重绘(用于实现流动动画)
    return oldDelegate.phase != phase;
  }
}

QQ消息拖拽效果

mqj8rxuf.png


class QqDragBubble extends StatefulWidget {
  final double initialRadius; // 初始圆半径
  final double maxDragDistance; // 最大拖拽断裂距离
  final Color color;

  const QqDragBubble({
    super.key,
    this.initialRadius = 15.0,
    this.maxDragDistance = 100.0,
    this.color = Colors.red,
  });

  @override
  State<QqDragBubble> createState() => _QqDragBubbleState();
}

class _QqDragBubbleState extends State<QqDragBubble>
    with SingleTickerProviderStateMixin {
  Offset _dragOffset = Offset.zero; // 手指拖拽位置
  bool _isDragging = false;
  bool _isBurst = false; // 是否已断裂

  late AnimationController _burstController;
  late Animation<double> _burstAnimation;

  @override
  void initState() {
    super.initState();
    _burstController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
    );
    _burstAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
      CurvedAnimation(parent: _burstController, curve: Curves.easeOut),
    );
  }

  @override
  void dispose() {
    _burstController.dispose();
    super.dispose();
  }

  void _onPanUpdate(DragUpdateDetails details) {
    if (_isBurst) return;
    setState(() {
      _isDragging = true;
      _dragOffset += details.delta;
    });
  }

  void _onPanEnd(DragEndDetails details) {
    if (_isBurst) return;
    final distance = _dragOffset.distance;
    if (distance > widget.maxDragDistance) {
      // 超过临界值,触发断裂动画
      setState(() => _isBurst = true);
      _burstController.forward(from: 0.0);
    } else {
      // 未超过临界值,回弹
      setState(() {
        _isDragging = false;
        _dragOffset = Offset.zero;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: _onPanUpdate,
      onPanEnd: _onPanEnd,
      child: AnimatedBuilder(
        animation: _burstAnimation,
        builder: (context, child) {
          return CustomPaint(
            size: Size(300, 300), // 给足绘制空间
            painter: QqBubblePainter(
              color: widget.color,
              initialRadius: widget.initialRadius,
              dragOffset: _isDragging ? _dragOffset : Offset.zero,
              maxDragDistance: widget.maxDragDistance,
              burstScale: _isBurst ? _burstAnimation.value : 1.0,
              isDragging: _isDragging && !_isBurst,
            ),
          );
        },
      ),
    );
  }
}

class QqBubblePainter extends CustomPainter {
  final Color color;
  final double initialRadius;
  final Offset dragOffset;
  final double maxDragDistance;
  final double burstScale;
  final bool isDragging;

  QqBubblePainter({
    required this.color,
    required this.initialRadius,
    required this.dragOffset,
    required this.maxDragDistance,
    required this.burstScale,
    required this.isDragging,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill;

    // 起点圆心(屏幕中心)
    final startCenter = Offset(size.width / 2, size.height / 2);
    // 拖拽圆(终点)圆心
    final endCenter = startCenter + dragOffset;

    // 计算两点间的距离和角度
    final distance = (endCenter - startCenter).distance;
    final angle =
        atan2(endCenter.dy - startCenter.dy, endCenter.dx - startCenter.dx);

    // 1. 动态计算半径:拖拽越远,起点圆越小;终点圆保持原大小
    double startRadius = initialRadius;
    double endRadius = initialRadius;
    if (isDragging && distance < maxDragDistance) {
      final ratio = distance / maxDragDistance;
      startRadius = initialRadius * (1 - ratio * 0.7); // 起点圆最多缩小 70%
    }

    // 2. 绘制起点圆(断裂后消失)
    if (!isDragging || distance <= maxDragDistance) {
      if (isDragging) {
        // 拖拽过程中起点圆随距离缩小
      } else {
        startRadius = initialRadius;
      }
      canvas.drawCircle(startCenter, startRadius, paint);
    }

    // 3. 绘制终点圆(拖拽时跟随手指,断裂时播放缩放消失动画)
    if (isDragging) {
      canvas.drawCircle(endCenter, endRadius, paint);
    } else if (burstScale < 1.0) {
      // 断裂爆炸动画
      canvas.drawCircle(endCenter, endRadius * burstScale, paint);
    } else {
      // 初始状态
      canvas.drawCircle(startCenter, initialRadius, paint);
    }

    // 4. 绘制贝塞尔连接带
    if (isDragging && distance < maxDragDistance && distance > 0) {
      final path = Path();

      // 计算连接点坐标(基于三角函数)
      // 起点圆上的两个切点
      final startX1 = startCenter.dx + startRadius * cos(angle + pi / 2);
      final startY1 = startCenter.dy + startRadius * sin(angle + pi / 2);
      final startX2 = startCenter.dx + startRadius * cos(angle - pi / 2);
      final startY2 = startCenter.dy + startRadius * sin(angle - pi / 2);

      // 终点圆上的两个切点
      final endX1 = endCenter.dx + endRadius * cos(angle + pi / 2);
      final endY1 = endCenter.dy + endRadius * sin(angle + pi / 2);
      final endX2 = endCenter.dx + endRadius * cos(angle - pi / 2);
      final endY2 = endCenter.dy + endRadius * sin(angle - pi / 2);

      // 贝塞尔控制点(取两点连线的中点)
      final controlX = (startCenter.dx + endCenter.dx) / 2;
      final controlY = (startCenter.dy + endCenter.dy) / 2;

      // 绘制上半部分曲线
      path.moveTo(startX1, startY1);
      path.quadraticBezierTo(controlX, controlY, endX1, endY1);

      // 绘制下半部分曲线(闭合路径)
      path.lineTo(endX2, endY2);
      path.quadraticBezierTo(controlX, controlY, startX2, startY2);

      path.close();
      canvas.drawPath(path, paint);
    }
  }

  @override
  bool shouldRepaint(covariant QqBubblePainter oldDelegate) {
    return oldDelegate.dragOffset != dragOffset ||
        oldDelegate.isDragging != isDragging ||
        oldDelegate.burstScale != burstScale;
  }
}

手写板带播放效果

mqj8u98y.png

import 'package:flutter/material.dart';

// 1. 笔画数据模型(包含时间戳)
class Stroke {
  final List<Offset> points;
  final DateTime startTime;
  final DateTime endTime;
  final Color color;
  final double strokeWidth;

  Stroke({
    required this.points,
    required this.startTime,
    required this.endTime,
    this.color = Colors.black,
    this.strokeWidth = 5.0,
  });

  int get duration => endTime.difference(startTime).inMilliseconds;
}

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

  @override
  State<HandwritingCanvas> createState() => _HandwritingCanvasState();
}

class _HandwritingCanvasState extends State<HandwritingCanvas>
    with SingleTickerProviderStateMixin {
  // 历史笔画
  final List<Stroke> _strokes = [];

  // 正在绘制的笔画(使用 ValueNotifier 保证书写绝对流畅)
  final ValueNotifier<List<Offset>> _currentPointsNotifier = ValueNotifier([]);
  DateTime? _strokeStartTime;

  // 播放控制
  late AnimationController _playbackController;
  late Animation<double> _playbackAnimation;
  bool _isPlaying = false;

  @override
  void initState() {
    super.initState();
    _playbackController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 5),
    );
    _playbackAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(parent: _playbackController, curve: Curves.linear),
    );
    // 监听播放动画,触发画笔重绘
    _playbackAnimation.addListener(() {
      if (_isPlaying) setState(() {});
    });
  }

  @override
  void dispose() {
    _playbackController.dispose();
    _currentPointsNotifier.dispose();
    super.dispose();
  }

  // --- 录制逻辑 ---
  void _onPanStart(DragStartDetails details) {
    if (_isPlaying) return; // 播放时禁止书写
    _strokeStartTime = DateTime.now();
    _currentPointsNotifier.value = [details.localPosition];
  }

  void _onPanUpdate(DragUpdateDetails details) {
    if (_isPlaying) return;
    _currentPointsNotifier.value = [
      ..._currentPointsNotifier.value,
      details.localPosition
    ];
  }

  void _onPanEnd(DragEndDetails details) {
    if (_isPlaying) return;
    if (_currentPointsNotifier.value.isNotEmpty && _strokeStartTime != null) {
      setState(() {
        _strokes.add(Stroke(
          points: List.from(_currentPointsNotifier.value),
          startTime: _strokeStartTime!,
          endTime: DateTime.now(),
        ));
        _currentPointsNotifier.value = [];
      });
    }
  }

  // --- 播放逻辑 ---
  void _startPlayback() {
    if (_strokes.isEmpty || _isPlaying) return;
    setState(() => _isPlaying = true);

    final totalDuration =
        _strokes.last.endTime.difference(_strokes.first.startTime);
    _playbackController.duration = totalDuration;
    _playbackController.forward(from: 0.0).then((_) {
      setState(() => _isPlaying = false);
    });
  }

  // --- 清除逻辑 ---
  void _clearCanvas() {
    if (_isPlaying) return;
    setState(() {
      _strokes.clear();
      _currentPointsNotifier.value = [];
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('完美手写板'),
        actions: [
          IconButton(
            icon: const Icon(Icons.play_arrow),
            onPressed: _isPlaying ? null : _startPlayback,
            tooltip: '播放笔迹',
          ),
          IconButton(
            icon: const Icon(Icons.delete_outline),
            onPressed: _isPlaying ? null : _clearCanvas,
            tooltip: '清除画板',
          ),
        ],
      ),
      body: GestureDetector(
        onPanStart: _onPanStart,
        onPanUpdate: _onPanUpdate,
        onPanEnd: _onPanEnd,
        child: RepaintBoundary(
          child: CustomPaint(
            size: Size.infinite,
            painter: HandwritingPainter(
              strokes: _strokes,
              currentPoints: _currentPointsNotifier,
              isPlaying: _isPlaying,
              playbackProgress: _playbackAnimation.value,
              totalDurationMs: _strokes.isNotEmpty
                  ? _strokes.last.endTime
                      .difference(_strokes.first.startTime)
                      .inMilliseconds
                  : 0,
            ),
          ),
        ),
      ),
    );
  }
}

class HandwritingPainter extends CustomPainter {
  final List<Stroke> strokes;
  final ValueNotifier<List<Offset>> currentPoints;
  final bool isPlaying;
  final double playbackProgress;
  final int totalDurationMs;

  HandwritingPainter({
    required this.strokes,
    required this.currentPoints,
    required this.isPlaying,
    required this.playbackProgress,
    required this.totalDurationMs,
  }) : super(repaint: currentPoints); // 依然保留 ValueNotifier 驱动书写

  @override
  void paint(Canvas canvas, Size size) {
    if (isPlaying) {
      // ================= 播放模式 =================
      final currentTimeMs = (playbackProgress * totalDurationMs).toInt();
      for (final stroke in strokes) {
        final elapsed = currentTimeMs -
            stroke.startTime.difference(strokes.first.startTime).inMilliseconds;
        if (elapsed <= 0) continue; // 还没到开始时间

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

        final path = Path();
        path.moveTo(stroke.points.first.dx, stroke.points.first.dy);
        for (int i = 1; i < stroke.points.length; i++) {
          path.lineTo(stroke.points[i].dx, stroke.points[i].dy);
        }

        if (elapsed < stroke.duration) {
          // 正在播放中的笔画:按比例截取
          final progress = elapsed / stroke.duration;
          final metric = path.computeMetrics().first;
          canvas.drawPath(
              metric.extractPath(0, metric.length * progress), paint);
        } else {
          // 已经播放完的笔画:完整绘制
          canvas.drawPath(path, paint);
        }
      }
    } else {
      // ================= 书写模式 =================
      // 绘制历史笔画
      for (final stroke in strokes) {
        final paint = Paint()
          ..color = stroke.color
          ..strokeWidth = stroke.strokeWidth
          ..strokeCap = StrokeCap.round
          ..strokeJoin = StrokeJoin.round
          ..style = PaintingStyle.stroke;

        final path = Path();
        path.moveTo(stroke.points.first.dx, stroke.points.first.dy);
        for (int i = 1; i < stroke.points.length; i++) {
          path.lineTo(stroke.points[i].dx, stroke.points[i].dy);
        }
        canvas.drawPath(path, paint);
      }

      // 绘制正在写的笔画(通过 ValueNotifier 局部重绘,绝对流畅)
      final points = currentPoints.value;
      if (points.isNotEmpty) {
        final paint = Paint()
          ..color = Colors.black
          ..strokeWidth = 5.0
          ..strokeCap = StrokeCap.round
          ..strokeJoin = StrokeJoin.round
          ..style = PaintingStyle.stroke;

        final path = Path();
        path.moveTo(points.first.dx, points.first.dy);
        for (int i = 1; i < points.length; i++) {
          path.lineTo(points[i].dx, points[i].dy);
        }
        canvas.drawPath(path, paint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant HandwritingPainter oldDelegate) {
    // 播放时或者历史笔画变化时重绘
    return isPlaying || oldDelegate.strokes != strokes;
  }
}

抛物线动画效果

mqj8xga7.png

0

评论 (0)

取消