长按说话功能实现
- 长按说话:在长按按钮时,显示扇形区域。
- 手指在区域内:显示 "上滑取消" 和 "松开发送"。
- 手指在区域外松开:取消发送。
- 动画:在录音时在屏幕中央显示
Lottie
动画。
截屏 2025-02-25 17.41.13.jpeg
截屏 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;
}