Flutter实战技巧之-仿美团城市联动选择

前言

最近接到一个小的需求:选择不同城市,显示对应城市的天气。其实这种需求在很多生活类的APP中也有,实现的功能也相差无几。对于一个经常点外卖的人来说,美团、饿了么用的比较多。打开美团看了一下,然后决定模仿美团做一个城市选择。用了大概两天时间做出来,先看看效果图吧(效果图里面的定位失败是因为使用的是模拟器)。

image

功能分析

页面主要由两个重要的部分组成:左边城市选择列表和右边字母导航条。城市选择列表由一个header和一个列表组成,列表数据按照字母A到Z进行排序。点击和滑动字母导航条时,屏幕中出现选中的字母方块,列表内容跳转到选中的字母开头的城市数据item位置。

具体实现

既然我们把整个页面分成了两个部分,那么我们就分为两个部分来依次完成。

城市选择列表的实现

城市选择列表,主要就是一个由一个带头部的列表组成。其中列表控件必须具有主动滚动的功能,这里我们可以用到ScrollController.scrollTo()\ScrollController.jumpTo()这两种方法,主要的区别是scrollTo可以加入duration参数。那么怎么样才能实现点击右边导航条中的字母,列表就滚动到对应字母的header处呢?来看看具体代码是怎么样实现的。布局代码我就不贴出了,主要看 一些核心的代码。

1.首先获取城市数据

 void _findBaseDictCity() {
    ApiInterface.findBaseDictCity(context).then((value) {
      (value['data'] as List).forEach((element) {
        CityEntity entity = CityEntity().fromJson(element);
        String cityName = entity.name;
        String firstPinyin = PinyinHelper.getFirstWordPinyin(cityName).substring(0, 1).toUpperCase();
        _dataMap.putIfAbsent(firstPinyin, () => List()).add(entity);
      });
      _mapKeysList = _dataMap.keys.toList();
      _mapKeysList.sort((a, b) {
        List<int> al = a.codeUnits;
        List<int> bl = b.codeUnits;
        for (int i = 0; i < al.length; i++) {
          if (bl.length <= i) return 1;
          if (al[i] > bl[i]) {
            return 1;
          } else if (al[i] < bl[i]) return -1;
        }
        return 0;
      });
      if (mounted) {
        setState(() {});
      }
    }).catchError((e) {});
  }

这里我用到的_dataMap和_mapKeysList是分别存储所有城市数据所有城市首字母数据的。其中对_mapKeysList中的数据进行了A-Z的排序。这里呢用到了一个PinyinHelper来获取城市名的首字母,这是一个汉字转拼音的库,感兴趣的朋友可以深入研究一下。所需要的数据 整理完毕后,就需要构建我们想要的列表了。

2.构建显示城市的列表

  //构建显示所有城市的列表
  Widget _buildAllCityList() {
    return ScrollablePositionedList.builder(
      itemCount: _dataMap == null ? 0 : _dataMap.length,
      itemBuilder: (context, index) {
        String key = _mapKeysList[index];
        List<CityEntity> cityList = _dataMap[key];
        return Container(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Offstage(
                offstage: index != 0,
                child: _buildPageHeader(),
              ),
              Padding(
                padding: EdgeInsets.only(left: 20, right: 20),
                child: Text('$key'),
              ),
              ListView.builder(
                itemBuilder: (context, index) {
                  CityEntity entity = cityList[index];
                  return _buildCityItem(entity);
                },
                itemCount: cityList == null ? 0 : cityList.length,
                shrinkWrap: true,
                padding: EdgeInsets.all(0),
                physics: NeverScrollableScrollPhysics(),
              )
            ],
          ),
        );
      },
      itemScrollController: _itemScrollController,
      itemPositionsListener: _itemPositionsListener,
    );
  }

在这个列表中使用了ScrollablePositionedList和ListView进行嵌套。ScrollablePositionedList提供了一个itemScrollController.jumpTo(index:index )方法,可以直接滚动到index处。ScrollablePositionedList的itemBuilder返回的是一个Text+ListView的组合,Text用于显示首字母,ListView用于展示当前首字母下所有的城市。注意:两个List列表嵌套时NeverScrollableScrollPhysics()不能少,它能解决嵌套出现的滑动冲突的问题。
此时左边的列表基本已经完成,剩下的就是右边导航条。

字母导航条的实现

先来分析一下。这个导航条显示的内容是列表中所有城市的首字母,点击导航条和在导航条上滑动时屏幕中件位置会出现对应字母的方块,并且列表会滚到对应字母的Item位置。滚动方法我们已经有了,只需要找到 触发事件时对应的字母就行了。因为这里涉及到了点击滑动两个方法,所以自然就想到了Flutter中的GestureDetector,它可以提供多种点击、滑动事件的回调。这里主要涉及到了onVerticalDragDownonVerticalDragUpdateonVerticalDragEndonTapUp四个事件回调。这里我就直接贴出导航条的全部代码,可以直接使用。

///垂直导航条
class VerticalGuideView extends StatefulWidget {
  VerticalGuideView({Key key, this.dataList, this.onTap})
      : assert(dataList != null),
        super(key: key);
  final List<String> dataList;
  final Function(DragSelectedInfo dragSelectedInfo) onTap;

  @override
  State<StatefulWidget> createState() {
    return VerticalGuideViewState();
  }
}

class VerticalGuideViewState extends State<VerticalGuideView> {
  double _widgetTop = -1; //整个布局Y轴上高度
  final double itemHeight = 32.w; //单个item高度
  bool _isTapDown = false;

  @override
  Widget build(BuildContext context) {
    List<Widget> children = List();
    widget.dataList.forEach((element) {
      children.add(SizedBox(
        height: itemHeight,
        width: itemHeight,
        child: Text(
          '$element',
          style: TextStyle(
            fontSize: 28.sp,
            color: Colours.color_22,
            fontWeight: FontWeight.w400,
          ),
          textAlign: TextAlign.center,
        ),
      ));
    });
    return Container(
      color: _isTapDown ? Colours.translucent : Colors.transparent,
      alignment: Alignment.center,
      child: GestureDetector(
        onVerticalDragDown: (detail) {
          _isTapDown = true;
          //手指触及到时开始计算 整个布局的初始高度 _widgetTop
          if (_widgetTop < 0) {
            RenderBox box = context.findRenderObject();
            Offset topLeftPosition = box.localToGlobal(Offset.zero);
            _widgetTop = topLeftPosition.dy;
          }
          //手指触及的位置 - 布局高度  计算出offSetY即 触及位置到布局顶部距离
          double offsetY = detail.globalPosition.dy - _widgetTop;
          int index = _getIndex(offsetY);
          if (index != -1) {
            _triggerTouchEvent(DragSelectedInfo(index: index, tag: widget.dataList[index], dragStatus: DragStatus.DragDown));
          }
        },
        onVerticalDragEnd: (detail) {
          _isTapDown = false;
          _triggerTouchEvent(DragSelectedInfo(dragStatus: DragStatus.DragEnd));
        },
        onVerticalDragUpdate: (detail) {
          double offsetY = detail.globalPosition.dy - _widgetTop;
          int index = _getIndex(offsetY);
          if (index != -1) {
            _triggerTouchEvent(DragSelectedInfo(index: index, tag: widget.dataList[index], dragStatus: DragStatus.DragUpdate));
          }
        },
        onTapUp: (details) {
          _isTapDown = false;
          _triggerTouchEvent(DragSelectedInfo(dragStatus: DragStatus.TapUp));
        },
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: children,
        ),
      ),
    );
  }

  //获取手指触及到widget时 对应的字母index
  int _getIndex(double offsetY) {
    //触及位置到布局顶部距离 ~/ 单个item高度 计算出index
    int index = (offsetY ~/ itemHeight);
    if (index >= widget.dataList.length || index < 0) return -1;
    return index;
  }

  //触发事件onTap
  _triggerTouchEvent(DragSelectedInfo dragSelectedInfo) {
    if (widget.onTap != null) {
      widget.onTap(dragSelectedInfo);
    }
  }
}

class DragSelectedInfo {
  int index;
  String tag;
  DragStatus dragStatus;

  DragSelectedInfo({this.index, this.tag, this.dragStatus});
}

enum DragStatus {
  TapDown,
  TapUp,
  DragDown,
  DragEnd,
  DragUpdate,
}

其中核心代码是在GestureDetector中,关键的步骤有注释。主要就是去计算手指触及导航条的位置到布局顶部的距离,再由这个距离整除单个Item的高度计算出此时的index即导航条中的具体字母。
好的现在两个主要构成部分都已经完成了,接下来就是这两个部分的联动。

整体布局和联动

  @override
  Widget build(BuildContext context) {
    return Material(
      color: Colours.color_f4,
      child: Stack(
        children: <Widget>[
          Column(
            children: <Widget>[
              Container(
                color: Colours.white,
                margin: EdgeInsets.only(top: Global.statusHeight),
                padding: EdgeInsets.only(left: 30.w, right: 30.w, top: 20.w, bottom: 20.w),
                child: Row(
                  children: <Widget>[
                    ContainerView(
                      onPressed: () {
                        Navigator.of(context).pop();
                      },
                      child: ImageUtils.imageShow('icon_back_black', width: 50.w),
                    ),
                    Expanded(
                      child: Container(
                        height: 65.w,
                        alignment: Alignment.center,
                        padding: EdgeInsets.only(left: 30.w, right: 30.w),
                        decoration: BoxDecoration(color: Colours.color_ed, borderRadius: BorderRadius.all(Radius.circular(32.w))),
                        child: Row(
                          children: <Widget>[
                            Image.asset(
                              ImageUtils.getImgPath('icon_search_gray'),
                              width: 35.w,
                              height: 35.w,
                            ),
                            Expanded(
                              child: InputTextField(
                                isDense: true,
                                noBorder: true,
                                hintText: '搜索城市',
                                contentPadding: EdgeInsets.only(left: 20.w, right: 20.w),
                                keyboardType: ITextInputType.text,
                                style: TextStyle(fontSize: 26.sp, fontWeight: FontWeight.w300, color: Colours.color_22),
                                controller: _searchController,
                                onChanged: (String str) {
                                  _searchEmpty = StringUtils.isEmpty(str);
                                  if (!_searchEmpty) {
                                    _buildLocalSearchData(str);
                                  }
                                  setState(() {});
                                },
                              ),
                            ),
                          ],
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              Expanded(
                child: _searchEmpty
                    ? _buildAllCityList()
                    : (_searchList == null || _searchList.length == 0)
                        ? NoData.show(contentText: '没有找到对应城市')
                        : ListView.builder(
                            itemBuilder: (context, index) {
                              return _buildCityItem(_searchList[index]);
                            },
                            itemCount: _searchList == null ? 0 : _searchList.length,
                            shrinkWrap: true,
                          ),
              )
            ],
          ),
          Positioned(
            right: 20.w,
            top: 0,
            bottom: 0,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Offstage(
                  offstage: !_searchEmpty,
                  child: VerticalGuideView(
                    dataList: _mapKeysList,
                    onTap: (DragSelectedInfo info) {
                      _handleDragSelectedInfo(info);
                    },
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

布局比较简单,使用Stack作为大的容器,利用Positioned放置VerticalGuideView导航条。比较核心的方法是_handleDragSelectedInfo(info)


  //处理dragSelectedInfo事件
  void _handleDragSelectedInfo(DragSelectedInfo info) {
    DragStatus status = info.dragStatus;
    int index = info.index;
    if (status == DragStatus.DragDown) {
      _selectedTag = info.tag;
      _insertCenterGuider();
      _itemScrollController.jumpTo(index: index);
    } else if (status == DragStatus.DragUpdate) {
      _removeCenterGuider();
      _selectedTag = info.tag;
      _insertCenterGuider();
      _itemScrollController.jumpTo(index: index);
    } else if (status == DragStatus.DragEnd) {
      _removeCenterGuider();
    } else if (status == DragStatus.TapUp) {
      _removeCenterGuider();
    }
    setState(() {});
  }

可以看到当DragStatus == DragDown或者DragUpdate时都会触发 _itemScrollController.jumpTo(index: index)也就是列表滚动,同时还会执行 _insertCenterGuider(),此方法主要是在屏幕中显示字母方块的。

 //显示屏幕中间 方块
  void _insertCenterGuider() {
    _overlayEntry = OverlayEntry(builder: (_) {
      return Align(
        child: Container(
          width: 120.w,
          height: 120.w,
          alignment: Alignment.center,
          color: Colors.black.withOpacity(0.5),
          child: Text(
            '$_selectedTag',
            style: TextStyle(
              fontSize: 40.sp,
              color: Colours.white,
              fontWeight: FontWeight.w600,
              decoration: TextDecoration.none,
            ),
          ),
        ),
        alignment: Alignment.center,
      );
    });
    Overlay.of(context).insert(_overlayEntry);
  }
  //移除屏幕中间 方块
  void _removeCenterGuider() {
    _overlayEntry.remove();
    _overlayEntry = null;
  }

这个方法中使用了Overlay.of(context).insert(_overlayEntry),不熟悉Overlay的同学可以去学习一下。这个widget用处还是比较多的。

结语

到此这个城市联动选择便已经完成了。其实涉及到的难点不多,而且实现的方法也是多种多样。再次分享一下我在这里用到过的两个库PinyinHelperScrollablePositionedList
有什么建议和意见请下方留言吧,看到会第一时间回复的。喜欢的请点个赞吧,谢谢啦!

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