• Flutter简单聊天界面布局及语音录制播放


    目录

    前言:

     注意事项:

    用到的部分组件依赖及版本:

    遇到的坑 

    遇到的坑1:

     遇到的坑2:

    遇到的坑3:

    遇到的坑4:

    Fluuter语音录制及播放组件生命周期

    Flutter录音组件生命周期图:

     Flutter语音播放组件生命周期图:

    代码

    简单视频演示: 


    前言:

    有好多todo没实现,这里总结一下这两天遇到的坑及简单的聊天界面布局和语音录制和播放功能,这里只实现了ios端的语音录制播放功能,android端没有测试。

     注意事项:

    ios端需要开启访问麦克风权限,位置在ios->Runner->Info.plist

    1. <key>NSMicrophoneUsageDescriptionkey>
    2. <string>访问麦克风string>

    用到的部分组件依赖及版本:

    1. #语音录制、播放插件
    2. flutter_sound: ^9.2.13
    3. #检查权限
    4. permission_handler: ^6.0.1
    5. #此插件会告知操作系统您的音频应用程序的性质(例如游戏、媒体播放器、助手等)以及您的应用程序将如何处理和启动音频中断(例如电话中断)
    6. audio_session: ^0.1.10
    7. #uuid
    8. uuid: ^3.0.6

    遇到的坑 

    遇到的坑1

    聊天消息布局不满一页在上方显示,满一页则停留在最底部:

    解决方法

    使用listview反转设置可以一直保持消息在底部,但是消息数据必须要倒序;

    使用Container的向上居中可以使子元素撑不满一屏时向上显示。

     遇到的坑2

    在iPhoneX及所有刘海屏Bottom留白问题:

    解决方法

    使用SafeArea安全组件可解决此问题

    遇到的坑3

    IOS端在Xcode Build时报错:

    Undefined symbols for architecture arm64:
    "___gxx_personality_v0", referenced from:
    +[FlutterSound registerWithRegistrar:] in flutter_sound(FlutterSound.o)

     解决方法

    在Xcode Build Setting中的Other Linker Flags添加-lc++即可

    遇到的坑4

    点击录音按钮不提示申请权限直接报错:

    排查了好久原来是检查权限工具版本的bug,改为6.0.1可成功弹出权限申请 

    Fluuter语音录制及播放组件生命周期

    Flutter录音组件生命周期图

    Flutter录音组件生命周期图

     Flutter语音播放组件生命周期图

    Flutter语音播放组件生命周期图

    代码

    1. import 'dart:math';
    2. import 'dart:ui';
    3. import 'package:audio_session/audio_session.dart';
    4. import 'package:dio/dio.dart';
    5. import 'package:flutter/foundation.dart';
    6. import 'package:flutter/material.dart';
    7. import 'package:fluttertoast/fluttertoast.dart';
    8. import 'package:new_chat/code/message_type.dart';
    9. import 'package:new_chat/r.dart';
    10. import 'package:new_chat/service/screen_adapter.dart';
    11. import 'package:new_chat/util/time_utils.dart';
    12. import 'package:new_chat/widget/toast_widget.dart';
    13. import 'package:flutter_sound/flutter_sound.dart';
    14. import 'package:flutter_sound_platform_interface/flutter_sound_recorder_platform_interface.dart';
    15. import 'package:permission_handler/permission_handler.dart';
    16. import 'package:logger/logger.dart' show Level;
    17. import 'package:uuid/uuid.dart';
    18. class SingleChatPage extends StatefulWidget {
    19. final String chatId;
    20. const SingleChatPage({Key? key, required this.chatId}) : super(key: key);
    21. @override
    22. State createState() {
    23. return _SingleChatPageState();
    24. }
    25. }
    26. class _SingleChatPageState extends State<SingleChatPage> {
    27. //message data
    28. List _messageData = [];
    29. ///语音播放及录制定义begin
    30. //默认语音录制为关闭
    31. bool _keyboardVoiceEnable = false;
    32. //listview跳转控制器
    33. final ScrollController _scrollController = ScrollController();
    34. //消息文本控制器
    35. final TextEditingController _textEditingController = TextEditingController();
    36. //语音类型
    37. final AudioSource _theSource = AudioSource.microphone;
    38. //存储录音编码格式
    39. Codec _codec = Codec.aacMP4;
    40. //播放器权限
    41. bool _voicePlayerIsInitialized = false;
    42. //录制权限
    43. bool _voiceRecorderIsInitialized = false;
    44. //播放器是否可播放
    45. bool _voicePlayerIsReady = false;
    46. //播放器是否在播放
    47. bool _voicePlayerIsPlay = false;
    48. //语音播放工具
    49. final FlutterSoundPlayer _voicePlayer =
    50. FlutterSoundPlayer(logLevel: Level.error);
    51. //语音录制工具
    52. final FlutterSoundRecorder _voiceRecorder =
    53. FlutterSoundRecorder(logLevel: Level.error);
    54. //存储文件后缀
    55. String _voiceFilePathSuffix = 'temp_file.mp4';
    56. //录音文件存储前缀
    57. String _voiceFilePrefix = "";
    58. ///语音播放及录制定义end
    59. @override
    60. void initState() {
    61. _initMessageData();
    62. //初始化播放器
    63. _voicePlayer.openPlayer().then((value) {
    64. setState(() {
    65. _voicePlayerIsInitialized = true;
    66. });
    67. });
    68. //初始化录音
    69. _initVoiceRecorder().then((value) {
    70. setState(() {
    71. _voiceRecorderIsInitialized = true;
    72. });
    73. });
    74. super.initState();
    75. }
    76. @override
    77. void dispose() {
    78. //关闭语音播放
    79. _voicePlayer.closePlayer();
    80. //关闭语音录制
    81. _voiceRecorder.closeRecorder();
    82. super.dispose();
    83. }
    84. ///录音及语音方法定义begin
    85. ///初始录音
    86. ///todo 用户禁止语音权限提示
    87. Future<void> _initVoiceRecorder() async {
    88. if (!kIsWeb) {
    89. var status = await Permission.microphone.request();
    90. if (status != PermissionStatus.granted) {
    91. throw RecordingPermissionException('Microphone permission not granted');
    92. }
    93. }
    94. await _voiceRecorder.openRecorder();
    95. if (!await _voiceRecorder.isEncoderSupported(_codec) && kIsWeb) {
    96. _codec = Codec.opusWebM;
    97. _voiceFilePathSuffix = 'tau_file.webm';
    98. if (!await _voiceRecorder.isEncoderSupported(_codec) && kIsWeb) {
    99. _voiceRecorderIsInitialized = true;
    100. return;
    101. }
    102. }
    103. final session = await AudioSession.instance;
    104. await session.configure(AudioSessionConfiguration(
    105. avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
    106. avAudioSessionCategoryOptions:
    107. AVAudioSessionCategoryOptions.allowBluetooth |
    108. AVAudioSessionCategoryOptions.defaultToSpeaker,
    109. avAudioSessionMode: AVAudioSessionMode.spokenAudio,
    110. avAudioSessionRouteSharingPolicy:
    111. AVAudioSessionRouteSharingPolicy.defaultPolicy,
    112. avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
    113. androidAudioAttributes: const AndroidAudioAttributes(
    114. contentType: AndroidAudioContentType.speech,
    115. flags: AndroidAudioFlags.none,
    116. usage: AndroidAudioUsage.voiceCommunication,
    117. ),
    118. androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
    119. androidWillPauseWhenDucked: true,
    120. ));
    121. _voiceRecorderIsInitialized = true;
    122. }
    123. ///开始录音并返回录音文件前缀
    124. String _beginVoice() {
    125. if (!_voiceRecorderIsInitialized) {
    126. ToastWidget.showToast("没有录音权限", ToastGravity.CENTER);
    127. throw Exception("没有录音权限");
    128. }
    129. var uuid = const Uuid().v4();
    130. _voiceRecorder
    131. .startRecorder(
    132. codec: _codec,
    133. toFile: uuid + _voiceFilePathSuffix,
    134. audioSource: _theSource)
    135. .then((value) {
    136. setState(() {
    137. //播放按钮禁用并插入语音到消息中
    138. _voicePlayerIsReady = false;
    139. });
    140. });
    141. return uuid;
    142. }
    143. ///停止录音 并将消息存储
    144. void _stopVoice(String voiceFileId) async {
    145. await _voiceRecorder.stopRecorder().then((value) {
    146. setState(() {
    147. //可以播放
    148. _voicePlayerIsReady = true;
    149. Map data = {};
    150. data['messageId'] = voiceFileId;
    151. //todo 差语音时长
    152. data['message'] = "语音消息按钮...";
    153. data['messageType'] = MessageType.voice;
    154. data['messageTime'] = TimeUtils.getFormatDataString(
    155. DateTime.now(), "yyyy-MM-dd HH:mm:ss");
    156. data['isMe'] = Random.secure().nextBool();
    157. //存储路径
    158. data['messageVoice'] = voiceFileId + _voiceFilePathSuffix;
    159. _messageData.insert(0, data);
    160. });
    161. });
    162. }
    163. ///开始播放录音
    164. void _beginPlayer(String messageVoiceFilePath) {
    165. assert(_voicePlayerIsInitialized &&
    166. _voicePlayerIsReady &&
    167. _voiceRecorder.isStopped &&
    168. _voicePlayer.isStopped);
    169. _voicePlayer
    170. .startPlayer(
    171. fromURI: messageVoiceFilePath,
    172. //codec: kIsWeb ? Codec.opusWebM : Codec.aacADTS,
    173. //语音播放完后的动作->停止播放
    174. whenFinished: () {
    175. setState(() {
    176. print("播放完的动作");
    177. _voicePlayerIsPlay = false;
    178. _voicePlayerIsReady = true;
    179. });
    180. })
    181. .then((value) {
    182. //语音正在播放的动作->正在播放
    183. setState(() {
    184. print("语音正在播放的动作");
    185. _voicePlayerIsPlay = true;
    186. _voicePlayerIsReady = false;
    187. });
    188. });
    189. }
    190. ///停止播放声音
    191. void _stopPlayer() {
    192. _voicePlayer.stopPlayer().then((value) {
    193. setState(() {
    194. _voicePlayerIsReady = true;
    195. _voicePlayerIsPlay = false;
    196. });
    197. });
    198. }
    199. ///录音及语音方法定义end
    200. ///初始化聊天数据
    201. //todo 差网络请求聊天数据 这里暂时mock
    202. _initMessageData() async {
    203. Dio dio = Dio();
    204. //mock data
    205. try {
    206. //todo timeout 1 seconds
    207. var response = await dio
    208. .get(
    209. "http://192.168.10.15:3000/mock/313/message",
    210. )
    211. .timeout(const Duration(seconds: 1));
    212. setState(() {
    213. _messageData = response.data['data'];
    214. });
    215. } catch (e) {
    216. //mock test data
    217. List<Map> tempData = [];
    218. Map data = {};
    219. data['messageId'] = const Uuid().v4();
    220. data['message'] = "嗯,没问题。明天我起床就联系你。";
    221. data['isMe'] = false;
    222. data['messageTime'] = "2022-08-17 16:20:20";
    223. data['messageType'] = MessageType.text;
    224. tempData.add(data);
    225. data = {};
    226. data['messageId'] = const Uuid().v4();
    227. data['message'] = "好的。有什么事情及时联系我都在线的。";
    228. data['isMe'] = true;
    229. data['messageTime'] = "2022-08-17 16:19:20";
    230. data['messageType'] = MessageType.text;
    231. tempData.add(data);
    232. data = {};
    233. data['messageId'] = const Uuid().v4();
    234. data['message'] = "晚安!";
    235. data['isMe'] = false;
    236. data['messageTime'] = "2022-08-17 16:16:20";
    237. data['messageType'] = MessageType.text;
    238. tempData.add(data);
    239. data = {};
    240. data['messageId'] = const Uuid().v4();
    241. data['message'] = "嗯,今晚好好休息!";
    242. data['isMe'] = false;
    243. data['messageTime'] = "2022-08-17 16:15:20";
    244. data['messageType'] = MessageType.text;
    245. tempData.add(data);
    246. data = {};
    247. data['messageId'] = const Uuid().v4();
    248. data['message'] = "好的,那到时见!!!";
    249. data['isMe'] = true;
    250. data['messageTime'] = "2022-08-16 01:15:20";
    251. data['messageType'] = MessageType.text;
    252. tempData.add(data);
    253. data = {};
    254. data['messageId'] = const Uuid().v4();
    255. data['message'] = "不用准备什么东西,我都已经准备好了。应该是吃完午餐就出发吧。大概下午2点左右。";
    256. data['isMe'] = false;
    257. data['messageTime'] = "2022-08-16 01:13:20";
    258. data['messageType'] = MessageType.text;
    259. tempData.add(data);
    260. data = {};
    261. data['messageId'] = const Uuid().v4();
    262. data['message'] = "需要准备什么东西带过去";
    263. data['isMe'] = true;
    264. data['messageTime'] = "2022-08-15 12:42:20";
    265. data['messageType'] = MessageType.text;
    266. tempData.add(data);
    267. data = {};
    268. data['messageId'] = const Uuid().v4();
    269. data['message'] = "好的,10点左右可以的。你打算几点出发?";
    270. data['isMe'] = true;
    271. data['messageTime'] = "2022-08-14 14:24:20";
    272. data['messageType'] = MessageType.text;
    273. tempData.add(data);
    274. data = {};
    275. data['messageId'] = const Uuid().v4();
    276. data['message'] = "嗯,大概上午10点左右吧。 如果没空就下午。";
    277. data['isMe'] = false;
    278. data['messageTime'] = "2022-08-13 11:11:22";
    279. data['messageType'] = MessageType.text;
    280. tempData.add(data);
    281. data = {};
    282. data['messageId'] = const Uuid().v4();
    283. data['message'] = "明天什么时候呢???";
    284. data['isMe'] = true;
    285. data['messageTime'] = "2022-08-12 10:32:11";
    286. data['messageType'] = MessageType.text;
    287. tempData.add(data);
    288. data = {};
    289. data['messageId'] = const Uuid().v4();
    290. data['message'] = "你明天有空过来吗??";
    291. data['isMe'] = false;
    292. data['messageTime'] = "2022-08-12 10:31:24";
    293. data['messageType'] = MessageType.text;
    294. tempData.add(data);
    295. setState(() {
    296. _messageData = tempData;
    297. });
    298. }
    299. }
    300. // chat widget
    301. //todo 后期需要根据messageSendType区分
    302. Widget _chatWidget(Map data) {
    303. if (data['isMe']) {
    304. return _myMessageWidget(data);
    305. }
    306. return _yourMessageWidget(data);
    307. }
    308. //your message widget
    309. Widget _yourMessageWidget(Map data) {
    310. String messageType = data['messageType'];
    311. return Padding(
    312. padding: EdgeInsets.fromLTRB(ScreenAdapter.width(32),
    313. ScreenAdapter.height(20), 0, ScreenAdapter.height(20)),
    314. child: Row(
    315. mainAxisAlignment: MainAxisAlignment.start,
    316. children: [
    317. Container(
    318. constraints: BoxConstraints(
    319. maxWidth: ScreenAdapter.width(450),
    320. ),
    321. padding: EdgeInsets.fromLTRB(
    322. ScreenAdapter.width(20),
    323. ScreenAdapter.height(24),
    324. ScreenAdapter.width(20),
    325. ScreenAdapter.height(24)),
    326. decoration: BoxDecoration(
    327. borderRadius: BorderRadius.circular(ScreenAdapter.width(20)),
    328. color: const Color.fromRGBO(255, 255, 255, 1),
    329. boxShadow: const [
    330. BoxShadow(
    331. color: Color.fromRGBO(0, 0, 0, 0.07),
    332. offset: Offset(0, 4),
    333. blurRadius: 8,
    334. spreadRadius: 0)
    335. ]),
    336. child: messageType == MessageType.text
    337. ? Text(
    338. data['message'],
    339. style: TextStyle(
    340. color: const Color.fromRGBO(51, 51, 51, 1),
    341. fontSize: ScreenAdapter.size(28)),
    342. )
    343. //todo 差语音样式
    344. : GestureDetector(
    345. onTap: () {
    346. //如果可播放且没有在播放则播放
    347. if (_voicePlayerIsReady && !_voicePlayerIsPlay) {
    348. _beginPlayer(data['messageVoice']);
    349. }
    350. //如果可播放且在播放 则停止播放
    351. if (_voicePlayerIsReady && _voicePlayerIsPlay) {
    352. _stopPlayer();
    353. }
    354. },
    355. child: Text(
    356. "语音消息....",
    357. style: TextStyle(
    358. color: const Color.fromRGBO(51, 51, 51, 1),
    359. fontSize: ScreenAdapter.size(28)),
    360. ),
    361. ),
    362. ),
    363. Padding(
    364. padding: EdgeInsets.only(left: ScreenAdapter.width(20)),
    365. child: Text(
    366. TimeUtils.setMessageTime(data['messageTime']),
    367. style: TextStyle(
    368. color: const Color.fromRGBO(183, 183, 183, 1),
    369. fontSize: ScreenAdapter.size(20),
    370. fontWeight: FontWeight.w500),
    371. ),
    372. )
    373. ],
    374. ),
    375. );
    376. }
    377. //my message widget
    378. Widget _myMessageWidget(Map data) {
    379. String messageType = data['messageType'];
    380. return Padding(
    381. padding: EdgeInsets.fromLTRB(0, ScreenAdapter.height(20),
    382. ScreenAdapter.width(32), ScreenAdapter.height(20)),
    383. child: Row(
    384. mainAxisAlignment: MainAxisAlignment.end,
    385. crossAxisAlignment: CrossAxisAlignment.center,
    386. children: [
    387. Padding(
    388. padding: EdgeInsets.only(right: ScreenAdapter.width(20)),
    389. child: Text(
    390. TimeUtils.setMessageTime(data['messageTime']),
    391. style: TextStyle(
    392. color: const Color.fromRGBO(183, 183, 183, 1),
    393. fontSize: ScreenAdapter.size(20),
    394. fontWeight: FontWeight.w500),
    395. ),
    396. ),
    397. Container(
    398. constraints: BoxConstraints(
    399. maxWidth: ScreenAdapter.width(450),
    400. ),
    401. //message
    402. padding: EdgeInsets.fromLTRB(
    403. ScreenAdapter.width(20),
    404. ScreenAdapter.height(24),
    405. ScreenAdapter.width(20),
    406. ScreenAdapter.height(24)),
    407. decoration: BoxDecoration(
    408. borderRadius: BorderRadius.circular(ScreenAdapter.width(20)),
    409. gradient: const LinearGradient(
    410. begin: Alignment.bottomCenter,
    411. end: Alignment.topCenter,
    412. colors: [
    413. Color.fromRGBO(99, 133, 230, 1),
    414. Color.fromRGBO(179, 106, 232, 1),
    415. ],
    416. ),
    417. boxShadow: const [
    418. BoxShadow(
    419. color: Color.fromRGBO(111, 129, 230, 0.2),
    420. offset: Offset(0, 4),
    421. blurRadius: 8,
    422. spreadRadius: 0)
    423. ]),
    424. //todo 后期要使用switch 这里先解决文本和语音
    425. child: messageType == MessageType.text
    426. ? Text(
    427. data['message'],
    428. style: TextStyle(
    429. color: const Color.fromRGBO(255, 255, 255, 1),
    430. fontSize: ScreenAdapter.size(28)),
    431. )
    432. //todo 差语音样式
    433. : GestureDetector(
    434. onTap: () {
    435. //如果可播放且没有在播放则播放
    436. if (_voicePlayerIsReady && !_voicePlayerIsPlay) {
    437. _beginPlayer(data['messageVoice']);
    438. }
    439. //如果可播放且在播放 则停止播放
    440. if (_voicePlayerIsReady && _voicePlayerIsPlay) {
    441. _stopPlayer();
    442. }
    443. },
    444. child: Text(
    445. "语音消息....",
    446. style: TextStyle(
    447. color: const Color.fromRGBO(255, 255, 255, 1),
    448. fontSize: ScreenAdapter.size(28)),
    449. ),
    450. ),
    451. ),
    452. ],
    453. ));
    454. }
    455. @override
    456. Widget build(BuildContext context) {
    457. ScreenAdapter.init(context);
    458. return Scaffold(
    459. body: Column(
    460. children: [
    461. //head
    462. Container(
    463. height: ScreenAdapter.height(220),
    464. width: ScreenAdapter.width(750),
    465. //padding only top->status bar
    466. padding: EdgeInsets.only(
    467. top: MediaQueryData.fromWindow(window).padding.top),
    468. //setting LinearGradient
    469. decoration: const BoxDecoration(
    470. boxShadow: [
    471. BoxShadow(
    472. offset: Offset(0, 8),
    473. blurRadius: 28,
    474. spreadRadius: 0,
    475. color: Color.fromRGBO(60, 70, 74, 0.3),
    476. )
    477. ],
    478. gradient: LinearGradient(
    479. begin: Alignment.bottomCenter,
    480. end: Alignment.topCenter,
    481. colors: [
    482. Color.fromRGBO(99, 133, 230, 1),
    483. Color.fromRGBO(179, 106, 232, 1),
    484. ],
    485. )),
    486. //head widget
    487. child: Row(
    488. mainAxisAlignment: MainAxisAlignment.spaceBetween,
    489. children: [
    490. //left row
    491. Row(
    492. children: [
    493. //break menu
    494. Container(
    495. margin: EdgeInsets.only(left: ScreenAdapter.width(44)),
    496. child: InkWell(
    497. onTap: () {
    498. Navigator.pop(context);
    499. },
    500. child: Image.asset(R.assetsImgLeftMenu,
    501. height: ScreenAdapter.height(42),
    502. width: ScreenAdapter.width(25)),
    503. ),
    504. ),
    505. //user portrait
    506. Container(
    507. margin: EdgeInsets.only(left: ScreenAdapter.width(27)),
    508. child: ClipOval(
    509. child: Image.network(
    510. "https://img2.baidu.com/it/u=2518930323,4285282159&fm=253&fmt=auto&app=120&f=JPEG?w=800&h=800",
    511. width: ScreenAdapter.width(80),
    512. height: ScreenAdapter.height(80),
    513. fit: BoxFit.cover,
    514. ),
    515. ),
    516. )
    517. ],
    518. ),
    519. //center column
    520. Column(
    521. mainAxisAlignment: MainAxisAlignment.center,
    522. crossAxisAlignment: CrossAxisAlignment.start,
    523. children: [
    524. Text("Shakibul Islam",
    525. style: TextStyle(
    526. color: const Color.fromRGBO(255, 255, 255, 1),
    527. fontSize: ScreenAdapter.size(32))),
    528. Text("最近会话 8:00",
    529. style: TextStyle(
    530. color: const Color.fromRGBO(255, 255, 255, 1),
    531. fontSize: ScreenAdapter.size(24))),
    532. ],
    533. ),
    534. //right row
    535. Row(
    536. children: [
    537. Container(
    538. margin: EdgeInsets.only(right: ScreenAdapter.width(40)),
    539. width: ScreenAdapter.width(38),
    540. height: ScreenAdapter.height(24),
    541. child: Image.asset(R.assetsImgChatVideo),
    542. ),
    543. Container(
    544. margin: EdgeInsets.only(right: ScreenAdapter.width(40)),
    545. child: Image.asset(R.assetsImgChatPhone,
    546. width: ScreenAdapter.width(32),
    547. height: ScreenAdapter.height(32)),
    548. ),
    549. Container(
    550. margin: EdgeInsets.only(right: ScreenAdapter.width(48)),
    551. child: Image.asset(R.assetsImgChatGroup,
    552. width: ScreenAdapter.width(8),
    553. height: ScreenAdapter.height(36)),
    554. )
    555. ],
    556. )
    557. ],
    558. ),
    559. ),
    560. //chat listview
    561. //todo 差消息撤回、删除、多选删除
    562. Expanded(
    563. flex: 1,
    564. child: Container(
    565. alignment: Alignment.topCenter,
    566. color: const Color.fromRGBO(244, 243, 249, 1),
    567. child: MediaQuery.removePadding(
    568. removeTop: true,
    569. removeBottom: true,
    570. context: context,
    571. child: ListView.builder(
    572. shrinkWrap: true,
    573. reverse: true,
    574. controller: _scrollController,
    575. itemCount: _messageData.length,
    576. itemBuilder: (BuildContext context, int index) {
    577. return _chatWidget(_messageData[index]);
    578. }),
    579. )),
    580. ),
    581. //bottom TextField
    582. //set bottom color
    583. ColoredBox(
    584. color: const Color.fromRGBO(244, 243, 249, 0.5),
    585. child: SafeArea(
    586. top: false,
    587. left: false,
    588. right: false,
    589. // maintainBottomViewPadding: true,
    590. child: Padding(
    591. padding: EdgeInsets.fromLTRB(
    592. ScreenAdapter.width(32),
    593. ScreenAdapter.height(20),
    594. ScreenAdapter.width(25),
    595. ScreenAdapter.height(20)),
    596. child: Row(
    597. mainAxisAlignment: MainAxisAlignment.center,
    598. children: [
    599. //TextField SizedBox
    600. SizedBox(
    601. width: ScreenAdapter.width(484),
    602. //TextField BoxDecoration
    603. child: TextField(
    604. minLines: 1,
    605. maxLines: 3,
    606. //发送按钮
    607. textInputAction: TextInputAction.send,
    608. controller: _textEditingController,
    609. //键盘弹起聊天页面滚到底部
    610. onTap: () {
    611. _scrollController.jumpTo(0);
    612. },
    613. onSubmitted: (String str) {
    614. _textEditingController.clear();
    615. if (str.isNotEmpty) {
    616. setState(() {
    617. Map data = {};
    618. data['messageId'] = const Uuid().v4();
    619. data['messageType'] = MessageType.text;
    620. data['message'] = str;
    621. data['messageTime'] =
    622. TimeUtils.getFormatDataString(
    623. DateTime.now(),
    624. "yyyy-MM-dd HH:mm:ss");
    625. data['isMe'] = Random.secure().nextBool();
    626. _messageData.insert(0, data);
    627. });
    628. }
    629. },
    630. //带外边框的样式
    631. decoration: InputDecoration(
    632. filled: true,
    633. fillColor: Colors.white,
    634. hintText: "输入信息......",
    635. contentPadding: const EdgeInsets.all(10),
    636. suffixIcon: GestureDetector(
    637. //长摁录音
    638. onLongPress: () {
    639. setState(() {
    640. _keyboardVoiceEnable =
    641. !_keyboardVoiceEnable;
    642. });
    643. //调取录音方法
    644. if (_voicePlayerIsInitialized) {
    645. _voiceFilePrefix = _beginVoice();
    646. }
    647. },
    648. onLongPressUp: () async {
    649. //停止录音并写入消息
    650. if (_voicePlayerIsInitialized) {
    651. _stopVoice(_voiceFilePrefix);
    652. }
    653. setState(() {
    654. _keyboardVoiceEnable =
    655. !_keyboardVoiceEnable;
    656. });
    657. //jumpToBottom
    658. _scrollController.jumpTo(0);
    659. },
    660. child: Icon(
    661. Icons.keyboard_voice,
    662. color: _keyboardVoiceEnable
    663. ? Colors.blue
    664. : Colors.black26,
    665. ),
    666. ),
    667. //获得焦点时的边框样式
    668. focusedBorder: OutlineInputBorder(
    669. borderRadius: BorderRadius.all(
    670. Radius.circular(
    671. ScreenAdapter.width(40))),
    672. borderSide: BorderSide(
    673. color: const Color.fromRGBO(
    674. 99, 133, 230, 1),
    675. width: ScreenAdapter.width(4))),
    676. //允许编辑焦点时的边框样式
    677. enabledBorder: OutlineInputBorder(
    678. borderRadius: BorderRadius.all(
    679. Radius.circular(
    680. ScreenAdapter.width(40))),
    681. borderSide: BorderSide(
    682. color: const Color.fromRGBO(
    683. 99, 133, 230, 1),
    684. width: ScreenAdapter.width(4)))),
    685. )),
    686. Expanded(
    687. child: Row(
    688. mainAxisAlignment: MainAxisAlignment.spaceAround,
    689. children: const [
    690. //todo 差相机按钮事件
    691. Icon(
    692. Icons.camera_alt_outlined,
    693. color: Color.fromRGBO(164, 175, 207, 1),
    694. ),
    695. //todo 差相册按钮事件
    696. Icon(
    697. Icons.photo,
    698. color: Color.fromRGBO(164, 175, 207, 1),
    699. ),
    700. //todo 还没想好
    701. Icon(
    702. Icons.add_circle,
    703. color: Colors.blue,
    704. ),
    705. ],
    706. ))
    707. ],
    708. ),
    709. )),
    710. )
    711. //chat widget
    712. //chat list
    713. ],
    714. ),
    715. );
    716. }
    717. }

    简单视频演示: 

    Flutter简单聊天界面布局及语音录制播放配套视频

  • 相关阅读:
    传输层_TCP&UDP
    CentOS 7 下 Ruby 环境搭建(编译安装)
    [本人毕业设计] 别踩白块_计算机科学与技术_前端H5游戏毕设
    el-table 列内容溢出 显示省略号 悬浮显示文字
    【重拾C语言】七、指针(三)指针与字符串(字符串与字符串数组;指针与字符串的遍历、拷贝、比较;反转字符串)
    迁移人大金仓问题汇总
    【Python】PySpark 数据计算 ① ( RDD#map 方法 | RDD#map 语法 | 传入普通函数 | 传入 lambda 匿名函数 | 链式调用 )
    Spark入门(一篇就够了)
    .nc格式文件的显示及特殊裁剪方式
    听说你要删库跑路了?这篇Linux脚本请收好
  • 原文地址:https://blog.csdn.net/u013600907/article/details/126505527