Flutter實(shí)戰(zhàn)技巧之-仿美團(tuán)城市聯(lián)動(dòng)選擇

前言

最近接到一個(gè)小的需求:選擇不同城市裙椭,顯示對(duì)應(yīng)城市的天氣驴娃。其實(shí)這種需求在很多生活類的APP中也有,實(shí)現(xiàn)的功能也相差無幾岩瘦。對(duì)于一個(gè)經(jīng)常點(diǎn)外賣的人來說未巫,美團(tuán)、餓了么用的比較多启昧。打開美團(tuán)看了一下叙凡,然后決定模仿美團(tuán)做一個(gè)城市選擇。用了大概兩天時(shí)間做出來密末,先看看效果圖吧(效果圖里面的定位失敗是因?yàn)槭褂玫氖悄M器)握爷。

image

功能分析

頁面主要由兩個(gè)重要的部分組成:左邊城市選擇列表和右邊字母導(dǎo)航條。城市選擇列表由一個(gè)header和一個(gè)列表組成严里,列表數(shù)據(jù)按照字母A到Z進(jìn)行排序新啼。點(diǎn)擊和滑動(dòng)字母導(dǎo)航條時(shí),屏幕中出現(xiàn)選中的字母方塊刹碾,列表內(nèi)容跳轉(zhuǎn)到選中的字母開頭的城市數(shù)據(jù)item位置燥撞。

具體實(shí)現(xiàn)

既然我們把整個(gè)頁面分成了兩個(gè)部分,那么我們就分為兩個(gè)部分來依次完成教硫。

城市選擇列表的實(shí)現(xiàn)

城市選擇列表叨吮,主要就是一個(gè)由一個(gè)帶頭部的列表組成。其中列表控件必須具有主動(dòng)滾動(dòng)的功能瞬矩,這里我們可以用到ScrollController.scrollTo()\ScrollController.jumpTo()這兩種方法茶鉴,主要的區(qū)別是scrollTo可以加入duration參數(shù)。那么怎么樣才能實(shí)現(xiàn)點(diǎn)擊右邊導(dǎo)航條中的字母景用,列表就滾動(dòng)到對(duì)應(yīng)字母的header處呢涵叮?來看看具體代碼是怎么樣實(shí)現(xiàn)的。布局代碼我就不貼出了伞插,主要看 一些核心的代碼割粮。

1.首先獲取城市數(shù)據(jù)

 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是分別存儲(chǔ)所有城市數(shù)據(jù)所有城市首字母數(shù)據(jù)的。其中對(duì)_mapKeysList中的數(shù)據(jù)進(jìn)行了A-Z的排序媚污。這里呢用到了一個(gè)PinyinHelper來獲取城市名的首字母舀瓢,這是一個(gè)漢字轉(zhuǎn)拼音的庫,感興趣的朋友可以深入研究一下耗美。所需要的數(shù)據(jù) 整理完畢后京髓,就需要構(gòu)建我們想要的列表了航缀。

2.構(gòu)建顯示城市的列表

  //構(gòu)建顯示所有城市的列表
  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,
    );
  }

在這個(gè)列表中使用了ScrollablePositionedList和ListView進(jìn)行嵌套。ScrollablePositionedList提供了一個(gè)itemScrollController.jumpTo(index:index )方法堰怨,可以直接滾動(dòng)到index處芥玉。ScrollablePositionedList的itemBuilder返回的是一個(gè)Text+ListView的組合,Text用于顯示首字母备图,ListView用于展示當(dāng)前首字母下所有的城市灿巧。注意:兩個(gè)List列表嵌套時(shí)NeverScrollableScrollPhysics()不能少,它能解決嵌套出現(xiàn)的滑動(dòng)沖突的問題揽涮。
此時(shí)左邊的列表基本已經(jīng)完成抠藕,剩下的就是右邊導(dǎo)航條。

字母導(dǎo)航條的實(shí)現(xiàn)

先來分析一下绞吁。這個(gè)導(dǎo)航條顯示的內(nèi)容是列表中所有城市的首字母幢痘,點(diǎn)擊導(dǎo)航條和在導(dǎo)航條上滑動(dòng)時(shí)屏幕中件位置會(huì)出現(xiàn)對(duì)應(yīng)字母的方塊,并且列表會(huì)滾到對(duì)應(yīng)字母的Item位置家破。滾動(dòng)方法我們已經(jīng)有了颜说,只需要找到 觸發(fā)事件時(shí)對(duì)應(yīng)的字母就行了。因?yàn)檫@里涉及到了點(diǎn)擊滑動(dòng)兩個(gè)方法汰聋,所以自然就想到了Flutter中的GestureDetector门粪,它可以提供多種點(diǎn)擊、滑動(dòng)事件的回調(diào)烹困。這里主要涉及到了onVerticalDragDown玄妈、onVerticalDragUpdateonVerticalDragEnd髓梅、onTapUp四個(gè)事件回調(diào)拟蜻。這里我就直接貼出導(dǎo)航條的全部代碼,可以直接使用枯饿。

///垂直導(dǎo)航條
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; //整個(gè)布局Y軸上高度
  final double itemHeight = 32.w; //單個(gè)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;
          //手指觸及到時(shí)開始計(jì)算 整個(gè)布局的初始高度 _widgetTop
          if (_widgetTop < 0) {
            RenderBox box = context.findRenderObject();
            Offset topLeftPosition = box.localToGlobal(Offset.zero);
            _widgetTop = topLeftPosition.dy;
          }
          //手指觸及的位置 - 布局高度  計(jì)算出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時(shí) 對(duì)應(yīng)的字母index
  int _getIndex(double offsetY) {
    //觸及位置到布局頂部距離 ~/ 單個(gè)item高度 計(jì)算出index
    int index = (offsetY ~/ itemHeight);
    if (index >= widget.dataList.length || index < 0) return -1;
    return index;
  }

  //觸發(fā)事件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中酝锅,關(guān)鍵的步驟有注釋。主要就是去計(jì)算手指觸及導(dǎo)航條的位置到布局頂部的距離奢方,再由這個(gè)距離整除單個(gè)Item的高度計(jì)算出此時(shí)的index即導(dǎo)航條中的具體字母搔扁。
好的現(xiàn)在兩個(gè)主要構(gòu)成部分都已經(jīng)完成了,接下來就是這兩個(gè)部分的聯(lián)動(dòng)蟋字。

整體布局和聯(lián)動(dòng)

  @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: '沒有找到對(duì)應(yīng)城市')
                        : 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);
                    },
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

布局比較簡(jiǎn)單稿蹲,使用Stack作為大的容器,利用Positioned放置VerticalGuideView導(dǎo)航條鹊奖。比較核心的方法是_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(() {});
  }

可以看到當(dāng)DragStatus == DragDown或者DragUpdate時(shí)都會(huì)觸發(fā) _itemScrollController.jumpTo(index: index)也就是列表滾動(dòng)苛聘,同時(shí)還會(huì)執(zhí)行 _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;
  }

這個(gè)方法中使用了Overlay.of(context).insert(_overlayEntry)设哗,不熟悉Overlay的同學(xué)可以去學(xué)習(xí)一下璧尸。這個(gè)widget用處還是比較多的。

結(jié)語

到此這個(gè)城市聯(lián)動(dòng)選擇便已經(jīng)完成了熬拒。其實(shí)涉及到的難點(diǎn)不多,而且實(shí)現(xiàn)的方法也是多種多樣垫竞。再次分享一下我在這里用到過的兩個(gè)庫PinyinHelperScrollablePositionedList 澎粟。
有什么建議和意見請(qǐng)下方留言吧,看到會(huì)第一時(shí)間回復(fù)的欢瞪。喜歡的請(qǐng)點(diǎn)個(gè)贊吧活烙,謝謝啦!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末遣鼓,一起剝皮案震驚了整個(gè)濱河市啸盏,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌骑祟,老刑警劉巖回懦,帶你破解...
    沈念sama閱讀 219,039評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異次企,居然都是意外死亡怯晕,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,426評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門缸棵,熙熙樓的掌柜王于貴愁眉苦臉地迎上來舟茶,“玉大人,你說我怎么就攤上這事堵第“闪梗” “怎么了?”我有些...
    開封第一講書人閱讀 165,417評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵踏志,是天一觀的道長(zhǎng)阀捅。 經(jīng)常有香客問我,道長(zhǎng)狰贯,這世上最難降的妖魔是什么也搓? 我笑而不...
    開封第一講書人閱讀 58,868評(píng)論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮涵紊,結(jié)果婚禮上傍妒,老公的妹妹穿的比我還像新娘。我一直安慰自己摸柄,他們只是感情好颤练,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,892評(píng)論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著驱负,像睡著了一般嗦玖。 火紅的嫁衣襯著肌膚如雪患雇。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,692評(píng)論 1 305
  • 那天宇挫,我揣著相機(jī)與錄音苛吱,去河邊找鬼。 笑死器瘪,一個(gè)胖子當(dāng)著我的面吹牛翠储,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播橡疼,決...
    沈念sama閱讀 40,416評(píng)論 3 419
  • 文/蒼蘭香墨 我猛地睜開眼援所,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了欣除?” 一聲冷哼從身側(cè)響起住拭,我...
    開封第一講書人閱讀 39,326評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎历帚,沒想到半個(gè)月后滔岳,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,782評(píng)論 1 316
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡抹缕,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,957評(píng)論 3 337
  • 正文 我和宋清朗相戀三年澈蟆,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片卓研。...
    茶點(diǎn)故事閱讀 40,102評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡趴俘,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出奏赘,到底是詐尸還是另有隱情寥闪,我是刑警寧澤,帶...
    沈念sama閱讀 35,790評(píng)論 5 346
  • 正文 年R本政府宣布磨淌,位于F島的核電站疲憋,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏梁只。R本人自食惡果不足惜缚柳,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,442評(píng)論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望搪锣。 院中可真熱鬧秋忙,春花似錦、人聲如沸构舟。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,996評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至弹澎,卻和暖如春朴下,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背苦蒿。 一陣腳步聲響...
    開封第一講書人閱讀 33,113評(píng)論 1 272
  • 我被黑心中介騙來泰國打工殴胧, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人佩迟。 一個(gè)月前我還...
    沈念sama閱讀 48,332評(píng)論 3 373
  • 正文 我出身青樓溃肪,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國和親音五。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,044評(píng)論 2 355