Flutter模仿微信通讯录列表

用flutter模仿微信通讯录列表写的一个demo,具体效果看gif图

list .gif

下面附上代码和 Demo,注释写的还是比较清楚的,这里就不做一一介绍了,

入口是ListPage类,

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:list/list/model/user_name.dart';
import 'package:list/list/pages/common.dart';
import 'package:list/list/pages/index_bar.dart';
import 'package:list/list/pages/item_cell.dart';
import 'package:list/list/pages/search_widget.dart';

class ListPage extends StatefulWidget {
  const ListPage({super.key});

  @override
  State<ListPage> createState() => _ListPageState();
}

class _ListPageState extends State<ListPage> {
  List<DataList> _data = [];
  final List<DataList> _dataList = []; //数据
  late ScrollController _scrollController;
  //字典 里面放item和高度对应的数据
  final Map<String, double> _groupOffsetMap = {
    INDEX_WORDS[0]: 0.0, //放大镜
    INDEX_WORDS[1]: 0.0, //⭐️
  };
  String searchStr = '';
  @override
  void initState() {
    _load();
    super.initState();
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _load() async {
    String jsonData = await loadJsonFromAssets('assets/data.json');
    Map<String, dynamic> dict = json.decode(jsonData);
    List<dynamic> list = dict['data_list'];
    _data = list.map((e) => DataList.fromJson(e)).toList();
    // 排序
    _data.sort((a, b) => a.indexLetter.compareTo(b.indexLetter));

    _dataList.addAll(_data);
    // 循环计算,将每个头的位置算出来,放入字典
    var groupOffset = 0.0;
    for (int i = 0; i < _dataList.length; i++) {
      if (i < 1) {
        //第一个cell一定有头
        _groupOffsetMap.addAll({_dataList[i].indexLetter: groupOffset});
        groupOffset += cellHeight + cellHeaderHeight;
      } else if (_dataList[i].indexLetter == _dataList[i - 1].indexLetter) {
        // 相同的时候只需要加cell的高度
        groupOffset += cellHeight;
      } else {
        //第一个cell一定有头
        _groupOffsetMap.addAll({_dataList[i].indexLetter: groupOffset});
        groupOffset += cellHeight + cellHeaderHeight;
      }
    }
    print('dc------$_groupOffsetMap');
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('通讯录'),
      ),
      body: Stack(
        children: [
          //列表
          Column(
            children: [
              // 搜索框
              SearchWidget(
                onSearchChange: (text) {
                  _dataList.clear();
                  searchStr = text;
                  if (text.isNotEmpty) {
                    for (int i = 0; i < _data.length; i++) {
                      String name = _data[i].name;
                      if (name.contains(text)) {
                        _dataList.add(_data[i]);
                      }
                    }
                  } else {
                    _dataList.addAll(_data);
                  }
                  setState(() {});
                },
              ),
              Expanded(
                child: ListView.builder(
                  controller: _scrollController,
                  itemCount: _dataList.length,
                  itemBuilder: _itemForRow,
                ),
              ),
            ],
          ),
          // 索引条
          Positioned(
            right: 0.0,
            top: screenHeight(context) / 8,
            height: screenHeight(context) / 2,
            width: indexBarWidth,
            child: IndexBarWidget(
              indexBarCallBack: (str) {
                print('拿到索引条选中的字符:$str');
                if (_groupOffsetMap[str] != null) {
                  _scrollController.animateTo(
                    _groupOffsetMap[str]!,
                    duration: const Duration(microseconds: 100),
                    curve: Curves.easeIn,
                  );
                } else {}
              },
            ),
          ),
        ],
      ),
    );
  }

  Widget? _itemForRow(BuildContext context, int index) {
    DataList user = _dataList[index];
    //是否显示组名字
    bool hiddenTitle = index > 0 &&
        _dataList[index].indexLetter == _dataList[index - 1].indexLetter;
    return ItemCell(
      imageUrl: user.imageUrl,
      name: user.name,
      groupTitle: hiddenTitle ? null : user.indexLetter,
    );
  }
}

这是每个cell的内容显示

import 'package:flutter/material.dart';
import 'package:list/list/pages/common.dart';

class ItemCell extends StatelessWidget {
  final String? imageUrl;
  final String name;
  final String? groupTitle;
  const ItemCell({
    super.key,
    this.imageUrl,
    required this.name,
    this.groupTitle,
  });

  @override
  Widget build(BuildContext context) {
    // TextStyle normalStyle = const TextStyle(
    //   fontSize: 16,
    //   color: Colors.black,
    // );
    // TextStyle highlightStyle = const TextStyle(
    //   fontSize: 16,
    //   color: Colors.green,
    // );
    return Column(
      children: [
        Container(
          alignment: Alignment.centerLeft,
          padding: const EdgeInsets.only(left: 10.0),
          height: groupTitle != null ? cellHeaderHeight : 0.0,
          color: Colors.grey,
          child: groupTitle != null ? Text(groupTitle!) : null,
        ),
        SizedBox(
          height: cellHeight,
          child: ListTile(
            leading: Container(
              width: 40,
              height: 40,
              decoration: BoxDecoration(
                color: Colors.red,
                image: imageUrl == null
                    ? null
                    : DecorationImage(
                        image: NetworkImage(imageUrl!),
                        fit: BoxFit.cover,
                      ),
                borderRadius: const BorderRadius.all(
                  Radius.circular(4),
                ),
              ),
            ),
            title: _title(name),
          ),
        ),
      ],
    );
  }

  Widget _title(String name) {
     //这里是让搜索的字体显示高亮状态
    // List<TextSpan> spans = [];
    // List<String> strs = name.split(searchStr);
    // for (int i = 0; i < strs.length; i++) {
    //   String str = strs[i];
    //   if (str == ''&&i<strs.length-1) {
    //     spans.add(TextSpan(text: searchStr, style: highlightStyle));
    //   } else {
    //     spans.add(TextSpan(text: str, style: normalStyle));
    //     if (i < strs.length - 1) {
    //       spans.add(TextSpan(text: searchStr, style: highlightStyle));
    //     }
    //   }
    // }
    // return RichText(text: TextSpan(children: spans));
    return Text(name);
  }
}

这是搜索框

import 'package:flutter/material.dart';
import 'package:list/list/pages/common.dart';

class SearchWidget extends StatefulWidget {
  final void Function(String) onSearchChange;
  const SearchWidget({
    super.key,
    required this.onSearchChange,
  });

  @override
  State<SearchWidget> createState() => _SearchWidgetState();
}

class _SearchWidgetState extends State<SearchWidget> {
  bool _isShowClear = false;
  final TextEditingController _textEditingController = TextEditingController();
  @override
  void dispose() {
    _textEditingController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 44,
      color: Colors.red,
      child: Row(
        children: [
          Container(
            width: screenWidth(context) - 20,
            height: 34,
            margin: const EdgeInsets.only(left: 10, right: 10.0),
            padding: const EdgeInsets.only(left: 10, right: 10.0),
            decoration: const BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.all(Radius.circular(6)),
            ),
            child: Row(
              children: [
                const Icon(Icons.search),
                Expanded(
                  child: TextField(
                    onChanged: _onChange,
                    controller: _textEditingController,
                    decoration: const InputDecoration(
                      hintText: '请输入搜索内容',
                      border: InputBorder.none,
                      contentPadding: EdgeInsets.only(
                        left: 10,
                        bottom: 12,
                      ),
                    ),
                  ),
                ),
                if (_isShowClear)
                  GestureDetector(
                    onTap: () {
                      _textEditingController.clear();
                      setState(() {
                        _onChange('');
                      });
                    },
                    child: const Icon(Icons.cancel),
                  ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  _onChange(String text) {
    _isShowClear = text.isNotEmpty;
    widget.onSearchChange(text);
  }
}

这是右侧的索引条

import 'package:flutter/material.dart';
import 'package:list/list/pages/common.dart';

class IndexBarWidget extends StatefulWidget {
  final void Function(String str) indexBarCallBack;
  const IndexBarWidget({
    super.key,
    required this.indexBarCallBack,
  });

  @override
  State<IndexBarWidget> createState() => _IndexBarWidgetState();
}

class _IndexBarWidgetState extends State<IndexBarWidget> {
  Color _bkColor = const Color.fromRGBO(1, 1, 1, 0.0);
  Color _textColor = Colors.black;

  double _indicatorY = 0.0;
  String _indicatorStr = 'A';
  bool _indicatorShow = false;
  @override
  void initState() {
    super.initState();
  }

// 获取选中的字符
  int getIndex(BuildContext context, Offset globalPosition) {
    // 拿到点前小部件(Container)的盒子
    RenderBox renderBox = context.findRenderObject() as RenderBox;
    // 拿到y值
    double y = renderBox.globalToLocal(globalPosition).dy;
    // 算出字符高度
    double itemHeight = renderBox.size.height / INDEX_WORDS.length;
    // 算出第几个item
    // int index = y ~/ itemHeight;
    // 为了防止滑出区域后出现问题,所以index应该有个取值范围
    int index = (y ~/ itemHeight).clamp(0, INDEX_WORDS.length - 1);
    return index;
  }

  @override
  Widget build(BuildContext context) {
    //索引条
    final List<Widget> wordsList = [];
    for (var i = 0; i < INDEX_WORDS.length; i++) {
      wordsList.add(
        Expanded(
          child: Text(
            INDEX_WORDS[i],
            style: TextStyle(
              color: _textColor,
              fontSize: 14.0,
            ),
          ),
        ),
      );
    }
    return Row(
      children: [
        Container(
          alignment: Alignment(0.0, _indicatorY),
          width: indexBarWidth - 20.0,
          // color: Colors.red,
          child: _indicatorShow
              ? Stack(
                  alignment: const Alignment(-0.1, 0),
                  children: [
                    //应该放一张图片,没找到合适的,就用Container代替
                    Container(
                      width: 60.0,
                      height: 60.0,
                      decoration: const BoxDecoration(
                        color: Colors.green,
                        borderRadius: BorderRadius.all(
                          Radius.circular(30.0),
                        ),
                      ),
                    ),
                    Text(
                      _indicatorStr,
                      style: const TextStyle(
                        color: Colors.white,
                        fontSize: 28.0,
                        fontWeight: FontWeight.bold,
                      ),
                    )
                  ],
                )
              : null,
        ),
        GestureDetector(
          onVerticalDragDown: (details) {
            int index = getIndex(context, details.globalPosition);
            widget.indexBarCallBack(INDEX_WORDS[index]);
            setState(() {
              _bkColor = const Color.fromRGBO(1, 1, 1, 0.5);
              _textColor = Colors.white;

              //显示气泡
              _indicatorY = 2.2 / INDEX_WORDS.length * index - 1.1;
              _indicatorStr = INDEX_WORDS[index];
              _indicatorShow = true;
            });
          },
          onVerticalDragEnd: (details) {
            setState(() {
              _bkColor = const Color.fromRGBO(1, 1, 1, 0.0);
              _textColor = Colors.black;

              // 隐藏气泡
              _indicatorShow = false;
            });
          },
          onVerticalDragUpdate: (details) {
            int index = getIndex(context, details.globalPosition);
            widget.indexBarCallBack(INDEX_WORDS[index]);

            //显示气泡
            setState(() {
              _indicatorY = 2.2 / INDEX_WORDS.length * index - 1.1;
              _indicatorStr = INDEX_WORDS[index];
              _indicatorShow = true;
            });
          },
          child: Container(
            color: _bkColor,
            width: 20.0,
            child: Column(
              children: wordsList,
            ),
          ),
        ),
      ],
    );
  }
}

这是定义的宏

//cell头的高度
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

const double cellHeaderHeight = 30.0;
// cell的高度
const double cellHeight = 50.0;
// cell的高度
const double indexBarWidth = 130.0;

double screenWidth(BuildContext context) => MediaQuery.of(context).size.width;
double screenHeight(BuildContext context) => MediaQuery.of(context).size.height;

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',
];

//从本地加载json数据
Future<String> loadJsonFromAssets(String fileName) async {
  return await rootBundle.loadString(fileName);
}

demo传送门

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,734评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,931评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,133评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,532评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,585评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,462评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,262评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,153评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,587评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,792评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,919评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,635评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,237评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,855评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,983评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,048评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,864评论 2 354

推荐阅读更多精彩内容