闲聊
仿微信语音发送的交互最早还是前几年做iOS开发的时候做过,之后就没有再接触了。再加上平时自己聊天都是文字输入,语音发送这块一直都没注意有更新。前段时间突发奇想就想重新写个flutter版本的交互,不看不知道,嚯,好家伙,直呼好家伙,UI可是大变样了。
断断续续在弄这个,一直也懒得发,但还是写个文章存个档,当做是开发记录吧。
以上都是废话,纯属内心旁白。
正文
先看一下微信发送语音的交互截图:
从截图分析出我们需要实现的部分:
-
聊天列表位置偏移,并且列表不是置顶状态,需要将列表置顶;
-
隔离于聊天列表的弹框,所有的语音输入交互都是在这里完成的;
-
语音录制UI可分为三个部分:1、正常录制语音;2、取消发送;3、实现音频转文字操作;三个部分分别对应3种气泡UI,并且触点改变后对应UI的切换,需要注意的是第3种音频转文字完成后,气泡UI是一个可输入的输入框;
-
语音录制。这个不用多说,这里有个附加操作是获取音频振幅,实现语音输入波浪动画;
-
语音转文字(未实现)。找了下资料发现大多都是付费API,所以这里就只是把异步操作口子暴露出来,方便有需要的时候进行接入;
从flutter开发分析对应的技术点:
-
列表置顶:可以使用
ScrollController
;聊天列表位置偏移:可以修改Size
/ 底部添加高度变化的Gaps
,这里我选用的第二种方式,具体实现采用AnimatedPadding
根据显示状态来设置padding
的变化; -
弹框:在flutter技术项中提到弹框就有两种实现方式,1、
Route
通过路由叠层现实;2、OverlayEntry
直接在当前页面上插入Overlay
实现。这里我也是用的第二种方式,目前没有进行横向对比哪一种实现方式更优,我们先需求后谈优,毕竟完成业务需求才是我们的本职工作,一昧追求优化反而有点钻牛角尖的感觉,技术是为业务服务的嘛,嘿嘿;(补充:Route
内置太多额外的东西,在这里做遮罩层反而不太适用,纯自定义的OverlayEntry
更加灵活) -
语音录制UI:首选当然是UI设计的图片啦,但是这里是个人demo,所以都是肉眼
高(大)仿(致)真(猜)照抄的,尽量使用的Icon
和Canvas
来替换,这里略过后续细说; -
语音录制:这里选取的是录音插件
record
,因为该插件可以获取到实时录音的振幅Amplite
,当然这里没有深入去了解其他插件的方法,都是简单看了一下多个录音插件的API,简单对比下完成的选取; -
语音转文字:demo并没有实现这个功能,因为平台的API是要钱的,是吧,我们把交互做完,口子暴露出来就好了。这里题外说一下,大平台的API如果接入的话,前一步的录音插件应该就不需要单独导入了,语音转文字的SDK好像有提供录制功能;
demo示例
编码分析
首先通过前面的分析我们先设计出在录音过程中,UI对应的状态枚举如下:
enum SoundsMessageStatus {
/// 默认状态 未交互/交互完成
none,
/// 录制
recording,
/// 取消录制
canceling,
/// 语音转文字
textProcessing,
/// 语音转文字 - 管理操作
textProcessed;
String get title {
switch (this) {
case none:
return '按住 说话';
case recording:
return '松开 发送';
case canceling:
return '松开 取消';
case textProcessing:
case textProcessed:
return '转文字';
}
}
}
一、手势交互区域
页面底部按钮
这个就不多说了,就是一个圆角矩形视图。
首先我们唤起录音弹窗的操作一般是 长按
触发,所以我们给 页面底部按钮
加上 GestureDetector
手势交互事件:
onLongPress
长按显示录音UI,并开始录音;onLongPressEnd
结束长按,关闭录音,并针对当前触点位置的SoundsMessageStatus
完成业务逻辑(取消、发送语音、语音转文字);onLongPressMoveUpdate
触点的移动,对应到三种交互的气泡和按钮的切换,这里简单对触点区域进行划分,代码如下:
onLongPressMoveUpdate: (details) {
final offset = details.globalPosition;
if ((scSize.height - offset.dy.abs()) >
widget.maskData.sendAreaHeight) {
final cancelOffset = offset.dx < scSize.width / 2;
if (cancelOffset) {
_soundsRecorder.updateStatus(SoundsMessageStatus.canceling);
} else {
_soundsRecorder.updateStatus(SoundsMessageStatus.textProcessing);
}
} else {
_soundsRecorder.updateStatus(SoundsMessageStatus.recording);
}
},
图例如下:
二、弹框的绘制
准备工作:给整个弹框添加一个UI渲染的各项配置 RecordingMaskOverlayData
以及继承自 InheritedWidget
的 PolymerState
,用于整个UI的配置数据流通。
// D:\Projects\sounds_message\lib\utils\data.dart
/// 当正在录制的时候,页面显示的 `OverlayEntry`
class RecordingMaskOverlayData {
/// 底部圆形的高度
final double sendAreaHeight;
/// 圆形图形大小
final double iconSize;
/// 圆形图形大小 - 响应
final double iconFocusSize;
/// 录音气泡大小
// final EdgeInsets soundsMargin;
/// 圆形图形颜色
final Color iconColor;
/// 圆形图形颜色 - 响应
final Color iconFocusColor;
/// 文字颜色
final Color iconTxtColor;
/// 文字颜色 - 响应
final Color iconFocusTxtColor;
/// 遮罩文字样式
final TextStyle maskTxtStyle;
const RecordingMaskOverlayData({
...
});
}
// D:\Projects\sounds_message\lib\sounds_button\recording_status_mask.dart
class PolymerData {
PolymerData(this.controller, this.data);
/// 逻辑处理
final SoundsRecorderController controller;
/// 语音输入时遮罩配置
final RecordingMaskOverlayData data;
}
class PolymerState extends InheritedWidget {
const PolymerState({
super.key,
required this.data,
required super.child,
});
final PolymerData data;
...
}
- 绘制弹框背景
整个页面使用
Stack
布局更为简单,那背景就是一个渐变色叠层。
class _MaskStackView extends StatelessWidget {
const _MaskStackView({
required this.children,
});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final polymerState = PolymerState.of(context);
return GestureDetector(
onTap: () {
FocusScope.of(context).unfocus();
},
child: Stack(alignment: Alignment.bottomCenter, children: [
Positioned(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Color(0xFF474747),
Color(0x00474747),
],
)),
),
),
Positioned(
child: Container(
height: polymerState.data.sendAreaHeight +
polymerState.data.iconFocusSize,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Color(0xFF474747),
Color(0x22474747),
],
)),
),
),
...children,
]),
);
}
}
代码中使用了两个渐变色块,局部渐变主要是用来做底部操作区域不透明处理,全局渐变用于整体的半透明。
children
为操作区域的布局元素。
- 绘制操作区域
底部弧形区域:使用 canvas
进行绘制,分为 整体弧形渐变区域
和 顶部弧形线条
,通过手势触点是否在录制区域来区分弧形区域颜色的改变。
Column(
mainAxisSize: MainAxisSize.min,
children: [
Visibility(
visible: value == SoundsMessageStatus.recording,
child: Text(value.title, style: data.maskTxtStyle),
),
const SizedBox(height: 8),
CustomPaint(
// size: Size(double.infinity, data.sendAreaHeight),
painter: _RecordingPainter(
value == SoundsMessageStatus.recording),
child: Container(
width: double.infinity,
height: data.sendAreaHeight,
alignment: Alignment.center,
child: VoiceIcon(color: data.iconTxtColor),
),
),
],
),
相关 CustomPainter
的实现就不贴了,比较简单,后续画布实现同样不会贴出,有点浪费篇幅。
圆形按钮(取消/转文字):通过 Visibility
控制提示文本的显示,通过 AnimatedContainer
完成圆形按钮半径变化的过渡动画,通过 Transform.rotate
完成视图的倾斜角度。
{
const _Circle({
required this.title,
this.isFocus = false,
this.isLeft = true,
});
final String title;
/// 是否为焦点
final bool isFocus;
/// 是否为左边
final bool isLeft;
@override
Widget build(BuildContext context) {
final polymerState = PolymerState.of(context);
final data = polymerState.data;
final size = isFocus ? data.iconFocusSize : data.iconSize;
double marginSide =
0 + (isFocus ? 0.5 : 1) * (data.iconFocusSize - data.iconSize);
return Column(
children: [
Visibility(
visible: isFocus,
child: Text(title, style: data.maskTxtStyle),
),
// const SizedBox(height: 10),
AnimatedContainer(
duration: _duration,
curve: Curves.easeInOut,
margin: EdgeInsets.all(marginSide),
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isFocus ? data.iconFocusColor : data.iconColor,
borderRadius: BorderRadius.circular(data.iconFocusSize),
),
child: Transform.rotate(
angle: isLeft ? -0.2 : 0.2,
child: isLeft
? Icon(
Icons.close,
size: 28,
color:
isFocus ? data.iconFocusTxtColor : data.iconTxtColor,
)
: Icon(
Icons.text_decrease,
size: 28,
color:
isFocus ? data.iconFocusTxtColor : data.iconTxtColor,
)
// : Text(
// '文',
// style: TextStyle(
// fontSize: 22,
// fontWeight: FontWeight.bold,
// color:
// isFocus ? data.iconFocusTxtColor : data.iconTxtColor,
// ),
// ),
),
),
],
);
}
}
语音转文字圆形按钮:通过异步事件完成状态,控制加载动画和转换完成的显示。
class _TextProcessedCircle extends StatelessWidget {
const _TextProcessedCircle({
required this.data,
this.onLoading,
this.onTap,
});
final RecordingMaskOverlayData data;
/// 解析语音的延时操作
final Future<bool> Function()? onLoading;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final size = data.iconFocusSize;
double marginSide = 0.5 * (data.iconFocusSize - data.iconSize);
return FutureBuilder<bool>(
future: onLoading?.call(),
builder: (context, snapshot) {
Widget icon = const CircularProgressIndicator(
strokeWidth: 3,
color: Colors.orange,
);
if (snapshot.data == true) {
icon = Icon(
Icons.check_rounded,
size: data.iconFocusSize / 2.2,
color: Colors.orange,
);
}
return GestureDetector(
onTap: () {
if (snapshot.data == true) {
onTap?.call();
}
},
child: Container(
margin: EdgeInsets.all(marginSide),
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: data.iconFocusColor,
borderRadius: BorderRadius.circular(data.iconFocusSize),
),
child: icon,
),
);
},
);
}
}
显示气泡:通过 canvas
进行绘制,唯一需要注意的是交互中的多个显示状态,气泡的大小是有区别的,如果直接通过 size
来进行绘制,切换动画会变得很突兀。正常来讲我们只需要给个变化的 size
就能实现宽高的过渡动画,但最初完成编码后发现气泡过渡会有闪动的情况,初步推断是因为 边距
没有很好的完成过渡。所以这里我选用的 AnimatedContainer - margin
来作为变化值,这样可以比较好的实现气泡之间 大小、边距
变化,而不会出现闪动的情况。
class _Bubble extends StatelessWidget {
const _Bubble({
required this.paddingSide,
});
final double paddingSide;
@override
Widget build(BuildContext context) {
final polymerState = PolymerState.of(context);
final data = polymerState.data;
final status = polymerState.controller.status.value;
// 80 是气泡整体高度
const height = 64.0;
Rect rect = const Rect.fromLTRB(24, 0, 24, height);
if (status == SoundsMessageStatus.recording) {
rect = Rect.fromLTRB(paddingSide + data.iconFocusSize / 2, 0,
paddingSide + data.iconFocusSize / 2, height);
} else if (status == SoundsMessageStatus.canceling) {
rect = Rect.fromLTRB(
paddingSide - 5,
0,
ScreenUtil().screenWidth - data.iconFocusSize - paddingSide - 10,
height);
}
double bottom = 0;
if (status == SoundsMessageStatus.textProcessing ||
status == SoundsMessageStatus.textProcessed) {
bottom = 20;
}
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
return Positioned(
left: 0,
right: 0,
// 键盘高度
bottom:
max(keyboardHeight, data.sendAreaHeight * 2 + data.iconFocusSize) +
20,
// bottom: data.sendAreaHeight * 2 + data.iconFocusSize,
child: AnimatedContainer(
duration: _duration,
curve: Curves.easeInOut,
margin: EdgeInsets.only(left: rect.left, right: rect.right, bottom: 0),
// height: rect.height,
// width: rect.width,
constraints: BoxConstraints(
minHeight: rect.height + bottom,
maxHeight: (rect.height + bottom) * 2,
maxWidth: rect.width,
minWidth: rect.width,
),
child: RepaintBoundary(
child: CustomPaint(
painter: _BubblePainter(data, status, paddingSide),
child: Container(
padding: const EdgeInsets.only(
left: 10, right: 10, top: 10, bottom: 10),
child: status == SoundsMessageStatus.textProcessing ||
status == SoundsMessageStatus.textProcessed
? const _TextProcessedContent()
: const AmpContent(),
),
),
),
),
);
}
}
当然这里可以采用 AnimationController
得方式来控制过渡动画。
最后说一下,录制振幅动画:canvas
绘制奇数颜色块,后逐步往两边进行扩展,这个绘制过程十分简单,主要是如何通过振幅回调进行高度计算(demo中是粗步计算的,因为我也没找到合适计算方式。。。)
// 音频振幅,回调计算
_amplitudeSub = _audioRecorder
.onAmplitudeChanged(const Duration(milliseconds: 110))
.listen((amp) {
amplitude.value = amp;
amplitudeList.value = [
(50 + amp.current) / 50,
...amplitudeList.value,
];
onAmplitudeChanged?.call(amp);
});
/// 振幅动画
class AmpContent extends StatelessWidget {
const AmpContent({super.key});
@override
Widget build(BuildContext context) {
final polymerState = PolymerState.of(context);
return CustomPaint(
painter: WavePainter(polymerState.controller.amplitudeList),
);
}
}
其余的就不再赘述了,整个编码过程主要就是对UI交互的拆解。
github: sounds_message
完结撒花。🎉🎉🎉