如何在Flutter中实现带吸附与选中指示器的横向滚动组件?
Flutter 实现带吸附与选中指示器的横向滚动组件
ListWheelScrollView更适合模拟滚轮选择器的交互,横向普通列表场景下用它确实容易出现适配问题,推荐用下面两种方案实现需求:
方案一:PageView + 自定义指示器(全屏宽度项)
如果你的滚动项是全屏宽度的,PageView是最优选择,它默认自带pageSnapping(吸附)功能,配合自定义指示器就能快速实现效果:
import 'package:flutter/material.dart'; class SnapScrollWithIndicator extends StatefulWidget { const SnapScrollWithIndicator({super.key}); @override State<SnapScrollWithIndicator> createState() => _SnapScrollWithIndicatorState(); } class _SnapScrollWithIndicatorState extends State<SnapScrollWithIndicator> { final PageController _pageController = PageController(); int _currentPage = 0; final List<String> _items = ['项1', '项2', '项3', '项4', '项5']; @override void initState() { super.initState(); // 监听页面切换,更新选中索引 _pageController.addListener(() { final page = _pageController.page?.round() ?? 0; if (page != _currentPage) { setState(() { _currentPage = page; }); } }); } @override void dispose() { _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ Expanded( child: PageView.builder( controller: _pageController, // 开启吸附,默认就是true,可显式声明 pageSnapping: true, itemCount: _items.length, itemBuilder: (context, index) { return Container( alignment: Alignment.center, color: index % 2 == 0 ? Colors.blue[100] : Colors.green[100], child: Text( _items[index], style: const TextStyle(fontSize: 24), ), ); }, ), ), // 底部选中指示器 Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(_items.length, (index) { return Container( width: 8, height: 8, margin: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( shape: BoxShape.circle, color: _currentPage == index ? Colors.blue : Colors.grey[300], ), ); }), ), ), ], ); } }
方案二:自定义横向滚动列表(非全屏宽度项)
如果你的滚动项宽度小于屏幕,需要滚动到中心时吸附并显示指示器,可以用SingleChildScrollView + ListView配合ScrollController实现:
import 'package:flutter/material.dart'; class SnapHorizontalList extends StatefulWidget { const SnapHorizontalList({super.key}); @override State<SnapHorizontalList> createState() => _SnapHorizontalListState(); } class _SnapHorizontalListState extends State<SnapHorizontalList> { final ScrollController _scrollController = ScrollController(); int _selectedIndex = 0; final List<String> _items = ['卡片1', '卡片2', '卡片3', '卡片4', '卡片5']; final double _itemWidth = 180; final double _itemSpacing = 16; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); } void _onScroll() { // 计算当前中心位置对应的item索引 final centerOffset = _scrollController.offset + MediaQuery.of(context).size.width / 2; final itemIndex = (centerOffset / (_itemWidth + _itemSpacing)).floor(); if (itemIndex != _selectedIndex && itemIndex >= 0 && itemIndex < _items.length) { setState(() { _selectedIndex = itemIndex; }); } // 滚动停止时自动吸附到选中项中心 if (!_scrollController.position.isScrollingNotifier.value) { final targetOffset = (_itemWidth + _itemSpacing) * _selectedIndex + (MediaQuery.of(context).size.width - _itemWidth) / 2; _scrollController.animateTo( targetOffset, duration: const Duration(milliseconds: 300), curve: Curves.ease, ); } } @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ SizedBox( height: 200, child: SingleChildScrollView( controller: _scrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: List.generate(_items.length, (index) { return Container( width: _itemWidth, margin: EdgeInsets.only(left: index == 0 ? 0 : _itemSpacing), alignment: Alignment.center, decoration: BoxDecoration( color: _selectedIndex == index ? Colors.blue[100] : Colors.grey[100], borderRadius: BorderRadius.circular(8), ), child: Text( _items[index], style: const TextStyle(fontSize: 18), ), ); }), ), ), ), // 选中指示器 Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(_items.length, (index) { return Container( width: 10, height: 10, margin: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( shape: BoxShape.circle, color: _selectedIndex == index ? Colors.blue : Colors.grey[300], border: _selectedIndex == index ? Border.all(color: Colors.blue[700], width: 2) : null, ), ); }), ), ), ], ); } }
关键说明
- 方案一适合全屏宽度的页面切换场景,PageView的
pageSnapping默认开启,无需额外处理吸附逻辑 - 方案二适合卡片式横向列表,通过监听滚动位置计算选中项,滚动停止时自动吸附到中心位置
- 两种方案都可以根据需求自定义指示器样式(比如改成下划线、数字标签等)
内容的提问来源于stack exchange,提问作者Opeyemi Lawal
相关产品推荐
相关产品推荐

