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

目录

前言:

 注意事项:

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

遇到的坑 

遇到的坑1:

 遇到的坑2:

遇到的坑3:

遇到的坑4:

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

Flutter录音组件生命周期图:

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

代码

简单视频演示: 


前言:

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

 注意事项:

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

    <key>NSMicrophoneUsageDescription</key>
    <string>访问麦克风</string>

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

  #语音录制、播放插件
  flutter_sound: ^9.2.13
  #检查权限
  permission_handler: ^6.0.1
  #此插件会告知操作系统您的音频应用程序的性质(例如游戏、媒体播放器、助手等)以及您的应用程序将如何处理和启动音频中断(例如电话中断)
  audio_session: ^0.1.10
  #uuid
  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语音播放组件生命周期图

代码

import 'dart:math';
import 'dart:ui';

import 'package:audio_session/audio_session.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:new_chat/code/message_type.dart';
import 'package:new_chat/r.dart';
import 'package:new_chat/service/screen_adapter.dart';
import 'package:new_chat/util/time_utils.dart';
import 'package:new_chat/widget/toast_widget.dart';
import 'package:flutter_sound/flutter_sound.dart';
import 'package:flutter_sound_platform_interface/flutter_sound_recorder_platform_interface.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:logger/logger.dart' show Level;
import 'package:uuid/uuid.dart';

class SingleChatPage extends StatefulWidget {
  final String chatId;

  const SingleChatPage({Key? key, required this.chatId}) : super(key: key);

  @override
  State<SingleChatPage> createState() {
    return _SingleChatPageState();
  }
}

class _SingleChatPageState extends State<SingleChatPage> {
  //message data
  List _messageData = [];

  ///语音播放及录制定义begin
  //默认语音录制为关闭
  bool _keyboardVoiceEnable = false;

  //listview跳转控制器
  final ScrollController _scrollController = ScrollController();

  //消息文本控制器
  final TextEditingController _textEditingController = TextEditingController();

  //语音类型
  final AudioSource _theSource = AudioSource.microphone;

  //存储录音编码格式
  Codec _codec = Codec.aacMP4;

  //播放器权限
  bool _voicePlayerIsInitialized = false;

  //录制权限
  bool _voiceRecorderIsInitialized = false;

  //播放器是否可播放
  bool _voicePlayerIsReady = false;

  //播放器是否在播放
  bool _voicePlayerIsPlay = false;

  //语音播放工具
  final FlutterSoundPlayer _voicePlayer =
      FlutterSoundPlayer(logLevel: Level.error);

  //语音录制工具
  final FlutterSoundRecorder _voiceRecorder =
      FlutterSoundRecorder(logLevel: Level.error);

  //存储文件后缀
  String _voiceFilePathSuffix = 'temp_file.mp4';

  //录音文件存储前缀
  String _voiceFilePrefix = "";

  ///语音播放及录制定义end

  @override
  void initState() {
    _initMessageData();
    //初始化播放器
    _voicePlayer.openPlayer().then((value) {
      setState(() {
        _voicePlayerIsInitialized = true;
      });
    });
    //初始化录音
    _initVoiceRecorder().then((value) {
      setState(() {
        _voiceRecorderIsInitialized = true;
      });
    });
    super.initState();
  }

  @override
  void dispose() {
    //关闭语音播放
    _voicePlayer.closePlayer();
    //关闭语音录制
    _voiceRecorder.closeRecorder();
    super.dispose();
  }

  ///录音及语音方法定义begin
  ///初始录音
  ///todo 用户禁止语音权限提示
  Future<void> _initVoiceRecorder() async {
    if (!kIsWeb) {
      var status = await Permission.microphone.request();
      if (status != PermissionStatus.granted) {
        throw RecordingPermissionException('Microphone permission not granted');
      }
    }
    await _voiceRecorder.openRecorder();
    if (!await _voiceRecorder.isEncoderSupported(_codec) && kIsWeb) {
      _codec = Codec.opusWebM;
      _voiceFilePathSuffix = 'tau_file.webm';
      if (!await _voiceRecorder.isEncoderSupported(_codec) && kIsWeb) {
        _voiceRecorderIsInitialized = true;
        return;
      }
    }
    final session = await AudioSession.instance;
    await session.configure(AudioSessionConfiguration(
      avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
      avAudioSessionCategoryOptions:
          AVAudioSessionCategoryOptions.allowBluetooth |
              AVAudioSessionCategoryOptions.defaultToSpeaker,
      avAudioSessionMode: AVAudioSessionMode.spokenAudio,
      avAudioSessionRouteSharingPolicy:
          AVAudioSessionRouteSharingPolicy.defaultPolicy,
      avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
      androidAudioAttributes: const AndroidAudioAttributes(
        contentType: AndroidAudioContentType.speech,
        flags: AndroidAudioFlags.none,
        usage: AndroidAudioUsage.voiceCommunication,
      ),
      androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
      androidWillPauseWhenDucked: true,
    ));
    _voiceRecorderIsInitialized = true;
  }

  ///开始录音并返回录音文件前缀
  String _beginVoice() {
    if (!_voiceRecorderIsInitialized) {
      ToastWidget.showToast("没有录音权限", ToastGravity.CENTER);
      throw Exception("没有录音权限");
    }
    var uuid = const Uuid().v4();
    _voiceRecorder
        .startRecorder(
            codec: _codec,
            toFile: uuid + _voiceFilePathSuffix,
            audioSource: _theSource)
        .then((value) {
      setState(() {
        //播放按钮禁用并插入语音到消息中
        _voicePlayerIsReady = false;
      });
    });
    return uuid;
  }

  ///停止录音 并将消息存储
  void _stopVoice(String voiceFileId) async {
    await _voiceRecorder.stopRecorder().then((value) {
      setState(() {
        //可以播放
        _voicePlayerIsReady = true;
        Map data = {};
        data['messageId'] = voiceFileId;
        //todo 差语音时长
        data['message'] = "语音消息按钮...";
        data['messageType'] = MessageType.voice;
        data['messageTime'] = TimeUtils.getFormatDataString(
            DateTime.now(), "yyyy-MM-dd HH:mm:ss");
        data['isMe'] = Random.secure().nextBool();
        //存储路径
        data['messageVoice'] = voiceFileId + _voiceFilePathSuffix;
        _messageData.insert(0, data);
      });
    });
  }

  ///开始播放录音
  void _beginPlayer(String messageVoiceFilePath) {
    assert(_voicePlayerIsInitialized &&
        _voicePlayerIsReady &&
        _voiceRecorder.isStopped &&
        _voicePlayer.isStopped);
    _voicePlayer
        .startPlayer(
            fromURI: messageVoiceFilePath,
            //codec: kIsWeb ? Codec.opusWebM : Codec.aacADTS,
            //语音播放完后的动作->停止播放
            whenFinished: () {
              setState(() {
                print("播放完的动作");
                _voicePlayerIsPlay = false;
                _voicePlayerIsReady = true;
              });
            })
        .then((value) {
      //语音正在播放的动作->正在播放
      setState(() {
        print("语音正在播放的动作");
        _voicePlayerIsPlay = true;
        _voicePlayerIsReady = false;
      });
    });
  }

  ///停止播放声音
  void _stopPlayer() {
    _voicePlayer.stopPlayer().then((value) {
      setState(() {
        _voicePlayerIsReady = true;
        _voicePlayerIsPlay = false;
      });
    });
  }

  ///录音及语音方法定义end

  ///初始化聊天数据
  //todo 差网络请求聊天数据 这里暂时mock
  _initMessageData() async {
    Dio dio = Dio();
    //mock data
    try {
      //todo timeout 1 seconds
      var response = await dio
          .get(
            "http://192.168.10.15:3000/mock/313/message",
          )
          .timeout(const Duration(seconds: 1));
      setState(() {
        _messageData = response.data['data'];
      });
    } catch (e) {
      //mock test data
      List<Map> tempData = [];
      Map data = {};
      data['messageId'] = const Uuid().v4();
      data['message'] = "嗯,没问题。明天我起床就联系你。";
      data['isMe'] = false;
      data['messageTime'] = "2022-08-17 16:20:20";
      data['messageType'] = MessageType.text;
      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();
      data['message'] = "好的。有什么事情及时联系我都在线的。";
      data['isMe'] = true;
      data['messageTime'] = "2022-08-17 16:19:20";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();

      data['message'] = "晚安!";
      data['isMe'] = false;
      data['messageTime'] = "2022-08-17 16:16:20";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();

      data['message'] = "嗯,今晚好好休息!";
      data['isMe'] = false;
      data['messageTime'] = "2022-08-17 16:15:20";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();

      data['message'] = "好的,那到时见!!!";
      data['isMe'] = true;
      data['messageTime'] = "2022-08-16 01:15:20";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();

      data['message'] = "不用准备什么东西,我都已经准备好了。应该是吃完午餐就出发吧。大概下午2点左右。";
      data['isMe'] = false;
      data['messageTime'] = "2022-08-16 01:13:20";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();

      data['message'] = "需要准备什么东西带过去";
      data['isMe'] = true;
      data['messageTime'] = "2022-08-15 12:42:20";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();

      data['message'] = "好的,10点左右可以的。你打算几点出发?";
      data['isMe'] = true;
      data['messageTime'] = "2022-08-14 14:24:20";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();

      data['message'] = "嗯,大概上午10点左右吧。 如果没空就下午。";
      data['isMe'] = false;
      data['messageTime'] = "2022-08-13 11:11:22";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();

      data['message'] = "明天什么时候呢???";
      data['isMe'] = true;
      data['messageTime'] = "2022-08-12 10:32:11";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      data = {};
      data['messageId'] = const Uuid().v4();

      data['message'] = "你明天有空过来吗??";
      data['isMe'] = false;
      data['messageTime'] = "2022-08-12 10:31:24";
      data['messageType'] = MessageType.text;

      tempData.add(data);
      setState(() {
        _messageData = tempData;
      });
    }
  }

  // chat widget
  //todo 后期需要根据messageSendType区分
  Widget _chatWidget(Map data) {
    if (data['isMe']) {
      return _myMessageWidget(data);
    }
    return _yourMessageWidget(data);
  }

  //your message widget
  Widget _yourMessageWidget(Map data) {
    String messageType = data['messageType'];
    return Padding(
      padding: EdgeInsets.fromLTRB(ScreenAdapter.width(32),
          ScreenAdapter.height(20), 0, ScreenAdapter.height(20)),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.start,
        children: [
          Container(
            constraints: BoxConstraints(
              maxWidth: ScreenAdapter.width(450),
            ),
            padding: EdgeInsets.fromLTRB(
                ScreenAdapter.width(20),
                ScreenAdapter.height(24),
                ScreenAdapter.width(20),
                ScreenAdapter.height(24)),
            decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(ScreenAdapter.width(20)),
                color: const Color.fromRGBO(255, 255, 255, 1),
                boxShadow: const [
                  BoxShadow(
                      color: Color.fromRGBO(0, 0, 0, 0.07),
                      offset: Offset(0, 4),
                      blurRadius: 8,
                      spreadRadius: 0)
                ]),
            child: messageType == MessageType.text
                ? Text(
                    data['message'],
                    style: TextStyle(
                        color: const Color.fromRGBO(51, 51, 51, 1),
                        fontSize: ScreenAdapter.size(28)),
                  )
            //todo 差语音样式
                : GestureDetector(
                    onTap: () {
                      //如果可播放且没有在播放则播放
                      if (_voicePlayerIsReady && !_voicePlayerIsPlay) {
                        _beginPlayer(data['messageVoice']);
                      }
                      //如果可播放且在播放 则停止播放
                      if (_voicePlayerIsReady && _voicePlayerIsPlay) {
                        _stopPlayer();
                      }
                    },
                    child: Text(
                      "语音消息....",
                      style: TextStyle(
                          color: const Color.fromRGBO(51, 51, 51, 1),
                          fontSize: ScreenAdapter.size(28)),
                    ),
                  ),
          ),
          Padding(
            padding: EdgeInsets.only(left: ScreenAdapter.width(20)),
            child: Text(
              TimeUtils.setMessageTime(data['messageTime']),
              style: TextStyle(
                  color: const Color.fromRGBO(183, 183, 183, 1),
                  fontSize: ScreenAdapter.size(20),
                  fontWeight: FontWeight.w500),
            ),
          )
        ],
      ),
    );
  }

  //my message widget
  Widget _myMessageWidget(Map data) {
    String messageType = data['messageType'];
    return Padding(
        padding: EdgeInsets.fromLTRB(0, ScreenAdapter.height(20),
            ScreenAdapter.width(32), ScreenAdapter.height(20)),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.end,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Padding(
              padding: EdgeInsets.only(right: ScreenAdapter.width(20)),
              child: Text(
                TimeUtils.setMessageTime(data['messageTime']),
                style: TextStyle(
                    color: const Color.fromRGBO(183, 183, 183, 1),
                    fontSize: ScreenAdapter.size(20),
                    fontWeight: FontWeight.w500),
              ),
            ),
            Container(
              constraints: BoxConstraints(
                maxWidth: ScreenAdapter.width(450),
              ),
              //message
              padding: EdgeInsets.fromLTRB(
                  ScreenAdapter.width(20),
                  ScreenAdapter.height(24),
                  ScreenAdapter.width(20),
                  ScreenAdapter.height(24)),
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(ScreenAdapter.width(20)),
                  gradient: const LinearGradient(
                    begin: Alignment.bottomCenter,
                    end: Alignment.topCenter,
                    colors: [
                      Color.fromRGBO(99, 133, 230, 1),
                      Color.fromRGBO(179, 106, 232, 1),
                    ],
                  ),
                  boxShadow: const [
                    BoxShadow(
                        color: Color.fromRGBO(111, 129, 230, 0.2),
                        offset: Offset(0, 4),
                        blurRadius: 8,
                        spreadRadius: 0)
                  ]),
              //todo 后期要使用switch 这里先解决文本和语音
              child: messageType == MessageType.text
                  ? Text(
                      data['message'],
                      style: TextStyle(
                          color: const Color.fromRGBO(255, 255, 255, 1),
                          fontSize: ScreenAdapter.size(28)),
                    )
              //todo 差语音样式
                  : GestureDetector(
                      onTap: () {
                        //如果可播放且没有在播放则播放
                        if (_voicePlayerIsReady && !_voicePlayerIsPlay) {
                          _beginPlayer(data['messageVoice']);
                        }
                        //如果可播放且在播放 则停止播放
                        if (_voicePlayerIsReady && _voicePlayerIsPlay) {
                          _stopPlayer();
                        }
                      },
                      child: Text(
                        "语音消息....",
                        style: TextStyle(
                            color: const Color.fromRGBO(255, 255, 255, 1),
                            fontSize: ScreenAdapter.size(28)),
                      ),
                    ),
            ),
          ],
        ));
  }

  @override
  Widget build(BuildContext context) {
    ScreenAdapter.init(context);
    return Scaffold(
      body: Column(
        children: [
          //head
          Container(
            height: ScreenAdapter.height(220),
            width: ScreenAdapter.width(750),
            //padding only top->status bar
            padding: EdgeInsets.only(
                top: MediaQueryData.fromWindow(window).padding.top),
            //setting LinearGradient
            decoration: const BoxDecoration(
                boxShadow: [
                  BoxShadow(
                    offset: Offset(0, 8),
                    blurRadius: 28,
                    spreadRadius: 0,
                    color: Color.fromRGBO(60, 70, 74, 0.3),
                  )
                ],
                gradient: LinearGradient(
                  begin: Alignment.bottomCenter,
                  end: Alignment.topCenter,
                  colors: [
                    Color.fromRGBO(99, 133, 230, 1),
                    Color.fromRGBO(179, 106, 232, 1),
                  ],
                )),
            //head widget
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                //left row
                Row(
                  children: [
                    //break menu
                    Container(
                      margin: EdgeInsets.only(left: ScreenAdapter.width(44)),
                      child: InkWell(
                        onTap: () {
                          Navigator.pop(context);
                        },
                        child: Image.asset(R.assetsImgLeftMenu,
                            height: ScreenAdapter.height(42),
                            width: ScreenAdapter.width(25)),
                      ),
                    ),
                    //user portrait
                    Container(
                      margin: EdgeInsets.only(left: ScreenAdapter.width(27)),
                      child: ClipOval(
                        child: Image.network(
                          "https://img2.baidu.com/it/u=2518930323,4285282159&fm=253&fmt=auto&app=120&f=JPEG?w=800&h=800",
                          width: ScreenAdapter.width(80),
                          height: ScreenAdapter.height(80),
                          fit: BoxFit.cover,
                        ),
                      ),
                    )
                  ],
                ),
                //center column
                Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text("Shakibul Islam",
                        style: TextStyle(
                            color: const Color.fromRGBO(255, 255, 255, 1),
                            fontSize: ScreenAdapter.size(32))),
                    Text("最近会话 8:00",
                        style: TextStyle(
                            color: const Color.fromRGBO(255, 255, 255, 1),
                            fontSize: ScreenAdapter.size(24))),
                  ],
                ),
                //right row
                Row(
                  children: [
                    Container(
                      margin: EdgeInsets.only(right: ScreenAdapter.width(40)),
                      width: ScreenAdapter.width(38),
                      height: ScreenAdapter.height(24),
                      child: Image.asset(R.assetsImgChatVideo),
                    ),
                    Container(
                      margin: EdgeInsets.only(right: ScreenAdapter.width(40)),
                      child: Image.asset(R.assetsImgChatPhone,
                          width: ScreenAdapter.width(32),
                          height: ScreenAdapter.height(32)),
                    ),
                    Container(
                      margin: EdgeInsets.only(right: ScreenAdapter.width(48)),
                      child: Image.asset(R.assetsImgChatGroup,
                          width: ScreenAdapter.width(8),
                          height: ScreenAdapter.height(36)),
                    )
                  ],
                )
              ],
            ),
          ),
          //chat listview
          //todo 差消息撤回、删除、多选删除
          Expanded(
            flex: 1,
            child: Container(
                alignment: Alignment.topCenter,
                color: const Color.fromRGBO(244, 243, 249, 1),
                child: MediaQuery.removePadding(
                  removeTop: true,
                  removeBottom: true,
                  context: context,
                  child: ListView.builder(
                      shrinkWrap: true,
                      reverse: true,
                      controller: _scrollController,
                      itemCount: _messageData.length,
                      itemBuilder: (BuildContext context, int index) {
                        return _chatWidget(_messageData[index]);
                      }),
                )),
          ),
          //bottom TextField
          //set bottom color
          ColoredBox(
            color: const Color.fromRGBO(244, 243, 249, 0.5),
            child: SafeArea(
                top: false,
                left: false,
                right: false,
                // maintainBottomViewPadding: true,
                child: Padding(
                  padding: EdgeInsets.fromLTRB(
                      ScreenAdapter.width(32),
                      ScreenAdapter.height(20),
                      ScreenAdapter.width(25),
                      ScreenAdapter.height(20)),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      //TextField SizedBox
                      SizedBox(
                          width: ScreenAdapter.width(484),
                          //TextField BoxDecoration
                          child: TextField(
                            minLines: 1,
                            maxLines: 3,
                            //发送按钮
                            textInputAction: TextInputAction.send,
                            controller: _textEditingController,
                            //键盘弹起聊天页面滚到底部
                            onTap: () {
                              _scrollController.jumpTo(0);
                            },
                            onSubmitted: (String str) {
                              _textEditingController.clear();
                              if (str.isNotEmpty) {
                                setState(() {
                                  Map data = {};
                                  data['messageId'] = const Uuid().v4();
                                  data['messageType'] = MessageType.text;
                                  data['message'] = str;
                                  data['messageTime'] =
                                      TimeUtils.getFormatDataString(
                                          DateTime.now(),
                                          "yyyy-MM-dd HH:mm:ss");
                                  data['isMe'] = Random.secure().nextBool();
                                  _messageData.insert(0, data);
                                });
                              }
                            },
                            //带外边框的样式
                            decoration: InputDecoration(
                                filled: true,
                                fillColor: Colors.white,
                                hintText: "输入信息......",
                                contentPadding: const EdgeInsets.all(10),
                                suffixIcon: GestureDetector(
                                  //长摁录音
                                  onLongPress: () {
                                    setState(() {
                                      _keyboardVoiceEnable =
                                          !_keyboardVoiceEnable;
                                    });
                                    //调取录音方法
                                    if (_voicePlayerIsInitialized) {
                                      _voiceFilePrefix = _beginVoice();
                                    }
                                  },
                                  onLongPressUp: () async {
                                    //停止录音并写入消息
                                    if (_voicePlayerIsInitialized) {
                                      _stopVoice(_voiceFilePrefix);
                                    }
                                    setState(() {
                                      _keyboardVoiceEnable =
                                          !_keyboardVoiceEnable;
                                    });
                                    //jumpToBottom
                                    _scrollController.jumpTo(0);
                                  },
                                  child: Icon(
                                    Icons.keyboard_voice,
                                    color: _keyboardVoiceEnable
                                        ? Colors.blue
                                        : Colors.black26,
                                  ),
                                ),
                                //获得焦点时的边框样式
                                focusedBorder: OutlineInputBorder(
                                    borderRadius: BorderRadius.all(
                                        Radius.circular(
                                            ScreenAdapter.width(40))),
                                    borderSide: BorderSide(
                                        color: const Color.fromRGBO(
                                            99, 133, 230, 1),
                                        width: ScreenAdapter.width(4))),
                                //允许编辑焦点时的边框样式
                                enabledBorder: OutlineInputBorder(
                                    borderRadius: BorderRadius.all(
                                        Radius.circular(
                                            ScreenAdapter.width(40))),
                                    borderSide: BorderSide(
                                        color: const Color.fromRGBO(
                                            99, 133, 230, 1),
                                        width: ScreenAdapter.width(4)))),
                          )),
                      Expanded(
                          child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceAround,
                        children: const [
                          //todo 差相机按钮事件
                          Icon(
                            Icons.camera_alt_outlined,
                            color: Color.fromRGBO(164, 175, 207, 1),
                          ),
                          //todo 差相册按钮事件
                          Icon(
                            Icons.photo,
                            color: Color.fromRGBO(164, 175, 207, 1),
                          ),
                          //todo 还没想好
                          Icon(
                            Icons.add_circle,
                            color: Colors.blue,
                          ),
                        ],
                      ))
                    ],
                  ),
                )),
          )
          //chat widget
          //chat list
        ],
      ),
    );
  }
}

简单视频演示: 

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

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340