大学搜索功能让用户可以查找全球各地的大学信息。这个页面和图书搜索类似,但数据结构不太一样,而且大学信息里有官网链接,可以直接跳转到大学官网。

做这个页面的时候,我特别注意了搜索体验:未搜索时显示引导提示,搜索中显示骨架屏,无结果时显示友好提示。这些细节加起来,让整个搜索流程变得顺畅。


请添加图片描述

状态变量设计

大学搜索页面需要管理搜索状态和结果:

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

  
  State<UniversityListScreen> createState() => _UniversityListScreenState();
}

class _UniversityListScreenState extends State<UniversityListScreen> {
  final _searchController = TextEditingController();
  List<dynamic> _universities = [];
  bool _isLoading = false;
  bool _hasSearched = false;
}

_hasSearched区分"还没搜索"和"搜索结果为空"两种状态。这个变量在图书搜索里也用到了,是搜索页面的标配。


搜索方法

调用API搜索大学:

Future<void> _search() async {
  if (_searchController.text.isEmpty) return;

  setState(() {
    _isLoading = true;
    _hasSearched = true;
  });

  try {
    final universities = await ApiService.searchUniversities(_searchController.text);
    if (mounted) {
      setState(() {
        _universities = universities;
        _isLoading = false;
      });
    }
  } catch (e) {
    print('search universities error: $e');
    if (mounted) {
      setState(() {
        _universities = [];
        _isLoading = false;
      });
    }
  }
}

搜索前检查输入是否为空,避免无效请求。即使请求失败也要更新状态,让页面显示正确的内容(空结果提示)。

关于API

大学搜索用的是Hipolabs的Universities API,这是一个免费的开放API,包含全球几千所大学的信息。搜索时可以按名称或国家搜索。


页面布局

搜索框和结果列表的组合:


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('搜索大学'),
    ),
    body: Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(16),
          child: TextField(
            controller: _searchController,
            decoration: InputDecoration(
              hintText: '输入大学名称...',
              prefixIcon: const Icon(Icons.search),
              suffixIcon: _searchController.text.isNotEmpty
                  ? IconButton(
                      icon: const Icon(Icons.clear),
                      onPressed: () {
                        _searchController.clear();
                        setState(() {
                          _universities = [];
                          _hasSearched = false;
                        });
                      },
                    )
                  : null,
            ),
            onSubmitted: (_) => _search(),
            onChanged: (_) => setState(() {}),
          ),
        ),
        Expanded(child: _buildResults()),
      ],
    ),
  );
}

清除按钮点击后重置所有状态,回到初始的引导界面。onSubmitted在用户按回车时触发搜索。


结果区域构建

根据不同状态显示不同内容:

Widget _buildResults() {
  if (!_hasSearched) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.school, size: 80, color: Colors.grey[300]),
          const SizedBox(height: 16),
          Text('搜索全球大学', style: TextStyle(color: Colors.grey[500])),
          const SizedBox(height: 8),
          Text('输入大学名称开始搜索', style: TextStyle(color: Colors.grey[400], fontSize: 12)),
        ],
      ),
    );
  }

未搜索时显示引导提示,一个大的学校图标加两行引导语。这比显示空白页面友好多了。

  if (_isLoading) {
    return ListView.builder(
      padding: const EdgeInsets.all(16),
      itemCount: 5,
      itemBuilder: (context, index) => const Padding(
        padding: EdgeInsets.only(bottom: 12),
        child: LoadingShimmer(height: 80),
      ),
    );
  }

  if (_universities.isEmpty) {
    return const EmptyWidget(message: '没有找到相关大学', icon: Icons.school);
  }

  return ListView.builder(
    padding: const EdgeInsets.all(16),
    itemCount: _universities.length,
    itemBuilder: (context, index) => _buildUniversityItem(_universities[index]),
  );
}

加载中显示骨架屏,无结果显示空状态,有结果显示列表。骨架屏的高度设为80,和实际的大学卡片高度接近。


大学项展示

每所大学用卡片展示:

Widget _buildUniversityItem(Map<String, dynamic> university) {
  final name = university['name'] ?? '未知';
  final country = university['country'] ?? '未知';
  final webPages = university['web_pages'] as List?;
  final domains = university['domains'] as List?;

  return Card(
    margin: const EdgeInsets.only(bottom: 12),
    child: InkWell(
      onTap: () => _showUniversityDetail(context, university),
      borderRadius: BorderRadius.circular(12),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Container(
                  padding: const EdgeInsets.all(10),
                  decoration: BoxDecoration(
                    color: Theme.of(context).colorScheme.primaryContainer,
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: Icon(Icons.school, color: Theme.of(context).colorScheme.primary),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(name, style: const TextStyle(fontWeight: FontWeight.w600)),
                      const SizedBox(height: 4),
                      Row(
                        children: [
                          const Icon(Icons.location_on, size: 14, color: Colors.grey),
                          const SizedBox(width: 4),
                          Text(country, style: TextStyle(color: Colors.grey[600], fontSize: 13)),
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),

左侧是一个带背景的学校图标,右侧显示大学名称和所在国家。位置图标让国家信息更直观。

            if (domains != null && domains.isNotEmpty) ...[
              const SizedBox(height: 12),
              Text(
                domains.first,
                style: TextStyle(color: Colors.grey[500], fontSize: 12),
              ),
            ],
            if (webPages != null && webPages.isNotEmpty) ...[
              const SizedBox(height: 8),
              TextButton.icon(
                onPressed: () => _launchUrl(webPages.first),
                icon: const Icon(Icons.open_in_new, size: 16),
                label: const Text('访问官网'),
                style: TextButton.styleFrom(padding: EdgeInsets.zero),
              ),
            ],
          ],
        ),
      ),
    ),
  );
}

域名用小号灰色字显示,官网链接用TextButton,点击后在浏览器中打开。padding: EdgeInsets.zero去掉按钮的默认内边距,让它和其他内容对齐。


打开网页

使用url_launcher打开大学官网:

Future<void> _launchUrl(String url) async {
  final uri = Uri.parse(url);
  if (await canLaunchUrl(uri)) {
    await launchUrl(uri, mode: LaunchMode.externalApplication);
  }
}

canLaunchUrl检查是否可以打开URL,externalApplication模式在外部浏览器中打开。如果用inAppWebView模式,会在App内部打开,但需要额外配置。

关于url_launcher

这是Flutter官方的插件,用于打开URL、发送邮件、拨打电话等。使用前需要在pubspec.yaml里添加依赖:

dependencies:
  url_launcher: ^6.1.0

大学详情弹窗

点击卡片显示详情弹窗:

void _showUniversityDetail(BuildContext context, Map<String, dynamic> university) {
  final name = university['name'] ?? '未知';
  final country = university['country'] ?? '未知';
  final stateProvince = university['state-province'];
  final webPages = university['web_pages'] as List?;
  final domains = university['domains'] as List?;
  final alphaTwoCode = university['alpha_two_code'] ?? '';

  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    builder: (context) => DraggableScrollableSheet(
      initialChildSize: 0.6,
      minChildSize: 0.4,
      maxChildSize: 0.9,
      expand: false,
      builder: (context, scrollController) => Container(
        padding: const EdgeInsets.all(20),
        child: ListView(
          controller: scrollController,
          children: [
            Center(
              child: Container(
                width: 40,
                height: 4,
                decoration: BoxDecoration(
                  color: Colors.grey[300],
                  borderRadius: BorderRadius.circular(2),
                ),
              ),
            ),
            const SizedBox(height: 20),
            // 详情内容...
          ],
        ),
      ),
    ),
  );
}

DraggableScrollableSheet创建可拖动的底部弹窗,用户可以上下拖动调整高度。顶部的小横条是拖动指示器,告诉用户这个弹窗可以拖动。

为什么用底部弹窗而不是新页面?

因为大学的信息不多,用弹窗展示就够了,不需要跳转到新页面。而且弹窗可以快速关闭,用户想看下一所大学时更方便。


资源释放

页面销毁时释放控制器:


void dispose() {
  _searchController.dispose();
  super.dispose();
}

小结

大学搜索页面展示了如何实现一个完整的搜索功能。通过_hasSearched状态区分不同场景,让用户在各种情况下都能得到合适的反馈。url_launcher的使用让用户可以直接访问大学官网,获取更多信息。底部弹窗的设计让详情展示更轻量,不需要跳转页面。

下一篇我们来看百科搜索功能的实现,了解如何接入维基百科API。


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

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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐