flutter 仿新版微信语音发送交互

3,352 阅读8分钟

闲聊

仿微信语音发送的交互最早还是前几年做iOS开发的时候做过,之后就没有再接触了。再加上平时自己聊天都是文字输入,语音发送这块一直都没注意有更新。前段时间突发奇想就想重新写个flutter版本的交互,不看不知道,嚯,好家伙,直呼好家伙,UI可是大变样了。

断断续续在弄这个,一直也懒得发,但还是写个文章存个档,当做是开发记录吧。

以上都是废话,纯属内心旁白。

正文

先看一下微信发送语音的交互截图:

e26e606b10a2be5f625103f4b298a10.jpg061952b242f738270d3b4e1ed72caee.jpg
c4a7fa980337b24cd5481ca9d517220.jpg5de973c7cc65dac32a8bf7d561a6de4.jpg
从截图分析出我们需要实现的部分:
  • 聊天列表位置偏移,并且列表不是置顶状态,需要将列表置顶;

  • 隔离于聊天列表的弹框,所有的语音输入交互都是在这里完成的;

  • 语音录制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,所以都是肉眼 (大)仿(致)(猜)照抄的,尽量使用的 IconCanvas 来替换,这里略过后续细说;

  • 语音录制:这里选取的是录音插件 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 手势交互事件:

  1. onLongPress 长按显示录音UI,并开始录音;
  2. onLongPressEnd 结束长按,关闭录音,并针对当前触点位置的 SoundsMessageStatus 完成业务逻辑(取消、发送语音、语音转文字);
  3. 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);
    }
  },

图例如下:

1713497317739.png

二、弹框的绘制

准备工作:给整个弹框添加一个UI渲染的各项配置 RecordingMaskOverlayData 以及继承自 InheritedWidgetPolymerState,用于整个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;
  ...
}
  1. 绘制弹框背景 整个页面使用 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 为操作区域的布局元素。

  1. 绘制操作区域

底部弧形区域:使用 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

完结撒花。🎉🎉🎉