在这里插入图片描述

社团之间总是有点良性竞争的,谁的活动办得好、谁的成员多、谁的评分高,大家都想知道。今天我们就来实现一个社团排行榜页面,让各社团的实力一目了然。

这个页面要做的事情:按评分排序展示社团、前三名要有特殊标识、点击能跳转到社团详情。

引入依赖

import 'package:flutter/material.dart';

Material库提供基础UI组件,每个页面都要引入。

没它啥也干不了。

import 'package:provider/provider.dart';

Provider做状态管理,社团数据从这里拿。

数据变了页面自动刷新。

import '../../providers/app_provider.dart';

我们自己的Provider,存着社团列表。

路径根据项目结构调整。

import 'club_detail_page.dart';

社团详情页面,点击排行榜项目跳转过去。

让用户能看到更多信息。

页面基本结构

class ClubRankingPage extends StatelessWidget {
  const ClubRankingPage({super.key});

用StatelessWidget就行,不需要维护状态。

数据都从Provider拿。

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('社团排行榜')
      ),

Scaffold搭架子,AppBar放标题。

标准套路。

获取并排序数据

      body: Consumer<AppProvider>(
        builder: (context, provider, _) {
          final clubs = List.of(provider.clubs)
            ..sort((a, b) => b.rating.compareTo(a.rating));

Consumer监听Provider,数据变了自动重建。

List.of创建副本避免修改原数据,sort按评分降序排列。

构建列表

          return ListView.builder(
            padding: const EdgeInsets.all(16),
            itemCount: clubs.length,
            itemBuilder: (context, index) {
              final club = clubs[index];
              final rank = index + 1;

ListView.builder渲染列表,性能好。

rank是排名,从1开始。

排行卡片容器

              return Card(
                margin: const EdgeInsets.only(
                  bottom: 12
                ),
                child: InkWell(
                  onTap: () => Navigator.push(
                    context, 
                    MaterialPageRoute(
                      builder: (_) => ClubDetailPage(
                        club: club
                      )
                    )
                  ),

Card包裹卡片,InkWell提供点击效果。

点击跳转到社团详情页。

卡片内容布局

                  child: Padding(
                    padding: const EdgeInsets.all(16),
                    child: Row(
                      children: [
                        _buildRankBadge(rank),
                        const SizedBox(width: 16),

Row横向排列:排名徽章、头像、信息、评分。

SizedBox加间距。

社团头像

                        CircleAvatar(
                          radius: 24,
                          backgroundColor: const Color(0xFF4A90E2).withOpacity(0.1),
                          child: Text(
                            club.name[0], 
                            style: const TextStyle(
                              color: Color(0xFF4A90E2), 
                              fontWeight: FontWeight.bold
                            )
                          ),
                        ),
                        const SizedBox(width: 12),

头像显示社团名首字母,蓝色主题。

没有真实头像时常用这种方式。

社团信息区域

                        Expanded(
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Text(
                                club.name, 
                                style: const TextStyle(
                                  fontWeight: FontWeight.bold, 
                                  fontSize: 16
                                )
                              ),
                              const SizedBox(height: 4),

Expanded让信息区占满剩余空间。

社团名用粗体突出。

分类和成员数

                              Row(
                                children: [
                                  Container(
                                    padding: const EdgeInsets.symmetric(
                                      horizontal: 6, 
                                      vertical: 2
                                    ),
                                    decoration: BoxDecoration(
                                      color: const Color(0xFF4A90E2).withOpacity(0.1),
                                      borderRadius: BorderRadius.circular(4),
                                    ),
                                    child: Text(
                                      club.category, 
                                      style: const TextStyle(
                                        color: Color(0xFF4A90E2), 
                                        fontSize: 11
                                      )
                                    ),
                                  ),

分类标签用蓝色小字,背景浅蓝。

让用户知道这是什么类型的社团。

                                  const SizedBox(width: 8),
                                  const Icon(
                                    Icons.people, 
                                    size: 14, 
                                    color: Colors.grey
                                  ),
                                  const SizedBox(width: 4),
                                  Text(
                                    '${club.memberCount}人', 
                                    style: const TextStyle(
                                      fontSize: 12, 
                                      color: Colors.grey
                                    )
                                  ),
                                ],
                              ),
                            ],
                          ),
                        ),

成员数用人物图标配合数字显示。

灰色表示次要信息。

评分显示

                        Column(
                          children: [
                            Row(
                              children: [
                                const Icon(
                                  Icons.star, 
                                  color: Colors.amber, 
                                  size: 20
                                ),
                                const SizedBox(width: 4),
                                Text(
                                  club.rating.toStringAsFixed(1), 
                                  style: const TextStyle(
                                    fontWeight: FontWeight.bold, 
                                    fontSize: 18, 
                                    color: Color(0xFF4A90E2)
                                  )
                                ),
                              ],
                            ),

星星图标配合评分数字,金色星星很醒目。

评分保留一位小数。

                            const Text(
                              '评分', 
                              style: TextStyle(
                                fontSize: 12, 
                                color: Colors.grey
                              )
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ),
              );
            },
          );
        },
      ),
    );
  }

评分下面加个标签说明。

整个卡片结构就这样。

排名徽章组件

  Widget _buildRankBadge(int rank) {
    Color color;
    IconData? icon;

    switch (rank) {
      case 1:
        color = const Color(0xFFFFD700);
        icon = Icons.emoji_events;
        break;

第一名用金色,显示奖杯图标。

金色是冠军的标志。

      case 2:
        color = const Color(0xFFC0C0C0);
        icon = Icons.emoji_events;
        break;

第二名用银色,也显示奖杯。

银色代表亚军。

      case 3:
        color = const Color(0xFFCD7F32);
        icon = Icons.emoji_events;
        break;

第三名用铜色,同样显示奖杯。

铜色代表季军。

      default:
        color = Colors.grey;
        icon = null;
    }

其他名次用灰色,不显示图标。

只显示数字就行。

徽章容器

    return Container(
      width: 36,
      height: 36,
      decoration: BoxDecoration(
        color: color.withOpacity(0.2),
        shape: BoxShape.circle,
      ),

圆形容器,背景是主色调的浅色版。

大小固定36x36。

      child: Center(
        child: icon != null
            ? Icon(
                icon, 
                color: color, 
                size: 20
              )
            : Text(
                '$rank', 
                style: TextStyle(
                  color: color, 
                  fontWeight: FontWeight.bold, 
                  fontSize: 16
                )
              ),
      ),
    );
  }
}

前三名显示奖杯图标,其他显示数字。

条件表达式控制显示内容。

社团数据模型

class Club {
  final String id;
  final String name;
  final String category;
  final int memberCount;
  final double rating;
  final String description;
  
  Club({
    required this.id,
    required this.name,
    required this.category,
    required this.memberCount,
    required this.rating,
    required this.description,
  });
}

社团模型包含这些字段。

rating是评分,用于排序。

Provider中的社团数据

class AppProvider extends ChangeNotifier {
  List<Club> _clubs = [];
  
  List<Club> get clubs => _clubs;
  
  void setClubs(List<Club> clubs) {
    _clubs = clubs;
    notifyListeners();
  }
}

Provider存着社团列表。

setClubs更新数据后通知监听者。

测试数据

void initTestClubs(AppProvider provider) {
  provider.setClubs([
    Club(
      id: 'club001',
      name: '编程爱好者协会',
      category: '科技',
      memberCount: 128,
      rating: 4.8,
      description: '热爱编程的同学聚集地',
    ),

开发阶段用测试数据调试。

这个社团评分4.8,应该排第一。

    Club(
      id: 'club002',
      name: '摄影社',
      category: '艺术',
      memberCount: 86,
      rating: 4.6,
      description: '用镜头记录美好瞬间',
    ),

摄影社评分4.6,排第二。

不同分类的社团都有。

    Club(
      id: 'club003',
      name: '篮球社',
      category: '体育',
      memberCount: 156,
      rating: 4.5,
      description: '热爱篮球运动的大家庭',
    ),

篮球社评分4.5,排第三。

成员数最多但评分不是最高。

    Club(
      id: 'club004',
      name: '读书会',
      category: '文化',
      memberCount: 62,
      rating: 4.3,
      description: '分享阅读的快乐',
    ),
  ]);
}

读书会排第四,显示数字4。

没有奖杯图标了。

页面路由

Navigator.push(
  context,
  MaterialPageRoute(
    builder: (_) => const ClubRankingPage()
  ),
);

从其他页面跳转过来就这么写。

标准路由跳转。

在发现页添加入口

ListTile(
  leading: const Icon(
    Icons.leaderboard,
    color: Color(0xFF4A90E2)
  ),
  title: const Text('社团排行榜'),
  trailing: const Icon(Icons.chevron_right),
  onTap: () {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => const ClubRankingPage()
      ),
    );
  },
),

发现页放个入口,点击跳转。

leaderboard图标很形象。

关于排序方式

// 按评分排序
clubs.sort((a, b) => b.rating.compareTo(a.rating));

// 按成员数排序
clubs.sort((a, b) => b.memberCount.compareTo(a.memberCount));

// 按名称排序
clubs.sort((a, b) => a.name.compareTo(b.name));

可以支持多种排序方式。

让用户选择按什么排序。

添加排序切换

enum SortType { rating, memberCount, name }

class ClubRankingPage extends StatefulWidget {
  // ...
}

class _ClubRankingPageState extends State<ClubRankingPage> {
  SortType _sortType = SortType.rating;
  
  void _changeSortType(SortType type) {
    setState(() {
      _sortType = type;
    });
  }
}

用枚举定义排序类型。

切换时调用setState刷新。

排序按钮

PopupMenuButton<SortType>(
  icon: const Icon(Icons.sort),
  onSelected: _changeSortType,
  itemBuilder: (context) => [
    const PopupMenuItem(
      value: SortType.rating,
      child: Text('按评分')
    ),
    const PopupMenuItem(
      value: SortType.memberCount,
      child: Text('按人数')
    ),
    const PopupMenuItem(
      value: SortType.name,
      child: Text('按名称')
    ),
  ],
)

PopupMenuButton做排序切换。

放在AppBar的actions里。

根据类型排序

List<Club> _sortClubs(List<Club> clubs) {
  final sorted = List.of(clubs);
  switch (_sortType) {
    case SortType.rating:
      sorted.sort((a, b) => b.rating.compareTo(a.rating));
      break;
    case SortType.memberCount:
      sorted.sort((a, b) => b.memberCount.compareTo(a.memberCount));
      break;
    case SortType.name:
      sorted.sort((a, b) => a.name.compareTo(b.name));
      break;
  }
  return sorted;
}

根据当前排序类型排序。

返回排序后的列表。

关于颜色常量

const Color goldColor = Color(0xFFFFD700);
const Color silverColor = Color(0xFFC0C0C0);
const Color bronzeColor = Color(0xFFCD7F32);

金银铜三种颜色定义成常量。

方便统一管理和复用。

小结

社团排行榜页面实现起来不复杂,核心就是排序和展示。前三名用金银铜奖杯突出显示,其他名次用数字,视觉效果清晰。

代码里用到的技巧:List.sort排序、switch条件判断、条件表达式控制显示内容。这些在其他场景也经常用到。

实际项目中可能还要考虑分页加载、筛选条件这些,但基本框架就是这样。把核心功能做好,其他的慢慢加。


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

Logo

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

更多推荐