在许多移动应用中,我们经常需要通过字母索引快速跳转到目标位置,比如通讯录、国家选择等功能。这篇博客将带大家实现一个仿照通讯录的 Flutter 国家选择器。通过一个字母索引的侧边栏,用户可以快速跳转到目标字母分组。
效果:
1. 项目需求与设计思路
我们需要实现一个包含多个国家的选择页面,这些国家根据首字母进行分组。通过侧边字母索引栏,用户可以快速跳转到对应的国家分组,提供类似通讯录的便捷操作体验。
主要技术点包括:
- 使用
ListView.builder
动态加载国家列表。 - 使用
ScrollController
实现页面的滚动控制。 - 实现自定义的侧边栏导航
IndexBar
,点击或拖动索引可以跳转至指定字母分组。 - 字母组头在列表中出现时要显示出来,并根据用户滑动到不同字母时动态显示当前字母。
2. 代码结构解析
2.1 数据初始化
在 PhoneArea
页面中,使用 ListView.builder
构建国家选择列表。在组件初始化时,我们从 _countryData
方法中获取所有国家数据,并使用 ScrollController
控制滚动。
class _PhoneAreaState extends State<PhoneArea> {
late ScrollController _scrollController; // 滚动控制器
final double _cellHeight = 50; // item的高度
final double _groupHeight = 34.0; // 组头高度
final Map<String, double> _groupOffsetMap = {}; // 存储偏移量
List<Map<String, dynamic>> _data = []; // 国家数据List
@override
void initState() {
super.initState();
_data = _countryData(); // 获取所有国家数据
_scrollController = ScrollController(); // 初始化滚动控制器
WidgetsBinding.instance.addPostFrameCallback((_) {
_calculateGroupOffsets(); // 计算每组的偏移量
});
}
}
2.2 数据获取
因为为了演示我写的是假数据,如果是调接口那把其他地方对应的名称改为接口数据返回的名称
// 假数据
List<Map<String, dynamic>> _countryData() {
List<Map<String, dynamic>> list = [];
INDEX_WORDS.forEach((element) {
for (int i = 0; i < 10; i++) {
list.add(
{"initialsy": element, "country": "$element中国", "code": "+86"});
}
});
return list;
}
2.3 计算每组的偏移量
为了确保通过字母索引跳转时定位正确,我们需要为每个分组计算出相对的滚动偏移量。通过遍历数据列表,将每个字母分组的偏移量保存在 _groupOffsetMap
中。
void _calculateGroupOffsets() {
double groupOffset = 0.0; // 初始化偏移量为 0,确保计算从列表顶部开始
for (int i = 0; i < _data.length; i++) {
if (i == 0) {
_groupOffsetMap[_data[i]["initialsy"]] = groupOffset;
} else if (_data[i]["initialsy"] == _data[i - 1]["initialsy"]) {
groupOffset += _cellHeight; // 累加 item 的高度
} else {
groupOffset += _groupHeight; // 累加组头高度
groupOffset += _cellHeight; // 累加当前项的高度
_groupOffsetMap[_data[i]["initialsy"]] = groupOffset;
}
}
}
2.4 国家列表显示
使用 ListView.builder
动态构建国家列表。每个国家项都包含其国家名称和电话区号。如果国家项属于新的字母组,显示组头。
Widget _countryOrIndexBar() {
return Stack(
children: [
ListView.builder(
controller: _scrollController, // 控制器
itemCount: _data.length,
itemBuilder: (context, index) {
bool isShowT = index > 0 &&
_data[index]["initialsy"] == _data[index - 1]["initialsy"];
return InkWell(
onTap: () {},
child: _CountryCell(
name: "${_data[index]["country"]} ${_data[index]["code"]}",
groupTitle: isShowT ? null : _data[index]["initialsy"],
),
);
},
),
IndexBar(
indexBarCallBack: (String str) {
if (_groupOffsetMap[str] != null) {
_scrollController.animateTo(
_groupOffsetMap[str]!,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOut,
);
}
},
),
],
);
}
2.5 自定义索引条
IndexBar
实现了自定义的字母索引条。用户可以点击或拖动字母条快速定位到指定的国家分组。
class IndexBar extends StatefulWidget {
final void Function(String str)? indexBarCallBack;
IndexBar({this.indexBarCallBack});
@override
State<IndexBar> createState() => _IndexBarState();
}
class _IndexBarState extends State<IndexBar> {
Color _backColor = const Color.fromRGBO(1, 1, 1, 0.0); //背景色
Color _textColor = Color(0xFF999999);
double _indicatorY = 0.0;
bool _indicatorHidden = true;
String _indicatorText = 'A';
int _index = 0;
@override
Widget build(BuildContext context) {
final List<Widget> words = []; //字母索引
for (int i = 0; i < INDEX_WORDS.length; i++) {
words.add(
Expanded(
child: Text(
INDEX_WORDS[i],
style: TextStyle(fontSize: 12, color: _textColor),
),
),
);
}
return Positioned(
right: 13,
top: 113,
height: 408,
width: 110,
child: Row(
children: [
//指示器
Container(
alignment: Alignment(0, _indicatorY),
width: 90,
// color: Colors.red,
child: _indicatorHidden
? null
: Stack(
alignment: const Alignment(-0.2, 0),
children: [
Image(
image: AssetImage("assets/images/bubble.png"),
width: 55),
Text(
_indicatorText,
style: TextStyle(fontSize: 18, color: Colors.white),
)
],
),
),
//索引
GestureDetector(
onVerticalDragDown: (DragDownDetails details) {
int index = getIndexItem(context, details.globalPosition);
//防止多次触发
if (_index == index) return;
_index = index;
widget.indexBarCallBack!(INDEX_WORDS[index]);
setState(() {
_backColor = Colors.transparent; //设置背景颜色
_textColor = Colors.black; //文字显示颜色
_indicatorY =
2.28 / INDEX_WORDS.length * index - 1.14; //改变坐标 Y值
_indicatorText = INDEX_WORDS[index]; //获取对应的字母
_indicatorHidden = false; //是否隐藏指示器
});
},
onVerticalDragEnd: (DragEndDetails details) {
setState(() {
_backColor = const Color.fromRGBO(1, 1, 1, 0.0);
_textColor = Colors.black;
_indicatorHidden = true;
});
},
onVerticalDragUpdate: (DragUpdateDetails details) {
int index = getIndexItem(context, details.globalPosition);
//防止多次触发
if (_index == index) return;
_index = index;
widget.indexBarCallBack!(INDEX_WORDS[index]);
setState(() {
_indicatorY = 2.28 / INDEX_WORDS.length * index - 1.14;
_indicatorText = INDEX_WORDS[index];
_indicatorHidden = false;
});
},
child: Container(
width: 20,
color: _backColor,
child: Column(
children: words,
),
),
)
],
),
);
}
}
3. 重点解析
-
ScrollController
跳转: 通过ScrollController
实现平滑滚动,并结合计算偏移量_calculateGroupOffsets
实现精确的分组跳转。 -
IndexBar
自定义组件:IndexBar
通过字母导航栏实现快速跳转,并显示当前选中字母的浮动指示器。
4. 完整代码
这个实现没有用到插件,可以创建个新项目直接粘贴上去跑起来;有问题或者不懂的可以跑起来后看着代码慢慢调整和理解。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Country List',
home: PhoneArea(),
);
}
}
class PhoneArea extends StatefulWidget {
const PhoneArea({Key? key}) : super(key: key);
@override
State<PhoneArea> createState() => _PhoneAreaState();
}
class _PhoneAreaState extends State<PhoneArea> {
late ScrollController _scrollController; // 滚动控制器
final double _cellHeight = 50; // item的高度
final double _groupHeight = 34.0; // 组头高度
final Map<String, double> _groupOffsetMap = {};
List<Map<String, dynamic>> _data = []; // 国家数据List
@override
void initState() {
super.initState();
_data = _countryData(); // 获取所有国家数据
_scrollController = ScrollController(); // 初始化滚动控制器
WidgetsBinding.instance.addPostFrameCallback((_) {
_calculateGroupOffsets();
});
}
// 计算每组的偏移量,设置到 _groupOffsetMap 中
void _calculateGroupOffsets() {
double groupOffset = 0.0; // 初始化偏移量为 0,确保计算从列表顶部开始
for (int i = 0; i < _data.length; i++) {
if (i == 0) {
// 如果是第一个元素,直接将偏移量添加到字母组
_groupOffsetMap[_data[i]["initialsy"]] = groupOffset;
} else if (_data[i]["initialsy"] == _data[i - 1]["initialsy"]) {
// 如果当前项与前一个项属于同一组,则只累加 item 的高度
groupOffset += _cellHeight;
} else {
// 如果是新的一组,则累加组头高度 + 当前项高度
groupOffset += _groupHeight;
groupOffset += _cellHeight; // 累加当前项的高度
_groupOffsetMap[_data[i]["initialsy"]] = groupOffset;
}
}
}
@override
void dispose() {
super.dispose();
_data.clear();
_groupOffsetMap.clear();
}
@override
Widget build(BuildContext context) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarBrightness: Brightness.dark,
statusBarColor: Colors.transparent,
),
);
return Scaffold(
appBar: AppBar(
title: const Text(
'选择国家和地区',
style: TextStyle(color: Colors.black),
),
backgroundColor: Colors.white,
),
body: _countryOrIndexBar(),
);
}
// 各个国家地区和索引条
Widget _countryOrIndexBar() {
return Stack(
children: [
ListView.builder(
controller: _scrollController, // 控制器
itemCount: _data.length,
itemBuilder: (context, index) {
bool isShowT = index > 0 &&
_data[index]["initialsy"] == _data[index - 1]["initialsy"];
return InkWell(
onTap: () {},
child: _CountryCell(
name: "${_data[index]["country"]} ${_data[index]["code"]}",
groupTitle: isShowT ? null : _data[index]["initialsy"],
),
);
},
),
IndexBar(
indexBarCallBack: (String str) {
if (_groupOffsetMap[str] != null) {
_scrollController.animateTo(
_groupOffsetMap[str]!,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOut,
);
}
},
),
],
);
}
// 假数据
List<Map<String, dynamic>> _countryData() {
List<Map<String, dynamic>> list = [];
INDEX_WORDS.forEach((element) {
for (int i = 0; i < 10; i++) {
list.add(
{"initialsy": element, "country": "$element中国", "code": "+86"});
}
});
return list;
}
}
// 国家列表项
class _CountryCell extends StatelessWidget {
final String? name; // 名称
final String? groupTitle; // 组头标题
const _CountryCell({Key? key, this.name, this.groupTitle}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
if (groupTitle != null)
Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 16),
height: 34,
color: const Color(0xFFF5F6F8),
child: Text(
groupTitle!,
style: const TextStyle(color: Color(0xFF999999)),
),
),
Container(
color: Colors.white,
child: Column(
children: [
Container(
alignment: Alignment.centerLeft,
height: 49.5,
padding: const EdgeInsets.only(left: 16),
child: Text(
name!,
style: const TextStyle(fontSize: 16, color: Colors.black),
),
),
const Divider(height: 0.5, color: Color(0xFFCCCCCC)),
],
),
),
],
);
}
}
/*
* 索引条
*/
class IndexBar extends StatefulWidget {
final void Function(String str)? indexBarCallBack;
IndexBar({this.indexBarCallBack});
@override
State<IndexBar> createState() => _IndexBarState();
}
class _IndexBarState extends State<IndexBar> {
Color _backColor = const Color.fromRGBO(1, 1, 1, 0.0); //背景色
Color _textColor = Color(0xFF999999);
double _indicatorY = 0.0;
bool _indicatorHidden = true;
String _indicatorText = 'A';
int _index = 0;
@override
Widget build(BuildContext context) {
final List<Widget> words = []; //字母索引
for (int i = 0; i < INDEX_WORDS.length; i++) {
words.add(
Expanded(
child: Text(
INDEX_WORDS[i],
style: TextStyle(fontSize: 12, color: _textColor),
),
),
);
}
return Positioned(
right: 13,
top: 113,
height: 408,
width: 110,
child: Row(
children: [
//指示器
Container(
alignment: Alignment(0, _indicatorY),
width: 90,
// color: Colors.red,
child: _indicatorHidden
? null
: Stack(
alignment: const Alignment(-0.2, 0),
children: [
Image(
image: AssetImage("assets/images/bubble.png"),
width: 55),
Text(
_indicatorText,
style: TextStyle(fontSize: 18, color: Colors.white),
)
],
),
),
//索引
GestureDetector(
onVerticalDragDown: (DragDownDetails details) {
int index = getIndexItem(context, details.globalPosition);
//防止多次触发
if (_index == index) return;
_index = index;
widget.indexBarCallBack!(INDEX_WORDS[index]);
setState(() {
_backColor = Colors.transparent; //设置背景颜色
_textColor = Colors.black; //文字显示颜色
_indicatorY =
2.28 / INDEX_WORDS.length * index - 1.14; //改变坐标 Y值
_indicatorText = INDEX_WORDS[index]; //获取对应的字母
_indicatorHidden = false; //是否隐藏指示器
});
},
onVerticalDragEnd: (DragEndDetails details) {
setState(() {
_backColor = const Color.fromRGBO(1, 1, 1, 0.0);
_textColor = Colors.black;
_indicatorHidden = true;
});
},
onVerticalDragUpdate: (DragUpdateDetails details) {
int index = getIndexItem(context, details.globalPosition);
//防止多次触发
if (_index == index) return;
_index = index;
widget.indexBarCallBack!(INDEX_WORDS[index]);
setState(() {
_indicatorY = 2.28 / INDEX_WORDS.length * index - 1.14;
_indicatorText = INDEX_WORDS[index];
_indicatorHidden = false;
});
},
child: Container(
width: 20,
color: _backColor,
child: Column(
children: words,
),
),
)
],
),
);
}
}
int getIndexItem(BuildContext context, Offset globalPosition) {
//拿到当前盒子
RenderBox box = context.findRenderObject() as RenderBox;
//拿到y值,当前位置到部件原点(部件左上角)的距离(x,y)
var y = box.globalToLocal(globalPosition).dy;
//算出字符高度
var itemHeight = MediaQuery.of(context).size.height / 2 / INDEX_WORDS.length;
int index =
y ~/ itemHeight.clamp(0, INDEX_WORDS.length - 1); //~取整,设置取整范围clamp
if (index > 25) {
index = 25;
}
return index;
}
const INDEX_WORDS = [
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z'
];
希望本文对你在Flutter中实现自定义侧边栏导航和基于字母索引的列表跳转有所帮助!!!
标签:index,const,自定义,INDEX,WORDS,跳转,return,data,选择器 From: https://blog.csdn.net/qq_52871405/article/details/142480623