当前位置:首页 > 随笔 > 正文内容

基于 Flutter for OpenHarmony 构建手语教学应用的学习中心模块

访客 随笔 2026年8月23日 1
学习中心界面UI

学习中心作为手语教学应用的核心枢纽,承担着展示学习进度、课程分类导航以及历史记录追溯等关键职责。本文将详细解析如何从零构建一个高内聚、低耦合的学习看板,涵盖进度可视化、网格化分类、横向瀑布流以及多维条件过滤等工程实践。

视图骨架与全局布局

为了应对后续可能增加的复杂交互状态(如动态筛选),我们将主视图定义为 StatefulWidget。全局采用 CustomScrollView 替代传统的 SingleChildScrollView 嵌套,以获得更优的滑动性能和统一的吸顶效果。


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

  @override
  State<StudyHubPage> createState() => _StudyHubPageState();
}

class _StudyHubPageState extends State<StudyHubPage> {
  @override
  Widget build(BuildContext context) {
    final screenSize = MediaQuery.sizeOf(context);
    final horizontalPadding = screenSize.width * 0.04;

    return Scaffold(
      backgroundColor: const Color(0xFFF8F9FA),
      appBar: AppBar(
        title: const Text('探索与学习'),
        elevation: 0,
        actions: [
          IconButton(
            icon: const Icon(Icons.tune_rounded),
            onPressed: _presentFilterSheet,
          ),
        ],
      ),
      body: CustomScrollView(
        slivers: [
          SliverToBoxAdapter(child: _buildRoadmapCard(screenSize)),
          SliverPadding(
            padding: EdgeInsets.all(horizontalPadding),
            sliver: _buildCategoryMatrix(),
          ),
          SliverToBoxAdapter(child: _buildRecentActivity(screenSize)),
          SliverPadding(
            padding: EdgeInsets.all(horizontalPadding),
            sliver: _buildCourseCatalog(),
          ),
        ],
      ),
    );
  }
}

通过引入 MediaQuery 获取屏幕物理尺寸,并基于比例计算边距,从而彻底摆脱对第三方屏幕适配插件的依赖。

进度可视化:学习路线图

顶部的学习路线图需要具备强烈的视觉引导性。我们采用深色渐变背景配合高亮节点来渲染当前的学习阶段。节点状态通过 Dart 3 的枚举和模式匹配来驱动 UI 变更。


enum NodeStatus { locked, active, completed }

Widget _buildRoadmapCard(Size screenSize) {
  return Container(
    margin: EdgeInsets.all(screenSize.width * 0.04),
    padding: EdgeInsets.all(screenSize.width * 0.05),
    decoration: BoxDecoration(
      gradient: const LinearGradient(
        colors: [Color(0xFF1E3A8A), Color(0xFF3B82F6)],
        begin: Alignment.topLeft,
        end: Alignment.bottomRight,
      ),
      borderRadius: BorderRadius.circular(20),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text('专属学习路径', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
        const SizedBox(height: 8),
        const Text('系统化掌握手语表达技巧', style: TextStyle(color: Colors.white70)),
        const SizedBox(height: 24),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            _RoadmapNode(label: '入门', status: NodeStatus.completed),
            _Connector(isActive: true),
            _RoadmapNode(label: '基础', status: NodeStatus.active),
            _Connector(isActive: false),
            _RoadmapNode(label: '进阶', status: NodeStatus.locked),
            _Connector(isActive: false),
            _RoadmapNode(label: '熟练', status: NodeStatus.locked),
          ],
        ),
        const SizedBox(height: 24),
        SizedBox(
          width: double.infinity,
          child: ElevatedButton(
            onPressed: () {},
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.white,
              foregroundColor: const Color(0xFF1E3A8A),
              shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
            ),
            child: const Text('继续当前课程'),
          ),
        ),
      ],
    ),
  );
}

将单个节点提取为独立的无状态组件 _RoadmapNode,利用 switch 表达式决定图标和背景色的渲染逻辑,保证了组件的高内聚。


class _RoadmapNode extends StatelessWidget {
  final String label;
  final NodeStatus status;

  const _RoadmapNode({required this.label, required this.status});

  @override
  Widget build(BuildContext context) {
    final (icon, bgColor, borderColor) = switch (status) {
      NodeStatus.completed => (Icons.check_rounded, Colors.white, Colors.transparent),
      NodeStatus.active => (Icons.play_arrow_rounded, Colors.white, Colors.amber),
      NodeStatus.locked => (Icons.lock_rounded, Colors.white24, Colors.transparent),
    };

    return Column(
      children: [
        Container(
          width: 40,
          height: 40,
          decoration: BoxDecoration(
            color: bgColor,
            shape: BoxShape.circle,
            border: Border.all(color: borderColor, width: 2.5),
          ),
          child: Icon(icon, color: status == NodeStatus.locked ? Colors.white54 : const Color(0xFF1E3A8A)),
        ),
        const SizedBox(height: 6),
        Text(label, style: const TextStyle(color: Colors.white, fontSize: 12)),
      ],
    );
  }
}

class _Connector extends StatelessWidget {
  final bool isActive;
  const _Connector({required this.isActive});

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Container(
        height: 2,
        margin: const EdgeInsets.only(bottom: 20),
        color: isActive ? Colors.white : Colors.white24,
      ),
    );
  }
}

课程分类矩阵

分类区域采用数据驱动的方式构建。定义结构化的数据模型,并使用 SliverGrid 实现高性能的网格布局。


class CategoryEntity {
  final String title;
  final IconData icon;
  final int courseCount;
  const CategoryEntity(this.title, this.icon, this.courseCount);
}

SliverGrid _buildCategoryMatrix() {
  final categories = [
    const CategoryEntity('日常问候', Icons.waving_hand, 15),
    const CategoryEntity('数字表达', Icons.pin, 20),
    const CategoryEntity('情感交流', Icons.favorite_border, 25),
    const CategoryEntity('家庭成员', Icons.people_outline, 12),
    const CategoryEntity('紧急求助', Icons.error_outline, 8),
    const CategoryEntity('时间日期', Icons.schedule, 18),
    const CategoryEntity('颜色认知', Icons.palette_outlined, 14),
    const CategoryEntity('职场用语', Icons.work_outline, 22),
  ];

  return SliverGrid(
    gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 4,
      mainAxisSpacing: 16,
      crossAxisSpacing: 12,
      childAspectRatio: 0.85,
    ),
    delegate: SliverChildBuilderDelegate(
      (context, index) {
        final item = categories[index];
        return _CategoryTile(entity: item);
      },
      childCount: categories.length,
    ),
  );
}

每个分类卡片封装了点击事件与视觉呈现,文本超长截断处理保证了布局的稳定性。


class _CategoryTile extends StatelessWidget {
  final CategoryEntity entity;
  const _CategoryTile({required this.entity});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {/* 路由跳转逻辑 */},
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: const Color(0xFF3B82F6).withOpacity(0.1),
              borderRadius: BorderRadius.circular(16),
            ),
            child: Icon(entity.icon, color: const Color(0xFF3B82F6), size: 28),
          ),
          const SizedBox(height: 8),
          Text(
            entity.title,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
          ),
        ],
      ),
    );
  }
}

历史记录横向轮播

为了在有限的垂直空间内展示更多内容,最近学习模块使用了带有分页物理效果的横向 ListView


Widget _buildRecentActivity(Size screenSize) {
  final cardWidth = screenSize.width * 0.4;
  
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Padding(
        padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.04),
        child: const Text('最近学习', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
      ),
      const SizedBox(height: 12),
      SizedBox(
        height: 120,
        child: ListView.builder(
          scrollDirection: Axis.horizontal,
          physics: const BouncingScrollPhysics(),
          padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.04),
          itemCount: 6,
          itemBuilder: (context, index) {
            return Container(
              width: cardWidth,
              margin: const EdgeInsets.only(right: 12),
              child: Card(
                elevation: 2,
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
                child: Center(child: Text('课程卡片 ${index + 1}')),
              ),
            );
          },
        ),
      ),
    ],
  );
}

全局课程目录与状态映射

底部列表通过状态管理库获取全量课程数据。在渲染难度标签时,废弃了冗长的 if-elseswitch 语句,转而使用 Map 进行颜色字典映射,提升了代码的可读性与扩展性。


final _difficultyColors = {
  '入门': const Color(0xFF4CAF50),
  '初级': const Color(0xFF8BC34A),
  '中级': const Color(0xFFFF9800),
  '高级': const Color(0xFFF44336),
};

SliverList _buildCourseCatalog() {
  // 假设通过 Provider 或 Riverpod 获取数据
  final courses = [
    {'title': '手语字母表', 'category': '基础', 'duration': 15, 'difficulty': '入门'},
    {'title': '数字与计数', 'category': '基础', 'duration': 20, 'difficulty': '初级'},
    {'title': '复杂句型表达', 'category': '进阶', 'duration': 45, 'difficulty': '高级'},
  ];

  return SliverList(
    delegate: SliverChildBuilderDelegate(
      (context, index) {
        final course = courses[index];
        final levelColor = _difficultyColors[course['difficulty']] ?? Colors.grey;

        return Card(
          margin: const EdgeInsets.only(bottom: 12),
          child: ListTile(
            contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            leading: Container(
              width: 48,
              height: 48,
              decoration: BoxDecoration(
                color: levelColor.withOpacity(0.1),
                borderRadius: BorderRadius.circular(12),
              ),
              child: Icon(Icons.accessibility_new, color: levelColor),
            ),
            title: Text(course['title'] as String, style: const TextStyle(fontWeight: FontWeight.w600)),
            subtitle: Text('${course['category']} · ${course['duration']}分钟', style: const TextStyle(fontSize: 12)),
            trailing: Container(
              padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
              decoration: BoxDecoration(
                color: levelColor.withOpacity(0.15),
                borderRadius: BorderRadius.circular(20),
              ),
              child: Text(
                course['difficulty'] as String,
                style: TextStyle(color: levelColor, fontSize: 12, fontWeight: FontWeight.bold),
              ),
            ),
          ),
        );
      },
      childCount: courses.length,
    ),
  );
}

多维度过滤面板

筛选交互通过 showModalBottomSheet 唤起,内部使用 StatefulBuilder 来局部刷新选中状态,避免了整个学习中心页面的重绘。过滤维度拆分为难度与时长两组。


void _presentFilterSheet() {
  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    backgroundColor: Colors.transparent,
    builder: (ctx) {
      String selectedLevel = '全部';
      String selectedDuration = '全部';

      return StatefulBuilder(
        builder: (context, setModalState) {
          return Container(
            padding: const EdgeInsets.fromLTRB(20, 16, 20, 40),
            decoration: const BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
            ),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text('精准筛选', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
                const SizedBox(height: 20),
                const Text('难度级别', style: TextStyle(fontWeight: FontWeight.w500)),
                const SizedBox(height: 12),
                Wrap(
                  spacing: 10,
                  children: ['全部', '入门', '初级', '中级', '高级'].map((level) {
                    final isSelected = selectedLevel == level;
                    return FilterChip(
                      label: Text(level),
                      selected: isSelected,
                      selectedColor: const Color(0xFF3B82F6).withOpacity(0.2),
                      checkmarkColor: const Color(0xFF3B82F6),
                      onSelected: (_) => setModalState(() => selectedLevel = level),
                    );
                  }).toList(),
                ),
                const SizedBox(height: 20),
                const Text('课程时长', style: TextStyle(fontWeight: FontWeight.w500)),
                const SizedBox(height: 12),
                Wrap(
                  spacing: 10,
                  children: ['全部', '10分钟内', '10-30分钟', '30分钟以上'].map((duration) {
                    final isSelected = selectedDuration == duration;
                    return FilterChip(
                      label: Text(duration),
                      selected: isSelected,
                      selectedColor: const Color(0xFF3B82F6).withOpacity(0.2),
                      onSelected: (_) => setModalState(() => selectedDuration = duration),
                    );
                  }).toList(),
                ),
              ],
            ),
          );
        },
      );
    },
  );
}

在自适应布局层面,彻底摒弃了硬编码像素值,通过 MediaQuery.sizeOf(context) 动态计算边距(如 screenSize.width * 0.04)和卡片宽度。这种做法不仅保证了应用在 OpenHarmony 各种分辨率设备(如折叠屏、平板)上的比例一致性,同时降低了引入额外依赖包所带来的构建负担。

相关文章

可以按小时收费的VPS

很多 VPS 提供商都支持 按小时计费(hourly billing),想短期试用 / 临时搭建节点、测试网络、短期项目等场景非常合适。下面是当前最主流且靠谱的按小时 VPS 选项,分别按不同需求场景整理: 1. Vultr(全球节点,包括日本) 按小时计费 可选机房:东京 / 大阪 / 洛杉矶 / 法兰克福 / 伦敦 … 支持 PayPal(部分情况),但更常用信用卡/PayPal+卡价格参考$...

在 iPhone 上下载国外App

地区/国家限制App Store 会根据 Apple ID 的国家或地区限制应用下载。如果你的 Apple ID 绑定的是中国大陆,就可能无法下载 OpenAI 官方的 ChatGPT 应用,因为它在大陆 App Store 不上架。解决办法:换成美国、加拿大、香港等地区的 Apple ID。或者在现有 Apple ID 上更改地区。注册一个国外 Apple ID(推荐)比如注册 美国区 Appl...

Node.js 中的异步编程:回调与 Promise

Node.js 是一个基于 JavaScript 构建的单线程、非阻塞运行环境,它通过异步编程机制来高效处理多个操作。在执行如文件读取、API 请求或数据库查询等任务时,Node.js 不会等待这些操作完成,而是使用回调函数和 Promise 来避免阻塞主线程。 回调方式实现异步 那么当异步操作完成后,Node.js 如何知道接下来要做什么呢?这就要用到 回调函数(callback)。 回调本质上...

Selenium自动化测试入门指南

Selenium自动化测试入门指南

什么是自动化测试? 自动化测试是指利用软件工具自动执行测试用例,模拟用户操作,如打开网页、点击链接、输入文本等,并验证结果是否符合预期。 其主要优点包括: 大幅减少人工成本 测试速度快 可以在非工作时间运行 支持持续集成和交付 然而,它也存在一些局限性,例如开发成本较高、不适合快速变化的项目、依赖稳定的UI界面等。 自动化测试的应用条件 适合引入自动化测试的情况包括: 手动测试耗时且需要大量...

MariaDB Galera集群故障快速恢复指南

OpenStack控制节点采用三节点MariaDB Galera集群架构。当数据库集群因故障重启时,有时会出现Galera集群无法正常启动的问题。虽然有多种方法可以恢复数据库服务,但如何实现快速启动同时确保数据完整性呢? 通过分析日志发现,MariaDB Galera集群节点宕机时会在日志中输出以下信息: [Note] WSREP: 新集群视图:全局状态: 874d8e7e-5980-11e8-8...

Android 中 EventBus 的通信机制与实现原理深度解析

EventBus 核心设计思想 EventBus 是一个基于观察者模式的事件总线框架,广泛应用于 Android 平台以实现组件解耦。它通过中心化的消息分发机制,使不同层级、不同线程的对象能够以"发布-订阅"方式通信,避免了传统接口回调或广播带来的强依赖问题。 核心角色说明 事件(Event):任意 Java 对象,作为数据载体,如网络状态变更通知、用户登录信息等。 发布者(Publi...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。