首页 > 其他分享 >Flutter 自定义国家选择器:基于 A ~ Z字母索引的列表跳转与侧边栏导航实现

Flutter 自定义国家选择器:基于 A ~ Z字母索引的列表跳转与侧边栏导航实现

时间:2024-09-25 16:51:13浏览次数:3  
标签:index const 自定义 INDEX WORDS 跳转 return data 选择器

在许多移动应用中,我们经常需要通过字母索引快速跳转到目标位置,比如通讯录、国家选择等功能。这篇博客将带大家实现一个仿照通讯录的 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. 重点解析
  1. ScrollController 跳转: 通过 ScrollController 实现平滑滚动,并结合计算偏移量 _calculateGroupOffsets 实现精确的分组跳转。

  2. 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

相关文章

  • 自定义表格样式
     HTML:<divclass="table-container"><tablestyle="width:90%;margin-left:5%"><trclass="table-title"><thstyle="width:33%&qu......
  • 多用户自定义商城小程序源码系统 独立部署 到源代码包以及搭建部署教程
    系统概述随着移动互联网的迅猛发展,消费者的购物习惯逐渐向线上转移。传统电商平台虽然提供了一定的便利,但也存在一些局限性,如高昂的入驻费用、缺乏个性化定制等。为了适应市场需求,多用户自定义商城小程序源码系统应运而生。这一系统的开发旨在为企业和商家提供一个自主掌控、......
  • CSS选择器
    选择器由HTML元素的id、class属性或元素名本身以及一些特殊符号构成,用来指定要为哪个HTML元素定义样式。选择器是CSS样式规则中重要的组成部分,我们可以将选择器看作是CSS样式与HTML元素之间的匹配模式,与选择器关联的样式规则会应用于选择器所指定的HTML元素上......
  • 自定义类型:结构体
    1.结构体类型的声明structtag{member-list;}variable-list;例如描述一个学生:structStu{charname[20];//名字intage;//年龄charsex[5];//性别charid[20];//学号};//分号不能丢structStu{charname[20];//名字intage;//年龄char......
  • 关于在vue2中自定义右键弹窗
            所需变量//右键点击的弹框对象rightDialogbox:null,//鼠标点击后获取的文本chooseText:'',//弹窗的偏移left:'',top:'',//右键点击的弹框显隐rightDialogShow:false,一、阻止原生事件......
  • PyTorch自定义学习率调度器实现指南
    在深度学习训练过程中,学习率调度器扮演着至关重要的角色。这主要是因为在训练的不同阶段,模型的学习动态会发生显著变化。在训练初期,损失函数通常呈现剧烈波动,梯度值较大且不稳定。此阶段的主要目标是在优化空间中快速接近某个局部最小值。然而,过高的学习率可能导致模型跳过潜在的......
  • helm初始化自定义应用
    使用Helm初始化一个应用(即创建一个HelmChart),可以通过Helm提供的helmcreate命令生成一个基础的HelmChart目录结构。下面是具体步骤:1.安装Helm首先确保你的环境中已经安装了Helm。如果还没有安装,可以按照Helm官方文档进行安装:Helm安装文档2.初始化HelmCh......
  • Android连接蓝牙自定义封装SDK(基于Cordova与ionic)
    今天给大家分享一款基于Cordova与ionic框架自定义封装的Android手机连接蓝牙的插件。自己公司遇到的业务需求是,与第三方公司合作,需要在项目现场打印项目物资与物料验收单,后期提供给财务核对报销等。第三方公司提供蓝牙打印机与手持机,我们需要自己结合业务开发相对应的功能。......
  • OpenCV_自定义线性滤波(filter2D)应用详解
    OpenCVfilter2D将图像与内核进行卷积,将任意线性滤波器应用于图像。支持就地操作。当孔径部分位于图像之外时,该函数根据指定的边界模式插值异常像素值。卷积核本质上是一个固定大小的系数数组,数组中的某个元素被作为锚点(一般是数组的中心)。上面讲了线性滤波的实质就是计算相......
  • vue自定义指令实现打字效果
    实现如通义灵码官网关于代码片段中,当鼠标hover上代码上时,出现打字效果,示例地址:https://tongyi.aliyun.com/lingma?spm=5176.28508143.J_ahRFo5CaAe_asSOaCgS4J.14.5421154auHz4xJ&scm=20140722.M_185502201.P_131.MO_2276-ID_10360025-MID_10360025-CID_31292-ST_10352-V_1通过vu......