Flutter 玩轉(zhuǎn)微信——通訊錄

概述

  • 鄙人于閑暇之日,自學(xué)Flutter已有兩月之久,古人曰:百聞不如一見(jiàn),百見(jiàn)不如一試,特此利用生平之所學(xué),實(shí)戰(zhàn)微信以項(xiàng)目。Flutter,學(xué)語(yǔ)法之輕易,用組件之簡(jiǎn)單,源碼開(kāi)源,插件豐富。然一份代碼,卻可完美運(yùn)行于iOS和Android之上,其運(yùn)行流暢,且效果杠杠,豈不拍案叫絕,牛B轟轟~。

  • 如有iOSAndroidWeb開(kāi)發(fā)之經(jīng)驗(yàn),聯(lián)想之前之所學(xué),類(lèi)比之前之所用,除寫(xiě)法不同,但語(yǔ)法通用,若多加練習(xí),定能快速上手,耳熟藍(lán)翔,不多逼逼,推薦以下之文檔。

  • 此文作微信通訊錄以文章,雖功能看似簡(jiǎn)單,但內(nèi)含技術(shù)豐富,且功能十分有趣。作為初學(xué)Flutter,拿其小試牛刀,必將初有成效。于Flutter而言, 鄙人也算是初生牛犢不怕虎,并非是天神下凡一錘五。當(dāng)然,筆者必將知無(wú)不言、言無(wú)不盡,梳理實(shí)戰(zhàn)過(guò)程之問(wèn)題,總結(jié)解決問(wèn)題之方案,讓大家知其然,知其所以然。望能拋玉引磚,擺渡眾生,如有紕漏,還望斧正。

  • 源碼地址:flutter_wechat

效果圖

列表 索引 側(cè)滑
contacts_page_0.png
contacts_page_1.png
contacts_page_2.png

列表

一、功能分析
搭建通訊錄之列表,其知識(shí)點(diǎn)涵蓋A-Z 索引Bar懸停效果view自定義Header索引聯(lián)動(dòng)漢字轉(zhuǎn)拼音,若想實(shí)現(xiàn)前面之功能,這里推薦以下之插件,好風(fēng)憑借力,送我上青云。

  • azlistview 實(shí)現(xiàn)A-Z 索引Bar懸停效果view自定義Header索引聯(lián)動(dòng)
  • lpinyin 實(shí)現(xiàn)漢字轉(zhuǎn)拼音

關(guān)于具體其使用,還請(qǐng)下載其Demo,運(yùn)行于電腦之上,查看其運(yùn)行效果,在此就不多逼逼。

二、數(shù)據(jù)配置

  // 獲取聯(lián)系人列表
  Future fetchContacts() async {
    // 先清除掉數(shù)據(jù)
    _contactsList.clear();
    _contactsMap.clear();
    // 獲取用戶(hù)信息列表
    final jsonStr =
        await rootBundle.loadString(Constant.mockData + 'contacts.json');
    // contactsJson
    final List contactsJson = json.decode(jsonStr);
    // 遍歷
    contactsJson.forEach((json) {
      final User user = User.fromJson(json);
      _contactsList.add(user);
      _contactsMap[user.idstr] = user;
    });
    for (int i = 0, length = _contactsList.length; i < length; i++) {
      String pinyin = PinyinHelper.getPinyinE(_contactsList[i].screenName);
      String tag = pinyin.substring(0, 1).toUpperCase();
      _contactsList[i].screenNamePinyin = pinyin;
      if (RegExp("[A-Z]").hasMatch(tag)) {
        _contactsList[i].tagIndex = tag;
      } else {
        _contactsList[i].tagIndex = "#";
      }
    }
    // 根據(jù)A-Z排序
    SuspensionUtil.sortListBySuspensionTag(_contactsList);
    // 返回?cái)?shù)據(jù)
    return _contactsList;
  }

三、UI搭建
azlistview組件提供的APIProperty可知,需要提供以下之部件(Widget):

// 列表中某一個(gè) item 部件
itemBuilder: (context, model) => _buildListItem(model),
// 頂部懸浮的Widget
suspensionWidget: _buildSusWidget(_suspensionTag, isFloat: true),
// 自定義header
header: AzListViewHeader(
   // - [特殊字符](https://blog.csdn.net/cfxy666/article/details/87609526)
   // - [特殊字符](http://www.fhdq.net/)
   tag: "♀",
   height: 5 * _itemHeight,
   builder: (context) {
     return _buildHeader();
   },
 ),
// IndexBar 這個(gè)可以不寫(xiě),使用默認(rèn)的IndexBar
indexBarBuilder: (context, tagList, onTouch){},
// 自定義 點(diǎn)擊IndexBar 中的某個(gè) tag,放大顯示在屏幕中間的 hint,必須showIndexHint: true, 默認(rèn)就是true
indexHintBuilder: (context, hint) {
    return Container(
     alignment: Alignment.center,
     width: 80.0,
     height: 80.0,
     decoration: BoxDecoration(color: Color(0xFFC7C7CB), shape: BoxShape.circle),
     child:Text(hint, style: TextStyle(color: Colors.white, fontSize: 30.0)),
   );
},

具體UI搭建,這里不多贅述,還請(qǐng)移駕鄙人提供的Demo,翻閱查看其代碼。這里筆者以自定義懸浮View組頭View為例,穿針引線,搭建符合要求之UI。效果圖如下所示:

contacts_page_3.png

  • A:懸浮View
  • B:組頭View

代碼實(shí)現(xiàn):

  /// 構(gòu)建懸浮部件
  /// [susTag] 標(biāo)簽名稱(chēng)
  /// [isFloat] 是否懸浮 默認(rèn)是 false
  Widget _buildSusWidget(String susTag, {bool isFloat = false}) {
    return Container(
      height: _suspensionHeight.toDouble(),
      padding: EdgeInsets.only(left: ScreenUtil.getInstance().setWidth(51.0)),
      decoration: BoxDecoration(
        color: isFloat ? Colors.white : Style.pBackgroundColor,
        border: isFloat
            ? Border(bottom: BorderSide(color: Color(0xFFE6E6E6), width: 0.5))
            : null,
      ),
      alignment: Alignment.centerLeft,
      child: Text(
        '$susTag',
        softWrap: false,
        style: TextStyle(
          fontSize: ScreenUtil.getInstance().setSp(39.0),
          color: isFloat ? Style.pTintColor : Color(0xff777777),
        ),
      ),
    );
  }

四、特別提醒

  1. azlistview 中要求itemCell懸停View自定義的Header、以及IndexBar中每個(gè)tag的高度必須是 int類(lèi)型且不可動(dòng)態(tài)修改。如涉及屏幕適配,還請(qǐng)向上(下)取整
  /// 懸浮view 高度 向上取整
  int _suspensionHeight =
      (ScreenUtil.getInstance().setHeight(99.0) as double).ceil();
  /// 每個(gè)item 高度 向上取整
  int _itemHeight =
      (ScreenUtil.getInstance().setHeight(168.0) as double).ceil();
  1. AzListView:只是對(duì)SuspensionView & IndexBar的封裝,方便使用罷了,爾等完全可以使用 SuspensionView & IndexBar 定制更加豐富的UI效果。

索引條

一、功能分析
由于,AzListView提供的IndexBar并不滿(mǎn)足微信通訊錄的要求,需求驅(qū)動(dòng)生產(chǎn),不可墨守成規(guī),爾等可運(yùn)行以下代碼,查看默認(rèn)和自定義的效果對(duì)比,爾等方能辨雌雄。

/// 構(gòu)建聯(lián)系人列表
  /// [defaultMode] 是否使用默認(rèn)的IndexBar
  Widget _buildContactsList({bool defaultMode = false}) {
    if (defaultMode) {
      return _buildDefaultIndexBarList();
    } else {
      return _buildCustomIndexBarList();
    }
  }

功能對(duì)比

類(lèi)型 Custom Default
效果
contacts_page_1.png
contacts_page_4.png
組件 AzListView AzListView
條件 showIndexHint: false,
indexBarBuilder: (_, _, _) => MHIndexBar()
showIndexHint: true,
功能 1、列表和IndexBar能相互聯(lián)動(dòng)
2、IndexBar當(dāng)前選中的Tag高亮
3、手指觸碰IndexBar中Tag, 彈出指向該Tag的氣泡
4、通過(guò)設(shè)置ignoreTags屬性,控制其中某個(gè)Tag,不高亮,不彈氣泡
4、通過(guò)設(shè)置mapTag和mapSelTag,可以將某個(gè)tag映射稱(chēng)自定義的默認(rèn)或選中樣式,eg: ♀ =>
1、只能通過(guò)IndexBar聯(lián)動(dòng)列表,反之不行
2、手指觸碰IndexBar中Tag, 彈出屏幕居中的氣泡 ????????????????????????????????????????????????????????????
3、能控制某個(gè)Tag不彈氣泡????????????

二、魔改源碼
考慮到只是在AzListView系統(tǒng)提供的IndexBar上新增一些功能,故筆者完全復(fù)制IndexBar之源碼,在其基礎(chǔ)之上,新增功能罷了,可謂是借東風(fēng)之力,成曠世之業(yè)。再此著重講講思路,若爾等想追根溯源,還以移駕/components/index_bar/mh_index_bar.dart查看源碼。

  1. 列表滾動(dòng)聯(lián)動(dòng)IndexBar標(biāo)簽(tag)滾動(dòng)功能實(shí)現(xiàn)

該功能的實(shí)現(xiàn),需要IndexBar提供一個(gè)tag屬性即可。 具體代碼實(shí)現(xiàn)如下

  /// list.dart  索引標(biāo)簽改變
  void _onSusTagChanged(String tag) {
    setState(() {
      _suspensionTag = tag;
    });
  }
  /// 傳遞改變的tag 給 IndexBar
  MHIndexBar(
    tag: _suspensionTag,
  )
  
  /// mh_index_bar.dart 處理列表傳經(jīng)來(lái)的tag
  // 配置 當(dāng)前 _indexModel, tag可能是用戶(hù)滾動(dòng)列表的傳進(jìn)來(lái)數(shù)據(jù),導(dǎo)致tag不一致
  if (widget.tag != null &&
        widget.tag.isNotEmpty &&
        widget.tag != _indexModel.tag) {
      _indexModel.tag = widget.tag;
      _indexModel.isTouchDown = false;
      _indexModel.position = widget.data.indexOf(widget.tag);
  }
  1. IndexBar選中tag高亮,配置某個(gè)tag不高亮配置某個(gè)tag映射其他部件,例如:♀ =>功能實(shí)現(xiàn)

選中tag高亮: 可以通過(guò)IndexBar內(nèi)部提供的私有對(duì)象_indexModel得知哪個(gè)tag高亮, 即 _indexModel.tag == tag 則此tag選中。
配置某個(gè)tag不高亮: IndexBar提供一個(gè)List<String> ignoreTags屬性,讓用戶(hù)去設(shè)置哪些標(biāo)簽不高亮。 例如:ignoreTags: ['♀'],,可得知這個(gè)標(biāo)簽不高亮。
配置某個(gè)tag映射其他部件,例如:♀ =>: IndexBar提供一個(gè)默認(rèn)的Map<String, Widget> mapTag和一個(gè)選中(高亮)的Map<String, Widget> mapSelTag來(lái)映射某個(gè)tag默認(rèn)和高亮的部件。當(dāng)然,如有需要還需配置一個(gè)彈出氣泡的隱射部件Map<String, Widget> mapHintTag
以上功能實(shí)現(xiàn)所需屬性如下:

  /// 當(dāng)前高亮顯示的標(biāo)簽
  final String tag;

  /// 忽略的Tags,這些忽略Tag, 不會(huì)高亮顯示,點(diǎn)擊或長(zhǎng)按 不會(huì)彈出 tagHint
  final List<String> ignoreTags;

  /// 針對(duì)某個(gè)Tag顯示其他部件的映射,一般都是映射 圖片/svg
  final Map<String, Widget> mapTag;

  /// 針對(duì)某個(gè)Tag顯示高亮其他部件的映射,一般都是映射 圖片/svg
  final Map<String, Widget> mapSelTag;

  /// 長(zhǎng)按彈出氣泡顯示的內(nèi)容,一般都是映射 圖片/svg
  final Map<String, Widget> mapHintTag;

以上功能實(shí)現(xiàn)代碼邏輯如下:<注意注釋>

  /// 獲取標(biāo)簽tag背景色
  Color _fetchColor(String tag) {
    if (_indexModel.tag == tag) {
      final List<String> ignoreTags = widget.ignoreTags ?? [];
      return ignoreTags.indexOf(tag) != -1
          ? widget.tagColor ?? Colors.transparent
          : widget.selectedTagColor ?? Color(0xFF07C160);
    }
    return widget.tagColor ?? Colors.transparent;
  }
  
  /// 構(gòu)建某個(gè)tag的部件
  Widget _buildTagWidget(String tag) {
    // 當(dāng)前選中的tag, 也就是高亮的場(chǎng)景
    if (_indexModel.tag == tag) {
      final List<String> ignoreTags = widget.ignoreTags ?? [];
      final isIgnore = ignoreTags.indexOf(tag) != -1;
      // 如果是忽略
      if (isIgnore) {
        // 獲取mapTag
        if (widget.mapTag != null && widget.mapTag[tag] != null) {
          // 返回映射的部件
          return widget.mapTag[tag];
        } else {
          // 返回默認(rèn)的部件
          return Text(
            tag,
            textAlign: TextAlign.center,
            style: widget.textStyle ??
                TextStyle(
                  fontSize: 10.0,
                  color: Color(0xFF555555),
                  fontWeight: FontWeight.w500,
                ),
          );
        }
      } else {
        // 不忽略,則顯示高亮組件
        if (widget.mapSelTag != null && widget.mapSelTag[tag] != null) {
          // 返回映射高亮的部件
          return widget.mapSelTag[tag];
        } else if (widget.mapTag != null && widget.mapTag[tag] != null) {
          // 返回映射默認(rèn)的部件
          return widget.mapTag[tag];
        } else {
          // 返回默認(rèn)的部件
          return Text(
            tag,
            textAlign: TextAlign.center,
            style: widget.selectedTextStyle ??
                TextStyle(
                  fontSize: 10.0,
                  color: Colors.white,
                  fontWeight: FontWeight.w500,
                ),
          );
        }
      }
    }
    // 非高亮場(chǎng)景
    // 獲取mapTag
    if (widget.mapTag != null && widget.mapTag[tag] != null) {
      // 返回映射的部件
      return widget.mapTag[tag];
    } else {
      // 返回默認(rèn)的部件
      return Text(
        tag,
        textAlign: TextAlign.center,
        style: widget.textStyle ??
            TextStyle(
              fontSize: 10.0,
              color: Color(0xFF555555),
              fontWeight: FontWeight.w500,
            ),
      );
    }
  }

  1. 手指按住某tag,彈出氣泡hint的功能實(shí)現(xiàn)。

相比AzListView默認(rèn)提供的一個(gè)屏幕居中的indexBarHint,自定義的indexBarHint,則是在手指按下的某個(gè)tag的左側(cè)彈出一個(gè)hint,且兩者中心點(diǎn)水平平行,其效果更加靈性而不失端莊,俏皮且略顯可愛(ài)
開(kāi)局一張圖,內(nèi)容全靠編

contacts_page_5.png

由上圖可知,考慮到hint(紅色)和長(zhǎng)按tag(藍(lán)色)水平居中且跟隨移動(dòng),這里采用Stack + Positioned來(lái)布局taghint,由于要保證長(zhǎng)按or點(diǎn)擊tag,才彈出hint,所以需要使用Offstage組件。注意:一定要設(shè)置Stackoverflow: Overflow.visible,為可見(jiàn)。偽代碼實(shí)現(xiàn)如下:

Stack(
  // 設(shè)置超出部分可見(jiàn) 必須設(shè)置
  overflow: Overflow.visible,
  children: <Widget>[
     // 標(biāo)簽組件
     TagWidget,
     // Hint組件
     Positioned(
       left: -80.0,
       top: -17.0,
       child: Offstage(
          // 長(zhǎng)按或點(diǎn)擊: false(顯示) ; 其他則為: true(隱藏)
        offstage: true/false,
        child: HintWidget,
       )
     )
  ],
),

水平靠左居中,偽代碼實(shí)現(xiàn).

// 靠左 hintW = 60, spaceX = 20
left: -(HintW + spaceX),
// 水平居中 HintH = 50, TagH = 16
top: -(HintH - TagH) * 0.5,

這里以布局Hint為例,代碼實(shí)現(xiàn)如下。

  /// 構(gòu)建indexBar hint
  Widget _buildIndexBarHintWidget(
      BuildContext context, String tag, IndexBarDetails indexModel) {
    // 如果外界自定義 indexbarHint
    if (widget.indexBarHintBuilder != null) {
      return widget.indexBarHintBuilder(context, tag, indexModel);
    } else {
      return Positioned(
        left: -(60 + widget.hintOffsetX ?? 20),
        top: -(50 - widget.itemHeight) * 0.5,
        child: Offstage(
          offstage: _fetchOffstage(tag),
          child: Container(
            width: 60.0,
            height: 50.0,
            decoration: BoxDecoration(
              image: DecorationImage(
                image: AssetImage(
                    'assets/images/contacts/ContactIndexShape_60x50.png'),
                fit: BoxFit.contain,
              ),
            ),
            alignment: Alignment(-0.25, 0.0),
            child: _buildHintChildWidget(tag),
          ),
        ),
      );
    }
  }
  
  // 獲取Offstage 是否隱居幕后
  bool _fetchOffstage(String tag) {
    if (_indexModel.tag == tag) {
      final List<String> ignoreTags = widget.ignoreTags ?? [];
      return ignoreTags.indexOf(tag) != -1 ? true : !_indexModel.isTouchDown;
    }
    return true;
  }

  /// 構(gòu)建某個(gè)hint中子部件
  Widget _buildHintChildWidget(String tag) {
    if (widget.mapHintTag != null && widget.mapHintTag[tag] != null) {
      // 返回映射高亮的部件
      return widget.mapHintTag[tag];
    }
    return Text(
      tag,
      style: TextStyle(
        color: Colors.white70,
        fontSize: 30.0,
        fontWeight: FontWeight.w700,
      ),
    );
  }

  1. 自定義標(biāo)簽和自定義Hint的樣式

當(dāng)然筆者為自定義的mh_index_bar提供了許多可配置的屬性,基本上能滿(mǎn)足類(lèi)似微信聯(lián)系人這樣的IndexBar,具體各個(gè)屬性的使用,這里就不一一贅述了,有興趣的童鞋可以自行查看。
當(dāng)然,如果你想定制更加花里胡哨的需求,且筆者提供的屬性也無(wú)法滿(mǎn)足時(shí)。莫慌,筆者也暴露了兩個(gè)方法,由用戶(hù)自行去構(gòu)建標(biāo)簽Hint的部件。 API如下


/// Called to build index hint. 自定義氣泡彈出Hint
/// [tag] 標(biāo)簽值
/// [indexModel] 當(dāng)前選中的標(biāo)簽Model
typedef Widget IndexBarHintBuilder(
    BuildContext context, String tag, IndexBarDetails indexModel);

/// Called to build index tag. 自定義氣標(biāo)簽
typedef Widget IndexBarTagBuilder(
    BuildContext context, String tag, IndexBarDetails indexModel);

關(guān)于這兩個(gè)API的實(shí)現(xiàn),筆者已經(jīng)在 /views/contacts/contacts_page.dart里面實(shí)現(xiàn)了,且只要運(yùn)行代碼,默認(rèn)就是通過(guò)這連個(gè)API構(gòu)建。

三種場(chǎng)景的效果圖對(duì)比如下。<PS:圖三、多個(gè)氣泡只是用來(lái)證明自定義樣式Hint罷了,然并卵~>

默認(rèn) 自定義(屬性) 自定義(Builder)
contacts_page_4.png
contacts_page_1.png
contacts_page_6.png

側(cè)滑(備注)

一、功能分析
聯(lián)系人右邊側(cè)滑展開(kāi)備注的功能。這里還是借助下面的插件來(lái)實(shí)現(xiàn),站在巨人的肩膀上編程。關(guān)于具體使用,還請(qǐng)查看插件的提供的Example

** 二、代碼實(shí)現(xiàn) **
利用flutter_slidable插件,很快將之前的cell的具有側(cè)滑功能,偽代碼實(shí)現(xiàn)如下:

    // cell
    Widget listTile = MHListTile();
    // 頭部是不需要側(cè)滑的(新的朋友、群聊、標(biāo)簽、備注)
    if (!needSlidable) {
      return listTile; 
    }else{
      // 這樣就具備了側(cè)滑
      return Slidable(
         child: listTile; 
     )
  }

三、問(wèn)題處理
flutter_slidable雖然引用和切入到已有代碼,非常的細(xì)膩絲滑,讓人嫉妒舒適。但是,為了完完全全實(shí)現(xiàn)微信通訊錄的功能,其中還是遇到了少許問(wèn)題,這里筆者一一記錄以及處理心得。

  1. 每一個(gè)Slidable必須設(shè)置一個(gè)key且不能為null,否則報(bào)錯(cuò)。例如:Slidable(key: Key(title))

  2. 不需要組件默認(rèn)提供的側(cè)滑到最左側(cè),執(zhí)行dismiss事件。
    默認(rèn)該組件側(cè)滑到最左側(cè),會(huì)執(zhí)行onDismissed回調(diào),如果不寫(xiě),程序會(huì)閃退。代碼如下:

 Slidable(
   // 必須的有key
   key: Key(title),
   dismissal: SlidableDismissal(
      child: SlidableDrawerDismissal(),
      onDismissed: (actionType) {
          /// 一般都是 刪除這個(gè)cell, 如果啥都不干,則運(yùn)行報(bào)錯(cuò)
    },
 ),

由于這是系統(tǒng)默認(rèn)的事件,且SlidableDismissal提供了一個(gè)屬性(dragDismissible)來(lái)阻止這個(gè)默認(rèn)事件。只需要設(shè)置為dragDismissible: false即可。
這個(gè)方法雖然是解決了拖拽到最左側(cè),調(diào)用Dismiss事件,但是,隨即帶來(lái)的是,側(cè)滑失去了原有的彈性效果,變得非常的死板和呆滯,瞬間失去了靈魂一般,得不償失。我們要的是:能側(cè)滑到左側(cè)回彈,且不執(zhí)行dismiss事件。
翻閱SlidableDismissal提供的屬性,驚奇的發(fā)現(xiàn)onWillDismiss屬性,查看其注釋便知,這不正是我們要的滑板鞋?!。

  /// Called before the widget is dismissed. If the call returns false, the
  /// item will not be dismissed.
  ///
  /// If null, the widget will always be dismissed.
  final SlideActionWillBeDismissed onWillDismiss;

所以最終解決方案如下:

Slidable(
   // 必須的有key
   key: Key(title),
   dismissal: SlidableDismissal(
      closeOnCanceled: false, // 取消 dismiss事件后,是否關(guān)閉item ,默認(rèn)是不關(guān)閉
      dragDismissible: true, // 必須為true,否則沒(méi)側(cè)滑回彈動(dòng)畫(huà)
      child: SlidableDrawerDismissal(),
      onWillDismiss: (actionType) {
          return false; // 告訴系統(tǒng),吾不死,爾等終究是臣
   },
 ),
  1. 側(cè)滑時(shí),禁止掉按下cell置灰(高亮)的效果。
    默認(rèn)情況下,按下或點(diǎn)擊某個(gè)Cell時(shí),該Cell會(huì)展示高亮(置灰)的效果,以此告知用戶(hù)具體按下哪個(gè)Cell。但是當(dāng)我們側(cè)滑或側(cè)滑展開(kāi)時(shí),再去點(diǎn)擊Cell,不應(yīng)該有這種高亮(置灰)的效果,否則,有點(diǎn)喧賓奪主的感覺(jué)。
    解決方案:監(jiān)聽(tīng)Slidable是否展開(kāi),來(lái)判斷Cell是否需要點(diǎn)擊高亮的效果。具體代碼如下:
  // 配置側(cè)滑監(jiān)聽(tīng)
  ScrollController  _slidableController = SlidableController(
    onSlideIsOpenChanged: _handleSlideIsOpenChanged,
  );
  // 監(jiān)聽(tīng)側(cè)滑展開(kāi)與否
  void _handleSlideIsOpenChanged(bool isOpen) {
    setState(() {
      _slideIsOpen = isOpen;
    });
  }
  // Cell
  Widget listTile = MHListTile(
    // 不需要側(cè)滑的cell,還是默認(rèn)可點(diǎn)擊,如果需要側(cè)滑的cell,側(cè)滑展開(kāi),則不可點(diǎn)擊,否則,可點(diǎn)擊
    allowTap: !_slideIsOpen || !needSlidable,
  );
  // Slidable
  Slidable(
    // 必須的有key
    key: Key(title),
    controller: _slidableController,
  );
  1. 手動(dòng)(程序)關(guān)閉上一個(gè)展開(kāi)的側(cè)滑部件(Cell)。
    程序關(guān)閉或展開(kāi)某個(gè)Cell,這里用到組件提供的兩個(gè)API : void close();void open({SlideActionType actionType});
    具體關(guān)閉和展開(kāi)某個(gè)Cell的代碼實(shí)現(xiàn)如下:
Slidable.of(context)?.open();
Slidable.of(context)?.close();

特別提醒的是: Slidable.of(context)中的context必須是 Slidable.childcontext。否則調(diào)用沒(méi)效果。

// Slidable
Slidable(
  // 必須的有key
  key: Key(title),
  controller: _slidableController,
  child: ItemWidget(),
);

// Slidable 的 child
class ItemWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      // 特別注意這里的context,如果你是封裝的組件,還請(qǐng)點(diǎn)擊事件中 將context回調(diào)出去!!!! SlidableRenderingMode.none 證明此cell未展開(kāi)
      onTap: () =>
          Slidable.of(context)?.renderingMode == SlidableRenderingMode.none
              ? Slidable.of(context)?.open()
              : Slidable.of(context)?.close(),
      child: Text('Hello world'),
    );
  }
}

上面的代碼實(shí)現(xiàn)的效果是:點(diǎn)擊 A Cell,則A Cell 展開(kāi)或關(guān)閉 側(cè)滑。
但是,我們希望的效果是,如果A Cell是關(guān)閉狀態(tài)時(shí),點(diǎn)擊 A Cell 是下鉆到用戶(hù)信息頁(yè)面。實(shí)現(xiàn)代碼如下:

// Cell
Widget listTile = MHListTile(
  // 由于筆者是封裝組件,所以點(diǎn)擊事件中,將 context 回調(diào)出來(lái)
  onTapValue: (cxt) {
    // 該cell處于關(guān)閉狀態(tài), 直接下鉆到 用戶(hù)信息頁(yè)面
    if (Slidable.of(cxt)?.renderingMode == SlidableRenderingMode.none) {
      // 下鉆 用戶(hù)信息
      NavigatorUtils.push(cxt,'${ContactsRouter.contactInfoPage}?idstr=${user.idstr}');
    }else{
      Slidable.of(cxt)?.close();
    }
  },
);

上面的代碼只是針對(duì)同一個(gè)Cell(A Cell)的點(diǎn)擊事件處理邏輯罷了。如果和其他Cell(B Cell)連用,就會(huì)出現(xiàn)問(wèn)題。
A CellB Cell為例,理想(現(xiàn)實(shí))場(chǎng)景如下:

  • 同Cell點(diǎn)擊場(chǎng)景

    • 當(dāng)點(diǎn)擊A Cell時(shí),若A Cell是側(cè)滑關(guān)閉狀態(tài)時(shí),則下鉆A的用戶(hù)信息頁(yè)面; 若 A Cell是側(cè)滑展開(kāi)狀態(tài)時(shí),則關(guān)閉A Cell的側(cè)滑;
    • 當(dāng)點(diǎn)擊B Cell時(shí),邏輯同上。
  • 不同Cell點(diǎn)擊場(chǎng)景

    • A CellB Cell都是側(cè)滑關(guān)閉狀態(tài)時(shí),點(diǎn)擊哪個(gè)Cell,則下鉆哪個(gè)Cell對(duì)應(yīng)的用戶(hù)信息頁(yè)面.
    • 不可能出現(xiàn)A CellB Cell都是側(cè)滑展開(kāi)狀態(tài)的場(chǎng)景。
    • A Cell是側(cè)滑展開(kāi)狀態(tài)時(shí),當(dāng)點(diǎn)擊B Cell時(shí),則關(guān)閉A Cell的側(cè)滑,下鉆到B的用戶(hù)信息頁(yè)面.
    • B Cell是側(cè)滑展開(kāi)狀態(tài)時(shí),當(dāng)點(diǎn)擊A Cell時(shí),則關(guān)閉B Cell的側(cè)滑,下鉆到A的用戶(hù)信息頁(yè)面.

俗話說(shuō):理想很豐滿(mǎn),現(xiàn)實(shí)很骨感。現(xiàn)實(shí)場(chǎng)景是:若A Cell是側(cè)滑展開(kāi)狀態(tài)時(shí),當(dāng)點(diǎn)擊B Cell時(shí),能下鉆到B的用戶(hù)信息頁(yè)面,但A Cell是不會(huì)自動(dòng)關(guān)閉側(cè)滑,還是會(huì)保持側(cè)滑展開(kāi)狀態(tài).
事故產(chǎn)生的最主要原因是:當(dāng)點(diǎn)擊B Cell時(shí),我們無(wú)法拿到A Cellcontext
知道了事故原因了,那么解決問(wèn)題就變得得心應(yīng)手了,這里講講筆者的幾種擺渡眾生解決方案。(PS:小伙伴們有更好的解決方案,歡迎文末評(píng)論留言!!!)

方案一:打開(kāi)一個(gè)空的左側(cè)滑(黑魔法)

首先,Slidable是支持左側(cè)滑和右側(cè)滑,其對(duì)應(yīng)的屬性為: List<Widget> actionsList<Widget> secondaryActions,但是目前需求我們只需要右側(cè)滑罷了,
其次,我們知道: 不可能出現(xiàn)A CellB Cell都是側(cè)滑展開(kāi)狀態(tài)的場(chǎng)景。
所以,若A Cell是右側(cè)滑展開(kāi)狀態(tài)時(shí),當(dāng)點(diǎn)擊B Cell時(shí),我們打開(kāi)B Cell的一個(gè)空的左側(cè)滑,即:Slidable.of(cxt)?.open(actionType: SlideActionType.primary);
因?yàn)?code>B Cell的actions是一個(gè)空數(shù)組,所以界面并沒(méi)有發(fā)生變化,且能將A Cell的右側(cè)滑關(guān)閉。
局限性:首先,該方案適合沒(méi)有左側(cè)滑的場(chǎng)景;其次,我們手動(dòng)打開(kāi)一個(gè)空的左側(cè)滑,雖然界面沒(méi)有變化,但是SlidableController.onSlideIsOpenChanged回調(diào)的isOpen一直為true,如果有些場(chǎng)景需要使用這個(gè)isOpen屬性,那么勢(shì)必會(huì)產(chǎn)生問(wèn)題;
最后,若A Cell是右側(cè)滑展開(kāi)狀態(tài)時(shí),我們不是點(diǎn)擊B Cell,而是點(diǎn)擊導(dǎo)航欄上的按鈕下鉆的場(chǎng)景,該方案也不適合。

方案一的功能代碼實(shí)現(xiàn)如下:

// Cell
Widget listTile = MHListTile(
  // 由于筆者是封裝組件,所以點(diǎn)擊事件中,將 context 回調(diào)出來(lái)
  onTapValue: (cxt) {
    // 該cell處于關(guān)閉狀態(tài), 直接下鉆到 用戶(hù)信息頁(yè)面
    if (Slidable.of(cxt)?.renderingMode == SlidableRenderingMode.none) {
      // 方案一: 針對(duì)cell點(diǎn)擊 和下鉆容易處理  但是一但 點(diǎn)擊導(dǎo)航欄上的 添加聯(lián)系人按鈕 ,因?yàn)楂@取不到 cxt 而力不從心
      // 細(xì)節(jié):這里由于 SlideActionType.primary 對(duì)應(yīng) actions 為空,所以雖然看似展開(kāi)空,目的就是關(guān)閉 上一個(gè)打開(kāi)的 secondary action
      Slidable.of(cxt)?.open(actionType: SlideActionType.primary);
      // 上面的雖然打開(kāi)了一個(gè)空的 但是系統(tǒng)還是會(huì)認(rèn)為是 打開(kāi)的 也就是 _slideIsOpen = true
      // 手動(dòng)設(shè)置為false
      _slideIsOpen = false;
      // 下鉆 用戶(hù)信息
      NavigatorUtils.push(cxt,'${ContactsRouter.contactInfoPage}?idstr=${user.idstr}');
    }else{
      Slidable.of(cxt)?.close();
    }
  },
);

方案二:每生成一個(gè)Cell,就將其Cell對(duì)應(yīng)的context記錄起來(lái)。Map[key] = cxt;

該方案的核心點(diǎn)就是使用: Map,而不是使用ListSet。一旦我們將每一個(gè)Cellcontext記錄在案,那么我們就可以遍歷出每一個(gè)cxt的狀態(tài),從而將某個(gè)context關(guān)閉。
分析:首先,方案的實(shí)用性,遠(yuǎn)遠(yuǎn)高于方案一的且完美解決了方案一的存在局限性。其次,數(shù)據(jù)量一旦過(guò)大,每次遍歷可能存在一定的性能問(wèn)題,注意這里只是可能。

方案二的功能代碼實(shí)現(xiàn)如下:

// Cell
Widget listTile = MHListTile(
  // 由于筆者是封裝組件,所以點(diǎn)擊事件中,將 context 回調(diào)出來(lái)
  onTapValue: (cxt) {
    
    // 沒(méi)有側(cè)滑展開(kāi)項(xiàng) 就直接下鉆
    if (!_slideIsOpen) {
      NavigatorUtils.push(cxt,
          '${ContactsRouter.contactInfoPage}?idstr=${user.idstr}');
      return;
    }

    // 該cell處于關(guān)閉狀態(tài), 直接下鉆到 用戶(hù)信息頁(yè)面
    if (Slidable.of(cxt)?.renderingMode == SlidableRenderingMode.none) {
      // 關(guān)閉上一個(gè)側(cè)滑
      _closeSlidable();

      // 下鉆 用戶(hù)信息
      NavigatorUtils.push(cxt,'${ContactsRouter.contactInfoPage}?idstr=${user.idstr}');
    }else{
      Slidable.of(cxt)?.close();
    }
  },
  // 回調(diào)context
  callbackContext: (BuildContext cxt) {
    _slidableCxtMap[title] = cxt;
  },
);

/// 關(guān)閉slidable
void _closeSlidable() {
  // 容錯(cuò)處理
  if (!_slideIsOpen) return;

  final cxts = _slidableCxtMap.values.toList();
  final len = cxts.length;
  for (var i = 0; i < len; i++) {
    final value = cxts[i];
    if (Slidable.of(value)?.renderingMode != SlidableRenderingMode.none) {
      // 關(guān)掉上一個(gè)
      Slidable.of(value)?.close();
      return;
    }
  }
}

方案三:使用 SlidableController.activeState

這個(gè)是筆者閱讀源碼,偶然發(fā)現(xiàn)的屬性。

方案三的功能代碼實(shí)現(xiàn)如下:

// Cell
Widget listTile = MHListTile(
  // 由于筆者是封裝組件,所以點(diǎn)擊事件中,將 context 回調(diào)出來(lái)
  onTapValue: (cxt) {
    // 沒(méi)有側(cè)滑展開(kāi)項(xiàng) 就直接下鉆
    if (!_slideIsOpen) {
      NavigatorUtils.push(cxt,
          '${ContactsRouter.contactInfoPage}?idstr=${user.idstr}');
      return;
    }
    // 該cell處于關(guān)閉狀態(tài), 直接下鉆到 用戶(hù)信息頁(yè)面
    if (Slidable.of(cxt)?.renderingMode == SlidableRenderingMode.none) {
      // 關(guān)閉上一個(gè)側(cè)滑
      // 方案三: 直接拿這個(gè)activaState
      _slidableController.activeState?.close();
      // 下鉆 用戶(hù)信息
      NavigatorUtils.push(cxt,'${ContactsRouter.contactInfoPage}?idstr=${user.idstr}');
    }else{
      Slidable.of(cxt)?.close();
    }
  },
);

總結(jié)

首先,微信通訊錄雖然看似只有搭建列表自定義IndexBar側(cè)滑備注等三大功能模塊,但是內(nèi)部涵蓋的一些知識(shí)點(diǎn)和細(xì)節(jié)處理還需要各位親自體驗(yàn);而且也怪筆者才疏學(xué)淺,核心功能都是借助第三方插件來(lái)實(shí)現(xiàn)的,再此表示抱歉。
其次,本模塊的核心點(diǎn)主要落在: 自定義IndexBar解決側(cè)滑關(guān)閉 上。 幸運(yùn)的是,筆者相信在這兩個(gè)核心點(diǎn)上解釋的已經(jīng)足夠詳細(xì),希望大家都過(guò)閱讀文章以及結(jié)合代碼,能夠領(lǐng)會(huì)筆者想表達(dá)的意圖和良苦用心。不求膜拜,只求點(diǎn)贊。
最后,希望大家通過(guò)閱讀本文,自己也能夠動(dòng)手寫(xiě)一個(gè)Flutter版本的微信通訊錄,從而激發(fā)你的學(xué)習(xí)動(dòng)力,提升你的學(xué)習(xí)樂(lè)趣。

期待

  1. 文章若對(duì)您有些許幫助,請(qǐng)給個(gè)喜歡??,畢竟碼字不易;若對(duì)您沒(méi)啥幫助,請(qǐng)給點(diǎn)建議,切記學(xué)無(wú)止境。
  2. 針對(duì)文章所述內(nèi)容,閱讀期間任何疑問(wèn);請(qǐng)?jiān)谖恼碌撞吭u(píng)論指出,我會(huì)火速解決和修正問(wèn)題。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源碼地址:flutter_wechat

拓展

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。