Flutter实战:仿微信语音按钮与交互页面深度解析

Flutter实战:仿微信语音按钮与交互页面深度解析

一、需求分析与UI设计

微信语音按钮的交互设计包含三个核心状态:

  1. 正常状态:圆形按钮带麦克风图标
  2. 按下状态:按钮变色+录音提示
  3. 滑动取消状态:按钮上方显示”松开手指 取消发送”

配套的语音发送页面需要实现:

  • 实时录音波形动画
  • 计时显示(精确到秒)
  • 滑动取消时的UI反馈
  • 录音完成后的播放预览

UI设计要点:

  • 使用Material Design的圆形按钮组件
  • 录音动画采用Canvas绘制波形
  • 状态切换使用AnimatedContainer实现平滑过渡
  • 文字提示采用Stack布局实现层叠效果

二、核心组件实现

1. 语音按钮组件

  1. class VoiceButton extends StatefulWidget {
  2. final Function(File) onSend;
  3. final VoidCallback onCancel;
  4. const VoiceButton({
  5. Key? key,
  6. required this.onSend,
  7. required this.onCancel,
  8. }) : super(key: key);
  9. @override
  10. _VoiceButtonState createState() => _VoiceButtonState();
  11. }
  12. class _VoiceButtonState extends State<VoiceButton> {
  13. bool _isRecording = false;
  14. bool _isCanceling = false;
  15. late Timer _timer;
  16. int _recordSeconds = 0;
  17. final Recorder _recorder = Recorder();
  18. @override
  19. Widget build(BuildContext context) {
  20. return GestureDetector(
  21. onLongPressStart: (_) => _startRecording(),
  22. onLongPressEnd: (_) => _stopRecording(),
  23. onVerticalDragUpdate: (details) {
  24. if (details.delta.dy < -50) { // 向上滑动超过50像素
  25. setState(() => _isCanceling = true);
  26. } else {
  27. setState(() => _isCanceling = false);
  28. }
  29. },
  30. onVerticalDragEnd: (_) {
  31. if (_isCanceling) {
  32. _cancelRecording();
  33. }
  34. },
  35. child: AnimatedContainer(
  36. duration: Duration(milliseconds: 200),
  37. width: 60,
  38. height: 60,
  39. decoration: BoxDecoration(
  40. shape: BoxShape.circle,
  41. color: _isRecording
  42. ? _isCanceling ? Colors.red : Colors.green
  43. : Colors.blue,
  44. ),
  45. child: Center(
  46. child: Icon(
  47. Icons.mic,
  48. color: Colors.white,
  49. size: 30,
  50. ),
  51. ),
  52. ),
  53. );
  54. }
  55. void _startRecording() async {
  56. setState(() {
  57. _isRecording = true;
  58. _isCanceling = false;
  59. _recordSeconds = 0;
  60. });
  61. await _recorder.start();
  62. _timer = Timer.periodic(Duration(seconds: 1), (timer) {
  63. if (_isRecording) {
  64. setState(() => _recordSeconds++);
  65. }
  66. });
  67. }
  68. void _stopRecording() async {
  69. _timer.cancel();
  70. if (!_isCanceling && _recordSeconds > 0) {
  71. final file = await _recorder.stop();
  72. widget.onSend(file);
  73. }
  74. setState(() => _isRecording = false);
  75. }
  76. void _cancelRecording() {
  77. _timer.cancel();
  78. _recorder.cancel();
  79. widget.onCancel();
  80. setState(() => _isRecording = false);
  81. }
  82. }

2. 录音管理器实现

  1. class Recorder {
  2. final FlutterSoundRecorder _audioRecorder = FlutterSoundRecorder();
  3. String? _outputFilePath;
  4. Future<void> start() async {
  5. final dir = await getTemporaryDirectory();
  6. _outputFilePath = '${dir.path}/temp_record.aac';
  7. await _audioRecorder.openAudioSession(
  8. focus: AudioFocus.requestFocusAndDuckOthers,
  9. category: SessionCategory.playAndRecord,
  10. );
  11. await _audioRecorder.startRecorder(
  12. toFile: _outputFilePath,
  13. codec: Codec.aacADTS,
  14. audioSource: AudioSource.microphone,
  15. );
  16. }
  17. Future<File> stop() async {
  18. await _audioRecorder.stopRecorder();
  19. await _audioRecorder.closeAudioSession();
  20. return File(_outputFilePath!);
  21. }
  22. void cancel() {
  23. _audioRecorder.closeAudioSession();
  24. }
  25. }

三、语音发送页面实现

1. 页面布局结构

  1. class VoiceRecordPage extends StatefulWidget {
  2. final File audioFile;
  3. const VoiceRecordPage({Key? key, required this.audioFile}) : super(key: key);
  4. @override
  5. _VoiceRecordPageState createState() => _VoiceRecordPageState();
  6. }
  7. class _VoiceRecordPageState extends State<VoiceRecordPage> {
  8. late AudioPlayer _audioPlayer;
  9. bool _isPlaying = false;
  10. @override
  11. void initState() {
  12. super.initState();
  13. _audioPlayer = AudioPlayer();
  14. }
  15. @override
  16. Widget build(BuildContext context) {
  17. return Scaffold(
  18. body: Stack(
  19. children: [
  20. Positioned.fill(
  21. child: WaveFormWidget(audioFile: widget.audioFile),
  22. ),
  23. Positioned(
  24. bottom: 80,
  25. left: 0,
  26. right: 0,
  27. child: Row(
  28. mainAxisAlignment: MainAxisAlignment.center,
  29. children: [
  30. IconButton(
  31. icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
  32. onPressed: _togglePlay,
  33. ),
  34. SizedBox(width: 20),
  35. Text('点击播放预览'),
  36. ],
  37. ),
  38. ),
  39. Positioned(
  40. bottom: 20,
  41. left: 0,
  42. right: 0,
  43. child: Row(
  44. mainAxisAlignment: MainAxisAlignment.center,
  45. children: [
  46. ElevatedButton(
  47. onPressed: () => Navigator.pop(context, 'send'),
  48. child: Text('发送'),
  49. ),
  50. SizedBox(width: 20),
  51. ElevatedButton(
  52. onPressed: () => Navigator.pop(context, 'cancel'),
  53. child: Text('取消'),
  54. ),
  55. ],
  56. ),
  57. ),
  58. ],
  59. ),
  60. );
  61. }
  62. Future<void> _togglePlay() async {
  63. if (_isPlaying) {
  64. await _audioPlayer.pause();
  65. } else {
  66. await _audioPlayer.play(widget.audioFile.path, isLocal: true);
  67. }
  68. setState(() => _isPlaying = !_isPlaying);
  69. }
  70. @override
  71. void dispose() {
  72. _audioPlayer.dispose();
  73. super.dispose();
  74. }
  75. }

2. 波形动画实现

  1. class WaveFormWidget extends StatefulWidget {
  2. final File audioFile;
  3. const WaveFormWidget({Key? key, required this.audioFile}) : super(key: key);
  4. @override
  5. _WaveFormWidgetState createState() => _WaveFormWidgetState();
  6. }
  7. class _WaveFormWidgetState extends State<WaveFormWidget> {
  8. List<double>? _samples;
  9. @override
  10. void initState() {
  11. super.initState();
  12. _loadAudioSamples();
  13. }
  14. Future<void> _loadAudioSamples() async {
  15. final bytes = await widget.audioFile.readAsBytes();
  16. // 这里需要实现实际的音频解析逻辑
  17. // 简化示例使用随机数据
  18. final random = Random();
  19. final samples = List.generate(100, (_) => random.nextDouble() * 0.8 + 0.2);
  20. setState(() => _samples = samples);
  21. }
  22. @override
  23. Widget build(BuildContext context) {
  24. return CustomPaint(
  25. size: Size(MediaQuery.of(context).size.width, 200),
  26. painter: WaveFormPainter(_samples ?? []),
  27. );
  28. }
  29. }
  30. class WaveFormPainter extends CustomPainter {
  31. final List<double> samples;
  32. WaveFormPainter(this.samples);
  33. @override
  34. void paint(Canvas canvas, Size size) {
  35. final paint = Paint()
  36. ..color = Colors.blue
  37. ..strokeWidth = 2.0
  38. ..style = PaintingStyle.stroke;
  39. final path = Path();
  40. final step = size.width / (samples.length - 1);
  41. for (int i = 0; i < samples.length; i++) {
  42. final x = i * step;
  43. final y = size.height / 2 * (1 - samples[i]);
  44. if (i == 0) {
  45. path.moveTo(x, y);
  46. } else {
  47. path.lineTo(x, y);
  48. }
  49. }
  50. canvas.drawPath(path, paint);
  51. }
  52. @override
  53. bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
  54. }

四、完整集成方案

1. 主页面集成

  1. class ChatPage extends StatelessWidget {
  2. @override
  3. Widget build(BuildContext context) {
  4. return Scaffold(
  5. appBar: AppBar(title: Text('聊天')),
  6. body: Center(
  7. child: VoiceButton(
  8. onSend: (file) async {
  9. final result = await Navigator.push(
  10. context,
  11. MaterialPageRoute(
  12. builder: (context) => VoiceRecordPage(audioFile: file),
  13. ),
  14. );
  15. if (result == 'send') {
  16. // 处理发送逻辑
  17. print('发送语音消息');
  18. }
  19. },
  20. onCancel: () {
  21. print('取消录音');
  22. },
  23. ),
  24. ),
  25. );
  26. }
  27. }

2. 依赖配置

pubspec.yaml中添加:

  1. dependencies:
  2. flutter_sound: ^9.2.13
  3. path_provider: ^2.0.11
  4. permission_handler: ^10.0.0

3. 权限处理

  1. void _requestPermission() async {
  2. final status = await Permission.microphone.request();
  3. if (!status.isGranted) {
  4. throw Exception('麦克风权限未授权');
  5. }
  6. }

五、优化建议

  1. 性能优化

    • 使用isolate处理音频解码,避免UI线程阻塞
    • 对波形数据进行抽样显示,减少绘制开销
    • 使用RepaintBoundary隔离动画区域
  2. 用户体验增强

    • 添加录音音量指示器
    • 实现最小录音时长限制(如1秒)
    • 添加录音振动反馈
  3. 错误处理

    • 捕获录音异常并提示用户
    • 处理存储空间不足的情况
    • 添加网络状态检查(如需上传)
  4. 可访问性

    • 添加语音提示
    • 支持屏幕阅读器
    • 增大触摸区域

六、扩展功能

  1. 多语言支持

    1. String getCancelText() {
    2. return Localizations.of(context)?.cancelButtonLabel ?? '取消';
    3. }
  2. 主题适配

    1. Color getButtonColor(BuildContext context) {
    2. return Theme.of(context).colorScheme.primary;
    3. }
  3. 动画效果增强
    ```dart
    AnimationController _controller;

void initState() {
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: 500),
)..repeat(reverse: true);
}

// 在build方法中使用
ScaleTransition(
scale: Tween(begin: 0.95, end: 1.0)
.animate(_controller),
child: …,
)

  1. ## 七、常见问题解决
  2. 1. **iOS录音权限问题**:
  3. - `Info.plist`中添加:
  4. ```xml
  5. <key>NSMicrophoneUsageDescription</key>
  6. <string>需要麦克风权限来录制语音</string>
  1. Android后台录音

    • AndroidManifest.xml中添加:
      1. <uses-permission android:name="android.permission.RECORD_AUDIO" />
      2. <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
  2. 音频格式兼容性

    • 优先使用AAC格式(.aac.m4a
    • 提供多种格式的备选方案

八、总结与展望

本实现完整复现了微信语音按钮的核心交互,包括:

  • 长按录音机制
  • 滑动取消手势
  • 实时波形显示
  • 录音状态管理

未来改进方向:

  1. 集成更专业的音频处理库(如ffmpeg
  2. 添加语音转文字功能
  3. 实现端到端加密传输
  4. 优化低延迟录音方案

通过模块化设计,各组件可独立复用,为即时通讯应用开发提供了坚实基础。实际项目中还需考虑测试覆盖率、性能监控等工程化实践。