知识问答是教育百科App中最有趣的功能之一,用户可以通过答题来检验自己的知识储备。老实说,做这个模块的时候我挺兴奋的,因为它不像其他页面那样只是展示数据,而是有真正的交互——选择难度、选择分类、答题、看成绩,整个流程下来还挺有成就感的。

这个页面需要展示题目分类、难度选择,还要有快速开始的入口。下面来看看具体怎么实现。


请添加图片描述

状态变量的设计

问答页面需要管理的状态比较多:

class QuizTab extends StatefulWidget {
  const QuizTab({super.key});

  
  State<QuizTab> createState() => _QuizTabState();
}

class _QuizTabState extends State<QuizTab> {
  List<dynamic> _categories = [];
  bool _isLoading = true;
  int _totalScore = 0;
  int _totalQuizzes = 0;

  
  void initState() {
    super.initState();
    _loadCategories();
  }
}

_categories存储从API获取的题目分类,_isLoading控制加载状态的显示。_totalScore_totalQuizzes用于统计用户的答题情况,虽然目前这两个值还没有持久化存储,但预留着以后用。

为什么要在这里加载分类数据? 因为问答页面需要展示热门分类,让用户可以快速选择感兴趣的领域。如果不加载分类数据,用户只能用"快速开始"功能,体验会差很多。


加载题目分类

从Open Trivia API获取所有可用的题目分类:

Future<void> _loadCategories() async {
  try {
    final categories = await ApiService.getTriviaCategories();
    if (mounted) {
      setState(() {
        _categories = categories;
        _isLoading = false;
      });
    }
  } catch (e) {
    print('loadCategories error: $e');
    if (mounted) {
      setState(() {
        _categories = [];
        _isLoading = false;
      });
    }
  }
}

这个API返回的数据格式是这样的:

{
  "trivia_categories": [
    {"id": 9, "name": "General Knowledge"},
    {"id": 10, "name": "Entertainment: Books"},
    ...
  ]
}

每个分类有一个id和name,id用于后续请求特定分类的题目。

关于错误处理: 即使加载失败,也要把_isLoading设为false,否则页面会一直显示加载中。加载失败时_categories设为空数组,页面会显示"暂无分类"的提示,总比卡在加载状态好。


页面整体布局

问答页面用ListView作为主体,包含快速开始卡片、难度选择和分类列表:


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('知识问答'),
      actions: [
        IconButton(
          icon: const Icon(Icons.leaderboard),
          onPressed: () => _showStats(context),
        ),
      ],
    ),

AppBar右侧有一个排行榜按钮,点击可以查看答题统计。用Icons.leaderboard这个图标,一看就知道是排行榜或统计相关的功能。

    body: _isLoading
        ? const Center(child: CircularProgressIndicator())
        : RefreshIndicator(
            onRefresh: _loadCategories,
            child: ListView(
              padding: const EdgeInsets.all(16),
              children: [
                _buildQuickStart(context),
                const SizedBox(height: 24),
                _buildDifficultySection(context),
                const SizedBox(height: 24),
                _buildCategoriesSection(context),
                const SizedBox(height: 100),
              ],
            ),
          ),
  );
}

RefreshIndicator让用户可以下拉刷新分类数据,虽然分类数据不太会变,但有这个功能总比没有好。底部留出100的空间是为了避免被底部导航栏遮挡。


快速开始卡片

快速开始是最醒目的入口,用渐变背景突出显示:

Widget _buildQuickStart(BuildContext context) {
  return Card(
    clipBehavior: Clip.antiAlias,
    child: Container(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          colors: [
            Theme.of(context).colorScheme.primary,
            Theme.of(context).colorScheme.secondary,
          ],
        ),
      ),

clipBehavior: Clip.antiAlias这个属性很重要,它让渐变背景能正确显示Card的圆角。如果不加这个,渐变色会超出圆角边界,很难看。

为什么用主题色做渐变?primarysecondary作为渐变色,好处是会自动适配用户选择的主题。如果用户切换了主题色,这个卡片的颜色也会跟着变,保持整体风格的一致性。

      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                const Icon(Icons.bolt, color: Colors.white, size: 28),
                const SizedBox(width: 8),
                Text(
                  '快速开始',
                  style: Theme.of(context).textTheme.titleLarge?.copyWith(
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),

闪电图标(Icons.bolt)暗示"快速"的含义,和"快速开始"的文字呼应。图标用28的尺寸,比标题文字稍大一点,视觉上更平衡。

            const SizedBox(height: 8),
            const Text(
              '随机10道题目,测试你的知识储备!',
              style: TextStyle(color: Colors.white70),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => _startQuiz(context),
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.white,
                foregroundColor: Theme.of(context).colorScheme.primary,
              ),
              child: const Text('开始答题'),
            ),
          ],
        ),
      ),
    ),
  );
}

按钮用白色背景和主题色文字,在渐变背景上非常醒目。Colors.white70是70%透明度的白色,用于副标题,不会太抢眼。


难度选择

提供三种难度供用户选择:

Widget _buildDifficultySection(BuildContext context) {
  final difficulties = [
    {'name': '简单', 'value': 'easy', 'color': Colors.green, 'icon': Icons.sentiment_satisfied},
    {'name': '中等', 'value': 'medium', 'color': Colors.orange, 'icon': Icons.sentiment_neutral},
    {'name': '困难', 'value': 'hard', 'color': Colors.red, 'icon': Icons.sentiment_very_dissatisfied},
  ];

用Map来组织难度数据,包括显示名称、API参数值、颜色和图标。图标用的是表情系列:笑脸表示简单,中性脸表示中等,难过脸表示困难。这种设计挺直观的,一看就懂。

  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text('选择难度', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
      const SizedBox(height: 12),
      Row(
        children: difficulties.map((d) => Expanded(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 4),
            child: Card(
              child: InkWell(
                onTap: () => _startQuiz(context, difficulty: d['value'] as String),
                borderRadius: BorderRadius.circular(12),
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    children: [
                      Icon(d['icon'] as IconData, color: d['color'] as Color, size: 32),
                      const SizedBox(height: 8),
                      Text(d['name'] as String, style: TextStyle(color: d['color'] as Color, fontWeight: FontWeight.bold)),
                    ],
                  ),
                ),
              ),
            ),
          ),
        )).toList(),
      ),
    ],
  );
}

三个难度卡片用Row和Expanded横向排列,每个卡片等宽。Padding(padding: const EdgeInsets.symmetric(horizontal: 4))给卡片之间留出8像素的间距(左右各4像素)。

颜色的选择:

  • 绿色表示简单,给人轻松、安全的感觉
  • 橙色表示中等,有一定挑战但不至于太难
  • 红色表示困难,警示用户这会很有挑战性

这种颜色编码是很常见的设计模式,用户不需要看文字就能大概判断难度。


分类网格展示

热门分类使用GridView展示:

Widget _buildCategoriesSection(BuildContext context) {
  final popularCategories = _categories.take(8).toList();

  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text('热门分类', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
          TextButton(
            onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const QuizCategoriesScreen())),
            child: const Text('查看全部'),
          ),
        ],
      ),

_categories.take(8)只取前8个分类作为热门分类展示。为什么是8个?因为GridView每行2个,8个刚好4行,不会太多也不会太少。

      const SizedBox(height: 12),
      GridView.builder(
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          childAspectRatio: 2.5,
          crossAxisSpacing: 8,
          mainAxisSpacing: 8,
        ),
        itemCount: popularCategories.length,
        itemBuilder: (context, index) {
          final category = popularCategories[index];
          return Card(
            child: InkWell(
              onTap: () => _startQuiz(context, category: category['id'].toString()),
              borderRadius: BorderRadius.circular(12),
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Row(
                  children: [
                    Icon(_getCategoryIcon(category['name']), color: Theme.of(context).colorScheme.primary),
                    const SizedBox(width: 8),
                    Expanded(
                      child: Text(
                        _translateCategory(category['name']),
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                        style: const TextStyle(fontSize: 12),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          );
        },
      ),
    ],
  );
}

GridView的配置说明:

  • shrinkWrap: true — 让GridView根据内容自适应高度,而不是占满剩余空间
  • physics: const NeverScrollableScrollPhysics() — 禁用GridView自身的滚动,让外层ListView统一处理滚动
  • childAspectRatio: 2.5 — 宽高比为2.5,意味着每个格子的宽度是高度的2.5倍,是扁平的矩形

这两个属性配合使用,可以把GridView嵌套在ListView里而不会有滚动冲突。


分类图标映射

根据分类名称返回对应的图标:

IconData _getCategoryIcon(String name) {
  if (name.contains('Science')) return Icons.science;
  if (name.contains('History')) return Icons.history_edu;
  if (name.contains('Geography')) return Icons.public;
  if (name.contains('Art')) return Icons.palette;
  if (name.contains('Sports')) return Icons.sports;
  if (name.contains('Music')) return Icons.music_note;
  if (name.contains('Film')) return Icons.movie;
  if (name.contains('Book')) return Icons.menu_book;
  if (name.contains('Computer')) return Icons.computer;
  if (name.contains('Math')) return Icons.calculate;
  if (name.contains('Animal')) return Icons.pets;
  if (name.contains('Vehicle')) return Icons.directions_car;
  return Icons.quiz;
}

通过检查分类名称中的关键词来匹配图标。比如名称里包含"Science"就用科学图标,包含"History"就用历史图标。如果都不匹配,就用默认的quiz图标。

为什么用contains而不是精确匹配? 因为API返回的分类名称格式不太统一,有的是"Science & Nature",有的是"Science: Computers"。用contains可以更灵活地匹配。


分类名称翻译

将英文分类名翻译成中文:

String _translateCategory(String name) {
  final translations = {
    'General Knowledge': '常识',
    'Entertainment: Books': '图书',
    'Entertainment: Film': '电影',
    'Entertainment: Music': '音乐',
    'Science & Nature': '科学与自然',
    'Science: Computers': '计算机科学',
    'Science: Mathematics': '数学',
    'Sports': '体育',
    'Geography': '地理',
    'History': '历史',
    'Art': '艺术',
    'Animals': '动物',
  };
  return translations[name] ?? name;
}

用Map存储翻译对照表,如果没有找到对应的翻译就返回原文。这种做法比写一堆if-else简洁多了,而且方便维护——要加新翻译只需要往Map里加一项。


开始答题

点击任何入口都会调用这个方法:

void _startQuiz(BuildContext context, {String? category, String? difficulty}) {
  Navigator.push(
    context,
    MaterialPageRoute(
      builder: (_) => QuizScreen(category: category, difficulty: difficulty),
    ),
  );
}

categorydifficulty都是可选参数。快速开始时两个都不传,选择难度时只传difficulty,选择分类时只传category。QuizScreen会根据传入的参数请求对应的题目。


答题统计弹窗

点击排行榜按钮显示统计信息:

void _showStats(BuildContext context) {
  showModalBottomSheet(
    context: context,
    builder: (context) => Container(
      padding: const EdgeInsets.all(24),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Icon(Icons.emoji_events, size: 64, color: Colors.amber),
          const SizedBox(height: 16),
          Text('答题统计', style: Theme.of(context).textTheme.titleLarge),
          const SizedBox(height: 24),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildStatItem('总答题数', '$_totalQuizzes'),
              _buildStatItem('总得分', '$_totalScore'),
              _buildStatItem('正确率', _totalQuizzes > 0 ? '${(_totalScore / _totalQuizzes * 10).toStringAsFixed(1)}%' : '0%'),
            ],
          ),
          const SizedBox(height: 24),
        ],
      ),
    ),
  );
}

showModalBottomSheet从底部弹出统计面板,比AlertDialog更现代一些。mainAxisSize: MainAxisSize.min让弹窗高度自适应内容,不会占满整个屏幕。

顶部放一个金色的奖杯图标,增加一点趣味性。正确率的计算要注意除零的情况,如果还没答过题就显示0%。


小结

问答页面的设计重点是让用户能快速开始答题,同时提供足够的选择。快速开始卡片用醒目的渐变色吸引注意,难度选择用颜色和表情直观区分,分类网格让用户可以按兴趣选择。这种层次分明的布局让页面既丰富又不杂乱。

下一篇我们来看收藏功能的实现,了解如何管理用户的收藏数据。


本文是Flutter for OpenHarmony教育百科实战系列的第三篇。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐