这篇文章拆解一个常见但细节很多的交互:底部评论面板(可拖拽吸附)、键盘联动、回复态切换、@ 提及高亮、以及列表-回复的递归渲染。文末给出代码下载链接。
效果如下:
TL;DR
- 容器 :
showModalBottomSheet
+DraggableScrollableSheet
- 吸附联动 :
snapSizes
+ValueNotifier<bool>
实时锁定到 1.0,避免键盘遮挡 - 回复态管理 :
CommentInputController
(ChangeNotifier
)+ 轻量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 聚焦时自动拉满
在 CommentsPage
的 initState
中给输入框的 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
监听输入变化,只在有内容时出现 - 输入框
padding
、SafeArea
、viewInsets
协同,保证不被遮挡
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")