目标:实现两个横向滚动列表的选择的互斥,就是说这两个列表中,所有的元素都是互斥的存在,每次只能选中一个。
效果图

实现步骤
1.定义枚举类型:水果和花朵
Dart
enum ItemType{fruit,flower}
2.定义数据类,虽然这个名字是水果类,但是其实是通用的,因为加了类型判断在里面
Dart
class FruitItem{
final String title;
final String image;
final ItemType type;
bool isSelected;
FruitItem({
required this.title,
required this.image,
required this.type,
this.isSelected = false,
});
}
3.定义数据,数据可以放在同一个列表里面,因为可以用传入类型区分这两种数据。
Dart
//使用自定义类
final List<FruitItem> _items = [
//水果项目
FruitItem(title: '苹果', image: 'assets/images/apple.png',type: ItemType.fruit,isSelected: false),
FruitItem(title: '香蕉', image: 'assets/images/banana.png',type: ItemType.fruit,isSelected: false),
FruitItem(title: '樱桃', image: 'assets/images/cherry.png',type: ItemType.fruit,isSelected: false),
//花朵项目
FruitItem(title: '玫瑰', image: 'assets/images/rose.png',type: ItemType.flower,isSelected: false),
FruitItem(title: '郁金香', image: 'assets/images/tulip.png',type: ItemType.flower,isSelected: false),
FruitItem(title: '向日葵', image: 'assets/images/sunflower.png',type: ItemType.flower,isSelected: false),
];
4.定义一个整数来标识当前用户选中的索引
Dart
int? _selectedIndex;
5.分出两个列表出来显示UI
Dart
List<FruitItem> get fruitItems => _items.where((item) => item.type == ItemType.fruit).toList();
List<FruitItem> get flowerItems => _items.where((item) => item.type == ItemType.flower).toList();
6.定义UI:背景渐变
Dart
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.green.withOpacity(0.9),
Colors.white,
],
),
),
),
7.定义总标题
Dart
Positioned(
top: 50,
left: 150,
child: Text(
'播放歌曲',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
8.定义水果标题和水果列表
Dart
//水果标题
Positioned(
top: 120,
left: 20,
child: Text(
'水果歌曲',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
// 水果横向滚动列表(占位置)
Positioned(
top: 150,
left: 0,
right: 0,
height: 180, // 列表高度
child: _buildHorizontalList(fruitItems),
),
9.定义花朵标题和花朵列表
Dart
//花朵标题
Positioned(
top: 320,
left: 20,
child: Text(
'花朵歌曲',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
// 花朵横向滚动列表(占位置)
Positioned(
top: 350,
left: 0,
right: 0,
height: 180, // 列表高度
child: _buildHorizontalList(flowerItems),
),
10.根据传入的不同数据构建滚动列表
Dart
Widget _buildHorizontalList(List<FruitItem> items) { //根据用户传入的不同的数据显示UI
return ListView.builder( //ListView.builder按需构建子项
scrollDirection: Axis.horizontal, // 横向滚动
padding: const EdgeInsets.symmetric(horizontal: 15), //内边距
itemCount: items.length, //子项的数量
itemBuilder: (context, index) { //框架自动传递index:为每个索引位置构建对应的Widget
final globalIndex = _getGlobalIndex(items[index]); //将局部索引转换为全局索引
final isSelected = _selectedIndex == globalIndex; // 计算选中状态 计算_selectedIndex是否等于 globalIndex?
return buildItem(
title: items[index].title,
image: items[index].image,
isSelected: isSelected, // 传递选中状态
callback: () {
_onItemTap(globalIndex);
},
);
},
);
}
//根据item对象找到在全局列表中的索引
//element是item中的每一个元素,意思是元素从头到尾一个一个赋值给element,然后跟传入进来的item去比较
int _getGlobalIndex(FruitItem item) {
return _items.indexWhere((element) =>
element.title == item.title && element.type == item.type);
}
11.选中事件的逻辑
Dart
void _onItemTap(int index) {
print('点击了: ${_items[index].title}');
setState(() {
// 互斥逻辑:如果点击的是已选中的项目,则取消选中
if (_selectedIndex == index) {
_selectedIndex = null;
} else {
_selectedIndex = index; //下标传递
}
});
// 传回点击事件给父组件
_handleItemSelection(index, _selectedIndex == index);
}
// 处理项目选择事件
void _handleItemSelection(int index, bool isSelected) {
//print('项目 $index ${isSelected ? '被选中' : '取消选中'}');
final item = _items[index];
if (isSelected) {
print('✅ 选中了【${item.type == ItemType.fruit ? '水果' : '花朵'}】: ${item.title}');
// 这里可以添加播放音频的逻辑
} else {
print('❌ 取消选中: ${item.title}');
// 这里可以添加停止音频的逻辑
}
}
12.定义通用的功能小卡片
Dart
Widget buildItem({
required String title, //名字
required String image, //图片
required bool isSelected, //是否选中
VoidCallback? callback //传回点击事件
}) {
return Container(
margin: const EdgeInsets.only(right: 15),//外边距
child: Column(
children: [
// 使用 Stack 包装图片和选中图标
Stack(
children: [
// 图片容器
Container(
width: 130,
height: 130,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(15),
boxShadow: [ //阴影效果
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
// 添加选中状态边框
border: isSelected ? Border.all(color: Colors.green, width: 3,) : null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: callback, //点击事件
borderRadius: BorderRadius.circular(15),
child: ClipRRect( //给图片做裁剪
borderRadius: BorderRadius.circular(15),
child: Image.asset(
image,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
),
),
),
),
// 选中图标 - 显示在右上角
if (isSelected)
Positioned(
top: 8,
right: 8,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 16,
),
),
),
],
),
// 文字
const SizedBox(height: 8),
Text(
title,
style: TextStyle(
color: isSelected ? Colors.green : Colors.white,
fontSize: 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
),
),
],
),
);
}
项目架构
Dart
数据层
// 数据模型
class FruitItem {
final String title;
final String image;
final ItemType type;
bool isSelected;
}
// 数据源管理
final List<FruitItem> _items = []; // 统一数据源
状态层
// 状态管理
int? _selectedIndex; // 单一状态源
UI层
// 组件化架构
Widget _buildHorizontalList() // 列表组件
Widget buildItem() // 卡片组件
数据流动路径
Dart
用户交互 → 事件回调 → 状态更新 → UI重绘 → 用户反馈
从那些方面学习这个项目
Dart
基础UI组件
// 学习重点:
- Stack 层叠布局
- Positioned 精确定位
- ListView.builder 动态列表
- Container 样式装饰
- InkWell 点击交互
状态管理
// 学习重点:
- StatefulWidget 生命周期
- setState() 触发重绘
- 状态提升 (Lifting State Up)
- 单向数据流
组件化设计
// 学习重点:
- 参数化组件 (buildItem方法)
- 回调函数通信
- 关注点分离
- 代码复用
架构模式
// 学习重点:
- 数据与UI分离
- 单一职责原则
- 索引映射算法
- 状态一致性保证
关键点
1.怎么将局部索引转换为全局索引:通过对象本身来找到它在全局列表中的位置
数据情况
Dart
// 全局列表(所有项目)
_items = [
苹果, // 全局索引0
香蕉, // 全局索引1
樱桃, // 全局索引2
玫瑰, // 全局索引3
郁金香,// 全局索引4
向日葵,// 全局索引5
];
// 水果列表(筛选出的子集)
fruitItems = [
苹果, // 局部索引0 → 全局索引0
香蕉, // 局部索引1 → 全局索引1
樱桃, // 局部索引2 → 全局索引2
];
// 花朵列表(筛选出的子集)
flowerItems = [
玫瑰, // 局部索引0 → 全局索引3
郁金香, // 局部索引1 → 全局索引4
向日葵, // 局部索引2 → 全局索引5
];
转换过程
Dart
// 当用户点击花朵列表的第2项(郁金香)时:
// 步骤1:通过局部索引拿到对象
FruitItem clickedItem = flowerItems[1]; // 拿到"郁金香"对象
// 步骤2:拿着这个对象去全局列表问:
// "郁金香同学,你在全局列表中是第几个?"
int globalIndex = _getGlobalIndex(clickedItem);
// _getGlobalIndex内部:
return _items.indexWhere((element) =>
element.title == '郁金香' && element.type == ItemType.flower
);
// 执行过程:
// 遍历_items,比较每个元素是不是我们要找的"郁金香"
// [0] 苹果 == 郁金香? ❌
// [1] 香蕉 == 郁金香? ❌
// [2] 樱桃 == 郁金香? ❌
// [3] 玫瑰 == 郁金香? ❌
// [4] 郁金香 == 郁金香? ✅ → 返回索引4
2.怎么理解用户点击子项后的数据流?
场景1:用户首次点击"郁金香"(花朵列表第2项)
阶段1:点击前的界面构建(_selectedIndex = null)
Dart
// 这是用户点击之前的界面构建过程
_buildHorizontalList(flowerItems) {
// index = 1 (用户将要点击郁金香)
// 坐标转换
final globalIndex = _getGlobalIndex(flowerItems[1]); // 返回4
// 判断选中状态 ← 此时_selectedIndex还是null!
final isSelected = _selectedIndex == 4; // null == 4 → false
// 构建UI组件 ← 显示未选中状态
return buildItem(
title: "郁金香",
image: "assets/images/tulip.png",
isSelected: false, // 显示灰色边框(未选中)
callback: () {
_onItemTap(4); // 设置点击回调
},
);
}
此时的界面显示
Dart
花朵歌曲: [玫瑰❌, 郁金香❌, 向日葵❌] ← 都显示未选中状态
阶段2:用户点击郁金香
Dart
// 用户点击郁金香卡片,触发callback
_onItemTap(4) {
print('点击了: ${_items[4].title}'); // 输出:点击了: 郁金香
setState(() {
// _selectedIndex从null变为4
_selectedIndex = 4;
});
_handleItemSelection(4, true); // 输出:✅ 选中了【花朵】: 郁金香
}
阶段3:setState触发页面重绘
Dart
// setState()后,整个build方法重新执行
// 现在_selectedIndex = 4
_buildHorizontalList(flowerItems) {
// 重新构建所有项目
// 对于郁金香(index=1):
final globalIndex = _getGlobalIndex(flowerItems[1]); // 返回4
final isSelected = _selectedIndex == 4; // 4 == 4 → true
return buildItem(
title: "郁金香",
image: "assets/images/tulip.png",
isSelected: true, // 现在显示绿色边框(选中)
callback: () {
_onItemTap(4);
},
);
// 对于其他项目(玫瑰、向日葵):
// isSelected = _selectedIndex == 其他索引 → false
}
重绘后,页面显示
Dart
花朵歌曲: [玫瑰❌, 郁金香✅, 向日葵❌] ← 只有郁金香显示选中状态
完整的时间线
Dart
时间点0: _selectedIndex = null
↓
界面构建:所有项目显示未选中
↓
用户点击郁金香
↓
_onItemTap(4): _selectedIndex = 4
↓
setState()触发重绘
↓
界面重新构建:郁金香显示选中,其他未选中
场景2:用户再点击"香蕉"(水果列表第2项)
数据流
Dart
// 1. 用户点击水果列表的第2项
_buildHorizontalList(fruitItems) {
// index = 1 (局部索引)
// 2. 坐标转换
final globalIndex = _getGlobalIndex(fruitItems[1]);
// fruitItems[1] = 香蕉对象
// _getGlobalIndex内部:在_items中查找"香蕉"
// 遍历过程:苹果❌ → 香蕉✅ (找到!)
// 返回:1
// 3. 判断选中状态
final isSelected = _selectedIndex == 1;
// _selectedIndex当前是4,4 != 1
// 所以 isSelected = false
// 4. 构建UI组件
return buildItem(
title: "香蕉",
image: "assets/images/banana.png",
isSelected: false, // 当前未选中
callback: () {
_onItemTap(1); // 点击时传入全局索引1
},
);
}
// 5. 用户点击香蕉卡片,触发callback
_onItemTap(1) {
print('点击了: ${_items[1].title}'); // 输出:点击了: 香蕉
setState(() {
// 互斥选择:取消之前的选中(4),设置新的选中(1)
_selectedIndex = 1;
});
_handleItemSelection(1, true); // 输出:✅ 选中了【水果】: 香蕉
}
界面更新
Dart
_selectedIndex = 1
界面显示:
水果歌曲: [苹果❌, 香蕉✅, 樱桃❌] ← 只有香蕉显示绿色边框
花朵歌曲: [玫瑰❌, 郁金香❌, 向日葵❌] ← 花朵全部取消选中
数据流总结
Dart
用户点击
↓
获取局部索引 + 对象
↓
坐标转换:对象 → 全局索引
↓
判断选中状态
↓
构建UI组件(传入回调)
↓
用户触发点击 → 执行_onItemTap(全局索引)
↓
更新_selectedIndex ← 互斥选择逻辑
↓
setState()触发界面重绘
↓
所有列表重新构建,显示新的选中状态