Flutter简单聊天界面布局及语音录制播放_录音功能的视线 flutter-程序员宅基地

技术标签: flutter  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简单聊天界面布局及语音录制播放配套视频

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/u013600907/article/details/126505527

智能推荐

oracle 12c 集群安装后的检查_12c查看crs状态-程序员宅基地

文章浏览阅读1.6k次。安装配置gi、安装数据库软件、dbca建库见下:http://blog.csdn.net/kadwf123/article/details/784299611、检查集群节点及状态:[root@rac2 ~]# olsnodes -srac1 Activerac2 Activerac3 Activerac4 Active[root@rac2 ~]_12c查看crs状态

解决jupyter notebook无法找到虚拟环境的问题_jupyter没有pytorch环境-程序员宅基地

文章浏览阅读1.3w次,点赞45次,收藏99次。我个人用的是anaconda3的一个python集成环境,自带jupyter notebook,但在我打开jupyter notebook界面后,却找不到对应的虚拟环境,原来是jupyter notebook只是通用于下载anaconda时自带的环境,其他环境要想使用必须手动下载一些库:1.首先进入到自己创建的虚拟环境(pytorch是虚拟环境的名字)activate pytorch2.在该环境下下载这个库conda install ipykernelconda install nb__jupyter没有pytorch环境

国内安装scoop的保姆教程_scoop-cn-程序员宅基地

文章浏览阅读5.2k次,点赞19次,收藏28次。选择scoop纯属意外,也是无奈,因为电脑用户被锁了管理员权限,所有exe安装程序都无法安装,只可以用绿色软件,最后被我发现scoop,省去了到处下载XXX绿色版的烦恼,当然scoop里需要管理员权限的软件也跟我无缘了(譬如everything)。推荐添加dorado这个bucket镜像,里面很多中文软件,但是部分国外的软件下载地址在github,可能无法下载。以上两个是官方bucket的国内镜像,所有软件建议优先从这里下载。上面可以看到很多bucket以及软件数。如果官网登陆不了可以试一下以下方式。_scoop-cn

Element ui colorpicker在Vue中的使用_vue el-color-picker-程序员宅基地

文章浏览阅读4.5k次,点赞2次,收藏3次。首先要有一个color-picker组件 <el-color-picker v-model="headcolor"></el-color-picker>在data里面data() { return {headcolor: ’ #278add ’ //这里可以选择一个默认的颜色} }然后在你想要改变颜色的地方用v-bind绑定就好了,例如:这里的:sty..._vue el-color-picker

迅为iTOP-4412精英版之烧写内核移植后的镜像_exynos 4412 刷机-程序员宅基地

文章浏览阅读640次。基于芯片日益增长的问题,所以内核开发者们引入了新的方法,就是在内核中只保留函数,而数据则不包含,由用户(应用程序员)自己把数据按照规定的格式编写,并放在约定的地方,为了不占用过多的内存,还要求数据以根精简的方式编写。boot启动时,传参给内核,告诉内核设备树文件和kernel的位置,内核启动时根据地址去找到设备树文件,再利用专用的编译器去反编译dtb文件,将dtb还原成数据结构,以供驱动的函数去调用。firmware是三星的一个固件的设备信息,因为找不到固件,所以内核启动不成功。_exynos 4412 刷机

Linux系统配置jdk_linux配置jdk-程序员宅基地

文章浏览阅读2w次,点赞24次,收藏42次。Linux系统配置jdkLinux学习教程,Linux入门教程(超详细)_linux配置jdk

随便推点

matlab(4):特殊符号的输入_matlab微米怎么输入-程序员宅基地

文章浏览阅读3.3k次,点赞5次,收藏19次。xlabel('\delta');ylabel('AUC');具体符号的对照表参照下图:_matlab微米怎么输入

C语言程序设计-文件(打开与关闭、顺序、二进制读写)-程序员宅基地

文章浏览阅读119次。顺序读写指的是按照文件中数据的顺序进行读取或写入。对于文本文件,可以使用fgets、fputs、fscanf、fprintf等函数进行顺序读写。在C语言中,对文件的操作通常涉及文件的打开、读写以及关闭。文件的打开使用fopen函数,而关闭则使用fclose函数。在C语言中,可以使用fread和fwrite函数进行二进制读写。‍ Biaoge 于2024-03-09 23:51发布 阅读量:7 ️文章类型:【 C语言程序设计 】在C语言中,用于打开文件的函数是____,用于关闭文件的函数是____。

Touchdesigner自学笔记之三_touchdesigner怎么让一个模型跟着鼠标移动-程序员宅基地

文章浏览阅读3.4k次,点赞2次,收藏13次。跟随鼠标移动的粒子以grid(SOP)为partical(SOP)的资源模板,调整后连接【Geo组合+point spirit(MAT)】,在连接【feedback组合】适当调整。影响粒子动态的节点【metaball(SOP)+force(SOP)】添加mouse in(CHOP)鼠标位置到metaball的坐标,实现鼠标影响。..._touchdesigner怎么让一个模型跟着鼠标移动

【附源码】基于java的校园停车场管理系统的设计与实现61m0e9计算机毕设SSM_基于java技术的停车场管理系统实现与设计-程序员宅基地

文章浏览阅读178次。项目运行环境配置:Jdk1.8 + Tomcat7.0 + Mysql + HBuilderX(Webstorm也行)+ Eclispe(IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持)。项目技术:Springboot + mybatis + Maven +mysql5.7或8.0+html+css+js等等组成,B/S模式 + Maven管理等等。环境需要1.运行环境:最好是java jdk 1.8,我们在这个平台上运行的。其他版本理论上也可以。_基于java技术的停车场管理系统实现与设计

Android系统播放器MediaPlayer源码分析_android多媒体播放源码分析 时序图-程序员宅基地

文章浏览阅读3.5k次。前言对于MediaPlayer播放器的源码分析内容相对来说比较多,会从Java-&amp;amp;gt;Jni-&amp;amp;gt;C/C++慢慢分析,后面会慢慢更新。另外,博客只作为自己学习记录的一种方式,对于其他的不过多的评论。MediaPlayerDemopublic class MainActivity extends AppCompatActivity implements SurfaceHolder.Cal..._android多媒体播放源码分析 时序图

java 数据结构与算法 ——快速排序法-程序员宅基地

文章浏览阅读2.4k次,点赞41次,收藏13次。java 数据结构与算法 ——快速排序法_快速排序法