前言

在像通讯录,联系人列表,城市选择列表等数据量比较多的长列表页面中,我们经常会留意到产品设计会在页面的右侧区域提供一个竖向的字母索引列表,供用户点击选择快速定位到长列表中的指定索引位置,以便于用户快速定位到自己要筛选的数据,从而提升用户体验,今天我们就以城市列表为例,来剖析一下,这样的体验效果如果用Flutter来实现

技术实现分析

  • 页面城市列表布局采用ListView嵌套ListView,其中外层ListView负责显示当前城市的分组字母信息,内层Listview负责显示当前字母索引分组下的所有城市
  • 右侧竖向字母索引列表采用ListView展示,当点击右侧字母索引的某一个时,动态的计算出,当前字母索引在城里列表的角标索引值,然后计算得出被点击的字符索引在城里列表里的高度值,通过 ScrollController让城里列表自动定位到计算出的高度位置,从而实现,点击字母所以动态定位城市列表位置的联动效果

效果如下

21  Flutter通讯录列表字母索引 - 图1

我先把上述效果的相关代码贴出来供大家参考,然后再把代码里涉及到计算位置的核心代码做下详细的讲解,代码里涉及到网络请求,以及网络工具类的封装这里就不再赘述了,读者可以查看我往期博客关于Flutter入门进阶之旅(十七)Flutter dio网络请求里的具体讲解,或者从github上直接找到源代码阅读https://github.com/xiedong11/flutter_app

核心代码

  1. /**
  2. * @desc 选择城市地区联动索引页
  3. * @author xiedong
  4. * @date 2020-04-30.
  5. */
  6. class PhoneCountryCodePage extends StatefulWidget {
  7. @override
  8. State<StatefulWidget> createState() => PageState();
  9. }
  10. class PageState extends State<PhoneCountryCodePage> {
  11. var GET_PHONE_COUNTRY_CODE_URL =
  12. "https://raw.githubusercontent.com/xiedong11/flutter_app/master/static/phoneCode.json";
  13. List<String> letters = [];
  14. List<PhoneCountryCodeData> data;
  15. ScrollController _scrollController = ScrollController();
  16. int _currentIndex = 0;
  17. @override
  18. void initState() {
  19. super.initState();
  20. getPhoneCodeDataList();
  21. }
  22. getPhoneCodeDataList() async {
  23. var response = await DioUtils.getInstance().get(GET_PHONE_COUNTRY_CODE_URL);
  24. var resultEntity = new PhoneCountryCodeEntity.fromJson(json.decode(response));
  25. if(resultEntity.code==200){
  26. this.setState(() {
  27. data = resultEntity.data;
  28. for (int i = 0; i < data.length; i++) {
  29. letters.add(data[i].name.toUpperCase());
  30. }
  31. });
  32. }
  33. }
  34. @override
  35. Widget build(BuildContext context) {
  36. return Scaffold(
  37. appBar: AppBar(
  38. title: Text("城市地区选择"),
  39. centerTitle: true,
  40. ),
  41. body: Stack(
  42. children: <Widget>[
  43. data == null || data.length == 0
  44. ? Text("")
  45. : Padding(
  46. padding: EdgeInsets.only(left: 20),
  47. child: ListView.builder(
  48. controller: _scrollController,
  49. itemCount: data.length,
  50. itemBuilder: (BuildContext context, int index) {
  51. return Column(
  52. crossAxisAlignment: CrossAxisAlignment.start,
  53. children: <Widget>[
  54. PhoneCodeIndexName(data[index].name.toUpperCase()),
  55. ListView.builder(
  56. itemBuilder:
  57. (BuildContext context, int index2) {
  58. return Container(
  59. height: 46,
  60. child: GestureDetector(
  61. // behavior: HitTestBehavior.translucent,
  62. child: Padding(
  63. padding:
  64. EdgeInsets.symmetric(vertical: 10),
  65. child: Row(
  66. children: <Widget>[
  67. Text(
  68. "${data[index].listData[index2].name}",
  69. style: TextStyle(
  70. fontSize: 16,
  71. color: Color(0xff434343))),
  72. Margin(width: 10),
  73. Text(
  74. "+${data[index].listData[index2].code}",
  75. style: TextStyle(
  76. fontSize: 16,
  77. color: Color(0xffD6D6D6)),
  78. )
  79. ],
  80. ),
  81. ),
  82. onTap: () {
  83. Navigator.of(context).pop(
  84. data[index].listData[index2].code);
  85. },
  86. ),
  87. );
  88. },
  89. itemCount: data[index].listData.length,
  90. shrinkWrap: true,
  91. physics:
  92. NeverScrollableScrollPhysics()) //禁用滑动事件),
  93. ],
  94. );
  95. }),
  96. ),
  97. Align(
  98. alignment: new FractionalOffset(1.0, 0.5),
  99. child: SizedBox(
  100. width: 25,
  101. child: Padding(
  102. padding: EdgeInsets.only(top: 20),
  103. child: ListView.builder(
  104. itemCount: letters.length,
  105. itemBuilder: (BuildContext context, int index) {
  106. return GestureDetector(
  107. child: Text(
  108. letters[index],
  109. style: TextStyle(color: Colors.black),
  110. ),
  111. onTap: () {
  112. setState(() {
  113. _currentIndex = index;
  114. });
  115. var height = index * 45.0;
  116. for (int i = 0; i < index; i++) {
  117. height += data[i].listData.length * 46.0;
  118. }
  119. _scrollController.jumpTo(height);
  120. },
  121. );
  122. },
  123. ),
  124. ),
  125. ),
  126. )
  127. ],
  128. ),
  129. );
  130. }
  131. }
  132. class PhoneCodeIndexName extends StatelessWidget {
  133. String indexName;
  134. PhoneCodeIndexName(this.indexName);
  135. Widget build(BuildContext context) {
  136. return Container(
  137. height: 45,
  138. child: Padding(
  139. child: Text(indexName,
  140. style: TextStyle(fontSize: 20, color: Color(0xff434343))),
  141. padding: EdgeInsets.symmetric(vertical: 10),
  142. ),
  143. );
  144. }
  145. }

Json数据映射实体类

  1. class PhoneCountryCodeEntity {
  2. int code;
  3. List<PhoneCountryCodeData> data;
  4. String message;
  5. PhoneCountryCodeEntity({this.code, this.data, this.message});
  6. PhoneCountryCodeEntity.fromJson(Map<String, dynamic> json) {
  7. code = json['code'];
  8. if (json['data'] != null) {
  9. data = new List<PhoneCountryCodeData>();
  10. (json['data'] as List).forEach((v) {
  11. data.add(new PhoneCountryCodeData.fromJson(v));
  12. });
  13. }
  14. message = json['message'];
  15. }
  16. Map<String, dynamic> toJson() {
  17. final Map<String, dynamic> data = new Map<String, dynamic>();
  18. data['code'] = this.code;
  19. if (this.data != null) {
  20. data['data'] = this.data.map((v) => v.toJson()).toList();
  21. }
  22. data['message'] = this.message;
  23. return data;
  24. }
  25. }
  26. class PhoneCountryCodeData {
  27. List<PhoneCountryCodeDataListdata> listData;
  28. String name;
  29. PhoneCountryCodeData({this.listData, this.name});
  30. PhoneCountryCodeData.fromJson(Map<String, dynamic> json) {
  31. if (json['listData'] != null) {
  32. listData = new List<PhoneCountryCodeDataListdata>();
  33. (json['listData'] as List).forEach((v) {
  34. listData.add(new PhoneCountryCodeDataListdata.fromJson(v));
  35. });
  36. }
  37. name = json['name'];
  38. }
  39. Map<String, dynamic> toJson() {
  40. final Map<String, dynamic> data = new Map<String, dynamic>();
  41. if (this.listData != null) {
  42. data['listData'] = this.listData.map((v) => v.toJson()).toList();
  43. }
  44. data['name'] = this.name;
  45. return data;
  46. }
  47. }
  48. class PhoneCountryCodeDataListdata {
  49. String code;
  50. String name;
  51. int id;
  52. String groupCode;
  53. PhoneCountryCodeDataListdata({this.code, this.name, this.id, this.groupCode});
  54. PhoneCountryCodeDataListdata.fromJson(Map<String, dynamic> json) {
  55. code = json['code'];
  56. name = json['name'];
  57. id = json['id'];
  58. groupCode = json['groupCode'];
  59. }
  60. Map<String, dynamic> toJson() {
  61. final Map<String, dynamic> data = new Map<String, dynamic>();
  62. data['code'] = this.code;
  63. data['name'] = this.name;
  64. data['id'] = this.id;
  65. data['groupCode'] = this.groupCode;
  66. return data;
  67. }
  68. }

博客中Json数据的测试地址为:https://raw.githubusercontent.com/xiedong11/flutter_app/master/static/phoneCode.json 读者测试代码时如果自己的json数据格式跟我的不一样,应该对应自己的json数据格式去解析对应的实体类,确保数据能正确的绑定到视图上。

难点分析

文章开头当点击右侧字母索引的某一个时,动态的计算出当前字母索引在城里列表的角标索引值,然后计算得出被点击的字符索引在城里列表里的高度值,通过 ScrollController让城里列表自动定位到计算出的高度位置,从而实现,点击字母所以动态定位城市列表位置的联动效果。

21  Flutter通讯录列表字母索引 - 图2

我们通过分析上图可以得出,当我们点击需要定位到的指定位置时,我们只需要计算出当前被点击的字母索引之前的所有item的累加高度值,然后通过 ListView中的 ScrollController.jumpTo(double value)滑动到指定的高度值,就完成了这整个联动效果。

这里,我们通过遍历我们从网络上获取数据值,很容易计算出需要滑动的高度累加值

核心代码如下

  1. var height = index * 45.0;
  2. for (int i = 0; i < index; i++) {
  3. height += data[i].listData.length * 46.0;
  4. }
  5. _scrollController.jumpTo(height);

至此整个实现效果已经完成,完整的项目跟代码请参阅:Flutter进阶之旅专栏