Flutter 实现「可拖拽评论面板 + 回复输入框 + @高亮」的完整方案

这篇文章拆解一个常见但细节很多的交互:底部评论面板(可拖拽吸附)、键盘联动、回复态切换、@ 提及高亮、以及列表-回复的递归渲染。文末给出代码下载链接。

效果如下:


TL;DR

  • 容器showModalBottomSheet + DraggableScrollableSheet
  • 吸附联动snapSizes + ValueNotifier<bool> 实时锁定到 1.0,避免键盘遮挡
  • 回复态管理CommentInputControllerChangeNotifier)+ 轻量 InheritedWidget 作"服务定位器"
  • 状态Bloc 只管数据装载,UI 用 ListenableBuilder 精准刷新
  • @ 提及高亮 :Unicode 友好正则 + RichText 拼接
  • 性能SliverList.builder 递归渲染回复、cacheExtent 预加载、ResizeImage 限制头像缓存尺寸

1. 目标与页面结构

我们要实现一个自底部弹出的评论面板 ,既能以 .6 / 1.0 两档吸附拖拽,又要在进入"回复某人"并弹出键盘时 自动全屏并禁止退回 .6,防止输入框被遮挡。

页面结构总览(简化):

scss 复制代码
MyHomePage
 └─ showModalBottomSheet
     └─ DraggableScrollableSheet (snap: [0.6, 1.0] 或只 [1.0])
         └─ CommentsPage (Stateful)
             ├─ CommentInheritedWidget(CommentInputController)
             ├─ CommentsView
             │   ├─ AppBar("评论")
             │   ├─ CommentsListView(SliverList.builder 递归)
             │   └─ bottomNavigationBar: CommentTextField
             └─ FocusNode 监听 -> 聚焦时自动 animateTo(1.0)

2. 弹出评论面板 + 可拖拽吸附

showModalBottomSheet + DraggableScrollableSheet 拿到原生手势阻尼吸附 体验。关键是运行时可调整 snapSizes :当需要锁满屏时,改为只允许 [1.0]

dart 复制代码
void showCommentList(BuildContext context) {
  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    useSafeArea: true,
    useRootNavigator: true,
    showDragHandle: true,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.only(
        topLeft: Radius.circular(16),
        topRight: Radius.circular(16),
      ),
    ),
    clipBehavior: Clip.antiAliasWithSaveLayer,
    builder: (_) {
      final controller = DraggableScrollableController();
      final snapLock = ValueNotifier<bool>(false); // true -> 只允许 1.0

      return ValueListenableBuilder<bool>(
        valueListenable: snapLock,
        builder: (context, locked, __) {
          return DraggableScrollableSheet(
            controller: controller,
            expand: false,
            snap: true,
            snapSizes: locked ? const [1.0] : const [0.6, 1.0],
            initialChildSize: 1.0,
            minChildSize: locked ? 1.0 : 0.4,
            builder: (context, scrollController) {
              return CommentsPage(
                scrollController: scrollController,
                draggableScrollableController: controller,
                snapLock: snapLock,
              );
            },
          );
        },
      );
    },
  );
}

这里把 snapLock 作为"运行时开关",后面会通过键盘可见 + 正在回复来动态控制它。


3. 键盘联动:聚焦即全屏 & 安全区抬升

3.1 聚焦时自动拉满

CommentsPageinitState 中给输入框的 FocusNode 加监听:有焦点就 animateTo(1.0)

dart 复制代码
void _commentFocusNodeListener() {
  if (_commentFocusNode.hasFocus) {
    if (!widget.draggableScrollableController.isAttached) return;
    if (widget.draggableScrollableController.size == 1) return;
    widget.draggableScrollableController.animateTo(
      1.0,
      duration: const Duration(milliseconds: 250),
      curve: Curves.ease,
    );
  }
}

3.2 键盘打开 + 正在回复 => 锁定吸附

build 里读取键盘高度与回复态,帧末 更新 snapLock

dart 复制代码
@override
Widget build(BuildContext context) {
  final isReplying = _commentInputController.isReplying;
  final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;

  WidgetsBinding.instance.addPostFrameCallback((_) {
    final needLockFull = isKeyboardVisible && isReplying;
    if (widget.snapLock?.value != needLockFull) {
      widget.snapLock?.value = needLockFull; // true 时 snapSizes = [1.0]
    }
  });

  // ...
}

3.3 输入区跟随键盘抬升

CommentTextField 外层用 Padding(bottom: MediaQuery.viewInsetsOf(context).bottom),并额外再包一层 SafeArea,确保 iOS Home Indicator 也处理到:

dart 复制代码
return Padding(
  padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
  child: SafeArea(
    top: false,
    child: /* 输入区内容 */,
  ),
);

4. 回复态:控制器 + InheritedWidget 的组合

目标 :页面各处都能拿到"回复态"(是否在回复、回复谁、输入框控制、emoji 追加等),且只在必要处刷新

  • CommentInputController:继承 ChangeNotifier,管理

    • isReplying / replyingUsername / replyingCommentId
    • commentFocusNode / commentTextController
    • setReplyingTo(...)onEmojiTap(...)clear()
  • CommentInheritedWidget:轻量"服务定位器",只暴露 controller 实例 。注意它的 updateShouldNotify 只在实例更换时返回 true,不会因为内部字段变化而触发整棵树 rebuild;真正刷新交给 ListenableBuilder

示例:点击"回复"时,设置回复态并聚焦输入框:

dart 复制代码
GestureDetector(
  onTap: () {
    final c = CommentsPage.of(context).commentInputController;
    c.setReplyingTo(commentId: comment.id, username: comment.author.username);
  },
  child: Text('回复', /* ... */),
)

输入栏顶部的"正在回复 @xxx"提示,用 ListenableBuilder 精准刷新:

dart 复制代码
ListenableBuilder(
  listenable: commentInputController,
  builder: (context, _) {
    return Offstage(
      offstage: !commentInputController.isReplying,
      child: ListTile(
        tileColor: const Color(0xFFE0E0E0),
        title: Text('回复:${commentInputController.replyingUsername ?? "未知"}'),
        trailing: GestureDetector(
          onTap: commentInputController.clear,
          child: const Icon(Icons.cancel, color: Colors.grey),
        ),
      ),
    );
  },
),

5. 列表与递归回复

评论是树状结构。这里用组件递归简化渲染逻辑:

dart 复制代码
class CommentView extends StatelessWidget {
  final Comment comment;
  final bool isReplied; // 是否为子回复

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        UserComment(comment: comment, isReplied: isReplied),
        if (!isReplied) RepliedComments(comment: comment), // 仅根评论渲染子回复
      ],
    );
  }
}

class RepliedComments extends StatelessWidget {
  final Comment comment;

  @override
  Widget build(BuildContext context) {
    final list = comment.repliedList;
    if (list == null) return const SizedBox.shrink();
    return Column(
      children: list
          .map((c) => CommentView(comment: c, isReplied: true))
          .toList(),
    );
  }
}

性能细节

  • SliverList.builder + 合理的 cacheExtent(根据你单项高度估算)
  • 头像用 ResizeImage.resizeIfNeeded 传递 cacheWidth/Height,减少内存压力

6. @ 提及的 Unicode 友好高亮

在中文环境下,@ 前后未必有空格,所以先把「字符 + @」打散 ,再按空格切分;之后用正则拾取所有 @xxx 并在 RichText 中着色。

dart 复制代码
List<String> getAllMentions(String text) {
  final re = RegExp(r'@[a-zA-Z0-9\p{L}\p{N}_.-]+', unicode: true);
  return [for (final m in re.allMatches(text)) m.group(0)!];
}

String cleanText(String text) {
  // 把"字@"拆成"字 @",确保后续按空格 split 时能识别
  return text.replaceAllMapped(
    RegExp(r'[\w\u4e00-\u9fa5]@+', unicode: true),
    (m) => "${m[0]!.split('').join(' ')}",
  );
}

RichText buildHighlightedText(Comment comment, BuildContext context) {
  final msg = cleanText(comment.content);
  final mentions = getAllMentions(msg);

  final spans = <TextSpan>[];
  for (final token in msg.split(' ')) {
    final isMention = mentions.contains(token) && token.startsWith('@');
    spans.add(TextSpan(
      text: '$token ',
      style: isMention
          ? Theme.of(context).textTheme.bodySmall?.copyWith(
              color: const Color(0xFF64B5F6),
              fontWeight: FontWeight.w700,
            )
          : Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.black),
    ));
  }
  return RichText(text: TextSpan(children: spans));
}

这种做法对中英文用户名都有效:使用了 \p{L}\p{N} 以支持 Unicode 字母数字。


7. 输入区:Emoji、光标、发布按钮

  • Emoji 行使用 onEmojiTap直接在 TextEditingController 末尾追加并移动光标
  • "发布"按钮用 ListenableBuilder 监听输入变化,只在有内容时出现
  • 输入框 paddingSafeAreaviewInsets 协同,保证不被遮挡
dart 复制代码
ListenableBuilder(
  listenable: commentInputController.commentTextController,
  builder: (context, _) {
    final hasText = commentInputController.commentTextController.text.trim().isNotEmpty;
    return hasText
        ? GestureDetector(
            onTap: () {
              // TODO: 发送评论
            },
            child: Text('发布', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: const Color(0xFF3898EC))),
          )
        : const SizedBox.shrink();
  },
),

8. 轻量 BLoC:数据与 UI 解耦

CommentsBloc 在示例中只负责"加载本地假数据",真实项目中替换为接口即可:

dart 复制代码
class CommentsBloc extends Bloc<CommentsEvent, CommentsState> {
  CommentsBloc() : super(const CommentsState.initial()) {
    on<CommentsListRequested>(_onRequested);
  }

  Future<void> _onRequested(CommentsListRequested e, Emitter<CommentsState> emit) async {
    // TODO: 请求后端
    emit(state.copyWith(comments: Comment.commentList));
  }
}

UI 侧用 context.select 精准取 comments,避免无关刷新:

dart 复制代码
final comments = context.select((CommentsBloc b) => b.state.comments);

9. 主题与系统栏

  • 基于 FlexColorScheme 启用 M3,统一 textTheme / iconTheme / AppBar / BottomSheet
  • AnnotatedRegion<SystemUiOverlayStyle> 自定义 iOS 风格状态栏样式

这些内容保证面板全屏时系统栏也和设计一致,不会出现字色看不清的问题。


10. 常见坑与修正

✅ 10.1 dispose 的监听清理顺序

你可能见过类似写法:

dart 复制代码
@override
void dispose() {
  _commentInputController..commentFocusNode..removeListener(_commentFocusNodeListener)..dispose();
  super.dispose();
}

这实际上会调用到 ChangeNotifier.removeListener没有把监听从 FocusNode 上移除,可能导致泄漏或异常。

正确做法

dart 复制代码
@override
void dispose() {
  _commentFocusNode.removeListener(_commentFocusNodeListener);
  _commentInputController.dispose(); // 如果内部统一释放 TextEditingController/FocusNode
  super.dispose();
}

如果 FocusNode/TextEditingController 的释放逻辑放在 CommentInputController.dispose() 里,就不要在外层重复 dispose();关键是先从 FocusNode 移除监听

✅ 10.2 InheritedWidget.of 的时机

不要在 CommentInheritedWidget 挂载前 调用 CommentsPage.of(context)。把调用放在其子树里,或直接使用上层持有的 controller。

✅ 10.3 锁吸附的判定

  • 只在 isKeyboardVisible && isReplying 时锁定 [1.0]
  • 退出回复或收起键盘后,恢复 [0.6, 1.0],让用户能继续拖拽

✅ 10.4 渐进增强的 M3 属性

  • ListTile.titleAlignment 等 M3 属性需要 useMaterial3: true(已开启)
  • 不同 Flutter 版本对 DraggableScrollableSheet.snapSizes 支持略有差异,注意 SDK 版本

11. 可扩展清单

  • @ 提及候选 :输入 @ 后弹出用户名候选面板(Overlay + CompositedTransformFollower
  • 滚动到被回复:点击"xx 回复了你",滚到父评论位置
  • 长按操作:复制、举报、删除等
  • 发布态 UX:loading、失败重试、本地乐观更新
  • 图片/表情包:输入区拓展到图片选择、表情面板
  • 无障碍 :为可点击文本添加 semanticsLabel、扩大 hitTest 区域(你已经给了 behavior: HitTestBehavior.opaque,很好)

12. 小结

这套实现的关键是把"交互联动"拆分清楚:

  • 拖拽吸附snapSizes 运行时切换
  • 键盘FocusNode 监听 + viewInsets 抬升
  • 回复态ChangeNotifier 管理 + InheritedWidget 分发
  • 刷新ListenableBuilder 精准驱动
  • 文本:Unicode 友好 @ 提及高亮

把这些拼起来,就能在不引入大型状态管理框架的情况下,做出体验细腻、性能友好的评论面板。


完整代码\] [github.com/wutao23yzd/...](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fwutao23yzd%2FuikitCombine "https://github.com/wutao23yzd/uikitCombine")

相关推荐
雨声不在2 小时前
使用android studio分析cpu开销
android·ide·android studio
程序leo源3 小时前
Linux_基础指令(二)
android·linux·运维·服务器·青少年编程
雨白5 小时前
Android 两种拖拽 API 详解:ViewDragHelper 和 OnDragListener 的原理与区别
android
元亓亓亓5 小时前
JavaWeb--day3--Ajax&Element&路由&打包部署
android·ajax·okhttp
居然是阿宋5 小时前
Android XML属性与Jetpack Compose的对应关系(控件基础属性篇)
android
GoatJun6 小时前
Android ScrollView嵌套RecyclerView 导致RecyclerView数据展示不全问题
android
潜龙95276 小时前
第6.2节 Android Agent开发<二>
android·python·覆盖率数据
网安Ruler7 小时前
代码审计-PHP专题&原生开发&SQL注入&1day分析构造&正则搜索&语句执行监控&功能定位
android