🎯 需求分析

我們要實現的功能包括:

  • 全局可刷新的評論列表(支持下拉刷新)
  • 底部固定“寫點評”按鈕(點擊提示功能暫不可用)
  • 長評論區域:若無數據則顯示佔位圖
  • 短評論默認收起,點擊展開
  • 每條評論支持彈出操作菜單(回覆、點贊、舉報、複製等)
  • 評論項包含:頭像、用户名、內容、回覆內容(如有)、時間、點贊數

⚠️ 注意:由於知乎 API 限制,本項目僅實現 UI 層面,不涉及真實網絡請求與交互邏輯。

🧱 UI 組件拆解

我們將整個頁面拆解為以下幾個核心 Widget:

  1. _buildList() —— 主列表容器(含 RefreshIndicator)
  2. _buildBottomBar() —— 底部“寫點評”按鈕
  3. _buildNull() —— 長評論空狀態佔位
  4. _buildTotal() —— 評論總數標題
  5. _buildExpansionTileForShort() —— 可展開的短評論區域
  6. _buildContentItem() —— 單條評論項(含回覆)
  7. _buildReply() —— 回覆內容子組件
  8. _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_commentsshort_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.builderRefreshIndicator 的配合使用
  • ExpansionTile 實現可摺疊區域
  • PopupMenuButton 創建上下文菜單
  • 複雜列表項的組件化拆分與複用
  • 空狀態與加載狀態的 UI 處理

雖然只是一個“半成品”,但它完整體現了 Flutter 的聲明式 UI 開發思想,是邁向複雜應用的重要一步。