🎯 需求分析
我們要實現的功能包括:
- 全局可刷新的評論列表(支持下拉刷新)
- 底部固定“寫點評”按鈕(點擊提示功能暫不可用)
- 長評論區域:若無數據則顯示佔位圖
- 短評論默認收起,點擊展開
- 每條評論支持彈出操作菜單(回覆、點贊、舉報、複製等)
- 評論項包含:頭像、用户名、內容、回覆內容(如有)、時間、點贊數
⚠️ 注意:由於知乎 API 限制,本項目僅實現 UI 層面,不涉及真實網絡請求與交互邏輯。
🧱 UI 組件拆解
我們將整個頁面拆解為以下幾個核心 Widget:
_buildList()—— 主列表容器(含 RefreshIndicator)_buildBottomBar()—— 底部“寫點評”按鈕_buildNull()—— 長評論空狀態佔位_buildTotal()—— 評論總數標題_buildExpansionTileForShort()—— 可展開的短評論區域_buildContentItem()—— 單條評論項(含回覆)_buildReply()—— 回覆內容子組件_buildPopItem()—— 彈出菜單包裝器
💻 核心代碼實現
1. 數據模型(簡化版)
class CommentModel {
final String author;
final String avatar;
final String content;
final int likes;
final int time; // Unix 時間戳
final ReplyToModel? replyTo;
CommentModel({
required this.author,
required this.avatar,
required this.content,
required this.likes,
required this.time,
this.replyTo,
});
}
class ReplyToModel {
final String author;
final String content;
ReplyToModel({required this.author, required this.content});
}
2. 主頁面結構
class CommentsPage extends StatefulWidget {
@override
_CommentsPageState createState() => _CommentsPageState();
}
class _CommentsPageState extends State<CommentsPage> {
late List<CommentModel> _datas;
late List<CommentModel> _shortComments;
int _shortCommentsLength = 0;
bool _isShowRetry = false;
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = GlobalKey();
final GlobalKey<ScaffoldState> _scaffoldStateKey = GlobalKey();
@override
void initState() {
super.initState();
_refreshData(); // 模擬加載
}
Future<void> _refreshData() async {
// 此處應調用 API,此處模擬數據
await Future.delayed(Duration(seconds: 1));
setState(() {
_datas = [
CommentModel(
author: "張三",
avatar: "",
content: "這個回答太有深度了!",
likes: 23,
time: 1704532620,
replyTo: ReplyToModel(author: "李四", content: "謝謝支持!"),
),
// ...更多模擬數據
];
_shortComments = _datas.take(3).toList();
_shortCommentsLength = _shortComments.length;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
key: _scaffoldStateKey,
appBar: AppBar(title: Text("評論")),
body: _buildList(),
bottomNavigationBar: _buildBottomBar(),
);
}
}
3. 構建主列表 _buildList()
Widget _buildList(BuildContext context) {
Widget content;
if (_datas == null || _datas.isEmpty) {
if (_isShowRetry) {
_isShowRetry = false;
content = CommonRetry.buildRetry(_refreshData);
} else {
content = ProgressDialog.buildProgressDialog();
}
} else {
content = ListView.builder(
physics: AlwaysScrollableScrollPhysics(),
itemCount: 2 + _datas.length, // 長評 + 短評標題 + 短評列表
itemBuilder: (context, index) {
if (index == 0) {
return _buildTotal("長評論");
} else if (index == 1) {
return _datas.isEmpty ? _buildNull() : _buildCommentItems(_datas);
} else if (index == 2) {
return _buildTotal("短評論");
} else {
return _buildExpansionTileForShort();
}
},
);
}
return RefreshIndicator(
key: _refreshIndicatorKey,
onRefresh: _refreshData,
child: content,
);
}
注:為簡化,這裏將長評直接展示;實際項目中可能需區分
long_comments和short_comments。
4. 單條評論項 _buildContentItem()
Widget _buildContentItem(CommentModel item) {
String time = DateUtil.formatDate(item.time * 1000); // 自定義時間格式化工具
return InkWell(
child: Padding(
padding: const EdgeInsets.only(left: 12.0, top: 12.0, right: 12.0),
child: Column(
children: [
Row(
children: [
CircleAvatar(
radius: 12.0,
backgroundImage: NetworkImage(
item.avatar.isEmpty ? Constant.defHeadimg : item.avatar,
),
),
Padding(
padding: const EdgeInsets.only(left: 12.0, right: 12.0),
child: Text(
item.author,
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w400),
),
),
Expanded(
child: Align(
alignment: Alignment.topRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Icon(Icons.thumb_up, color: Colors.grey, size: 18.0),
Text('(${item.likes})', style: TextStyle(color: Colors.grey)),
],
),
),
)
],
),
Padding(
padding: const EdgeInsets.only(left: 35.0, top: 4.0, bottom: 10.0),
child: Align(
alignment: Alignment.topLeft,
child: Text(item.content, style: TextStyle(fontSize: 14.0)),
),
),
_buildReply(item),
Padding(
padding: const EdgeInsets.only(top: 12.0, bottom: 8.0),
child: Align(
alignment: Alignment.topRight,
child: Text(time),
),
),
Divider(height: 1.0),
],
),
),
);
}
5. 回覆內容 _buildReply()
Widget _buildReply(CommentModel item) {
ReplyToModel? replyToModel = item.replyTo;
if (replyToModel != null) {
return Padding(
padding: const EdgeInsets.only(left: 35.0, bottom: 12.0),
child: Container(
alignment: Alignment.topLeft,
child: Text.rich(
TextSpan(
text: '//${replyToModel.author}:',
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w400),
children: [
TextSpan(
text: replyToModel.content,
style: TextStyle(fontSize: 14.0, color: Colors.grey[600]),
)
],
),
),
),
);
} else {
return Container(height: 0.0); // 避免佈局抖動
}
}
6. 彈出菜單 _buildPopItem()
class Choice {
final String choiceName;
Choice(this.choiceName);
}
final List<Choice> choices = <Choice>[
Choice('回覆'),
Choice('點贊'),
Choice('舉報'),
Choice('複製'),
];
Widget _buildPopItem(CommentModel item) {
return PopupMenuButton<Choice>(
padding: EdgeInsets.zero,
onSelected: (Choice choice) {
print(choice.choiceName);
// 實際可觸發 SnackBar 或 Dialog
},
child: _buildContentItem(item),
itemBuilder: (BuildContext context) {
return choices.map((Choice choice) {
return PopupMenuItem<Choice>(
value: choice,
child: Text(choice.choiceName),
);
}).toList();
},
);
}
7. 短評論摺疊區域
Widget _buildExpansionTileForShort() {
return ExpansionTile(
title: Text(
'$_shortCommentsLength 條短評論',
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w500),
),
children: _shortComments.map((model) => _buildPopItem(model)).toList(),
);
}
8. 底部“寫點評”按鈕
Widget _buildBottomBar() {
return BottomAppBar(
child: InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('該功能暫時無法完成')),
);
},
child: Container(
height: 40.0,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.edit, color: Colors.blue, size: 20.0),
Text('寫點評', style: TextStyle(fontSize: 16.0, color: Colors.blue)),
],
),
),
),
),
);
}
9. 長評論空狀態
Widget _buildNull() {
return Container(
color: Colors.grey[100],
height: 300.0,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.desktop_mac, color: Colors.blue[200], size: 100.0),
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Text(
'深度長評虛位以待',
style: TextStyle(color: Colors.blue[400]),
),
)
],
),
),
);
}
✅ 總結
通過本次實戰,我們掌握了:
- Flutter 中
ListView.builder與RefreshIndicator的配合使用 ExpansionTile實現可摺疊區域PopupMenuButton創建上下文菜單- 複雜列表項的組件化拆分與複用
- 空狀態與加載狀態的 UI 處理
雖然只是一個“半成品”,但它完整體現了 Flutter 的聲明式 UI 開發思想,是邁向複雜應用的重要一步。