flutter 仿微信-语音消息长按说话上滑取消效果

长按说话功能实现

  1. 长按说话:在长按按钮时,显示扇形区域。
  2. 手指在区域内:显示 "上滑取消" 和 "松开发送"。
  3. 手指在区域外松开:取消发送。
  4. 动画:在录音时在屏幕中央显示Lottie动画。
截屏 2025-02-25 17.41.13.jpeg
截屏 2025-02-25 17.41.17-1.jpeg
绘制扇形区域

使用三次贝塞尔曲线绘制:
第一个控制点(0, size.height - 150) 决定曲线的形状。
第二个控制点(size.width, size.height - 150) 也影响曲线。
结束点(size.width, size.height) 确定扇形的右下角。

   final path = Path()
      ..moveTo(0, size.height)
      ..cubicTo(
        0,
        size.height - 150,
        size.width,
        size.height - 150,
        size.width,
        size.height,
      )
      ..lineTo(0, size.height)
      ..close();

    // 绘制扇形.
    canvas.drawPath(path, paint);
判断手指位置是否在扇形区域内

_checkInSector 函数用于判断手指当前位置是否在定义的扇形区域内。
_cubicBezierX 函数用于计算三次贝塞尔曲线的 x 坐标,帮助确定扇形区域的左右边界。通过这两个函数,能够实现手指在扇形区域内外的交互逻辑。

 bool _checkInSector(Offset position, Size size) {
    final y = position.dy;
    final x = position.dx;

    if (y < size.height - _sectorHeight || y > size.height) {
      return false;
    }

    final normalizedY = (size.height - y) / _sectorHeight;
    final xLeft = _cubicBezierX(normalizedY, 0, 0, size.width);
    final xRight = size.width - _cubicBezierX(normalizedY, 0, 0, size.width);

    return x >= xLeft && x <= xRight;
  }

  double _cubicBezierX(double t, double startX, double controlX, double endX) {
    final t1 = 1 - t;
    return t1 * t1 * t1 * startX +
        3 * t1 * t1 * t * controlX +
        3 * t1 * t * t * controlX +
        t * t * t * endX;
  }

完整代码

import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart'; // 导入 Lottie 包

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('底部固定扇形')),
        body: Center(
          child: VoiceButton(
            onSend: () => print("消息已发送"),
            onCancel: () => print("取消发送"),
          ),
        ),
      ),
    );
}

class VoiceButton extends StatefulWidget {
  final VoidCallback onSend;
  final VoidCallback onCancel;

  const VoiceButton({super.key, required this.onSend, required this.onCancel});

  @override
  _VoiceButtonState createState() => _VoiceButtonState();
}

class _VoiceButtonState extends State<VoiceButton> {
  OverlayEntry? _overlayEntry;
  bool _isInSector = false;
  bool _isRecording = false; // 状态变量用于控制动画显示
  final double _sectorHeight = 150;

  bool _checkInSector(Offset position, Size size) {
    final y = position.dy;
    final x = position.dx;

    if (y < size.height - _sectorHeight || y > size.height) {
      return false;
    }

    final normalizedY = (size.height - y) / _sectorHeight;
    final xLeft = _cubicBezierX(normalizedY, 0, 0, size.width);
    final xRight = size.width - _cubicBezierX(normalizedY, 0, 0, size.width);

    return x >= xLeft && x <= xRight;
  }

  double _cubicBezierX(double t, double startX, double controlX, double endX) {
    final t1 = 1 - t;
    return t1 * t1 * t1 * startX +
        3 * t1 * t1 * t * controlX +
        3 * t1 * t * t * controlX +
        t * t * t * endX;
  }

  void _showOverlay() {
    _overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        bottom: 0,
        left: 0,
        right: 0,
        child: CustomPaint(
          size: Size(MediaQuery.of(context).size.width, _sectorHeight),
          painter: _SectorPainter(isInSector: _isInSector),
        ),
      ),
    );
    Overlay.of(context).insert(_overlayEntry!);
  }

  void _hideOverlay() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

  @override
  Widget build(BuildContext context) {
    final screenSize = MediaQuery.of(context).size;

    return Stack(
      children: [
        GestureDetector(
          onLongPressStart: (_) {
            _showOverlay();
            setState(() {
              _isRecording = true; // 开始录音时显示动画
            });
          },
          onLongPressMoveUpdate: (d) {
            final inSector = _checkInSector(d.globalPosition, screenSize);
            if (inSector != _isInSector) {
              setState(() => _isInSector = inSector);
              _overlayEntry?.markNeedsBuild();
            }
          },
          onLongPressEnd: (d) {
            _isInSector ? widget.onSend() : widget.onCancel();
            setState(() {
              _isRecording = false; // 结束录音时隐藏动画
            });
            _hideOverlay();
          },
          child: Visibility(
      visible: !_isRecording,
            child: Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.blue,
                borderRadius: BorderRadius.circular(30),
              ),
              child: const Text(
                '按住说话',
                style: TextStyle(color: Colors.white, fontSize: 18),
              ),
            ),
          ),
        ),
        if (_isRecording) // 根据状态显示 Lottie 动画
          Center(
            child: Container(
              decoration: BoxDecoration(
                color: Colors.blue.withOpacity(0.8),
                borderRadius: BorderRadius.circular(30), // 设置圆角
              ),
              child: Lottie.asset(
                'assets/record_auido.json', // 替换为你的 Lottie 动画文件路径
                width: 100,
                height: 100,
                fit: BoxFit.fill,
              ),
            ),
          ),
      ],
    );
  }
}

class _SectorPainter extends CustomPainter {
  _SectorPainter({required this.isInSector});
  
  // 是否在扇形区域内.
  final bool isInSector; 
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = isInSector
          ? Colors.green.withOpacity(0.5)
          : Colors.red.withOpacity(0.5)
      ..style = PaintingStyle.fill;

    final path = Path()
      ..moveTo(0, size.height)
      ..cubicTo(
        0,
        size.height - 150,
        size.width,
        size.height - 150,
        size.width,
        size.height,
      )
      ..lineTo(0, size.height)
      ..close();

    // 绘制扇形.
    canvas.drawPath(path, paint);

    // 绘制状态文本.
    if (isInSector) {
      final textPainter = TextPainter(
        text: const TextSpan(
          text: '上滑取消',
          style: TextStyle(
            color: Colors.grey,
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
        ),
        textDirection: TextDirection.ltr,
      );

      // 测量文本宽度和高度
      textPainter.layout();
      final textOffset = Offset(
        (size.width - textPainter.width) / 2,
        size.height - 180, // 在扇形上方的位置
      );

      // 绘制文本
      textPainter.paint(canvas, textOffset);
    }

    // 绘制底部文本
    final bottomTextPainter = TextPainter(
      text: TextSpan(
        text: isInSector ? '松开发送' : '松开取消',
        style: const TextStyle(
          color: Colors.white,
          fontSize: 20,
          fontWeight: FontWeight.bold,
        ),
      ),
      textDirection: TextDirection.ltr,
    );

    bottomTextPainter.layout();
    final bottomTextOffset = Offset(
      (size.width - bottomTextPainter.width) / 2,
      (size.height - bottomTextPainter.height) / 1.5,
    );
    bottomTextPainter.paint(canvas, bottomTextOffset);
  }

  @override
  bool shouldRepaint(covariant _SectorPainter oldDelegate) =>
      oldDelegate.isInSector != isInSector;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容