Flutter | 超簡單仿微信QQ側滑菜單組件

  • 2019 年 10 月 5 日
  • 筆記

相信大家對於這種需求肯定不陌生:

側滑出菜單,在Flutter 當中,這種需求怎麼實現?

看一下實現的效果:

需求分析

老套路,先分析一下需求:

1.首先可以滑出菜單2.菜單滑動到一定距離完全滑出,未達到距離回滾3.菜單數量、樣式隨意訂製4.菜單點擊回調5.菜單展開時,點擊 item 收回菜單(見QQ)

程式碼實現

需求明了以後就可以寫程式碼了。

1. 首先可以滑出菜單

最基本的,菜單要能滑的出來,我們思考一下,如何能在螢幕外面放置 Widget,並且還能滑動?

基本上不到一分鐘,相信大家都能想出來答案:ScrollView,沒錯,也就只有 ScrollView 滿足我們的需求。

說干就干:

SingleChildScrollView(    physics: ClampingScrollPhysics(),    scrollDirection: Axis.horizontal,    controller: _controller,    child: Row(children: children),  )    ---------------  // 第一個 Widget,寬度為螢幕的寬度  SizedBox(    width: screenWidth,    child: child,  ),

1.首先把 ScrollView 滑動的位置改為橫向2.把滑動的效果改為 ClampingScrollPhysics,否則 iOS 會有回彈的效果3.設置一個 controller,用於監聽滑動距離4.設置child 為Row,並且第一個 Widget 充滿橫向螢幕,這樣後續的菜單就在螢幕外了

2. 菜單滑動到一定距離完全滑出,未達到距離回滾

這個效果就需要監聽滑動距離和手勢了。

如果滑動距離大於所有 menu 寬度的 1/4,那就全都滑出來,如果不到的話,就回滾回去。

那就要判斷一下手是否已經離開螢幕,在這個時候判斷距離。

本來想著套一個 Gesture,但是發現不行,問了一下大佬們,用了 Listener

程式碼如下:

Listener(    onPointerUp: (d) {      if (_controller.offset < (screenWidth / 5) * menu.length / 4) {        _controller.animateTo(0, duration: Duration(milliseconds: 100), curve: Curves.linear);      } else {        _controller.animateTo(menu.length * (screenWidth / 5), duration: Duration(milliseconds: 100), curve: Curves.linear);      }    },    child: SingleChildScrollView(      physics: ClampingScrollPhysics(),      scrollDirection: Axis.horizontal,      controller: _controller,      child: Row(children: children),    ),  )

很簡單,就是在 手抬起 的時候判斷了一下距離,然後調用了 animteTo 方法。

3. 菜單數量、樣式隨意訂製

這個其實很簡單,讓「用戶」來傳入就好了,

我只需要控制 menu 的寬度。

於是我把 Container 的參數都扒了下來,封裝成了一個組件 SlideMenuItem

class SlideMenuItem extends StatelessWidget {    SlideMenuItem({      Key key,      @required this.child,      this.alignment,      this.padding,      Color color,      Decoration decoration,      this.foregroundDecoration,      this.height,      BoxConstraints constraints,      this.margin,      this.transform,      @required this.onTap,    })  : assert(child != null),          assert(margin == null || margin.isNonNegative),          assert(padding == null || padding.isNonNegative),          assert(decoration == null || decoration.debugAssertIsValid()),          assert(constraints == null || constraints.debugAssertIsValid()),          assert(              color == null || decoration == null,              'Cannot provide both a color and a decorationn'              'The color argument is just a shorthand for "decoration: new BoxDecoration(color: color)".'),          decoration =              decoration ?? (color != null ? BoxDecoration(color: color) : null),          constraints = (height != null)              ? constraints?.tighten(height: height) ??                  BoxConstraints.tightFor(height: height)              : constraints,          super(key: key);      final BoxConstraints constraints;    final Decoration decoration;    final AlignmentGeometry alignment;    final EdgeInsets padding;    final Decoration foregroundDecoration;    final EdgeInsets margin;    final Matrix4 transform;    final Widget child;    final double height;    final GestureTapCallback onTap;      @override    Widget build(BuildContext context) {      return Container(        child: child,        alignment: alignment,        constraints: constraints,        decoration: decoration,        padding: padding,        width: screenWidth / 5,        height: height,        foregroundDecoration: foregroundDecoration,        margin: margin,        transform: transform,      );    }  }

這麼長的程式碼,其實就 「width」 和 「onTap」 是自己寫的。

4. 菜單點擊回調

這裡有個小問題:把 Menu 單獨封裝成了一個組件,那如何在點擊 menu 的時候把 menu 收回去?

基於這個問題,在創建整個 SlideItem 的時候,通過構造函數把每一個 menu 都添加上了 GestureDetector,然後在 onTap() 回調中調用 menu 的 onTap() 方法,再調用 dismiss() 方法來收回 menu。

程式碼如下:

addAll(menu         .map((w) => GestureDetector(           child: w,           onTap: (){             w.onTap();             dismiss();           },         ))         .toList());

5. 菜單展開時,點擊 item 收回菜單

也就是 菜單展開時,點擊了 item 的話,要先收回菜單。QQ 就是如此。

這裡有一個知識點,我們設置的點擊事件默認是不會命中透明組件的,所以要給第一個默認佔滿螢幕寬度的 Widget 加上一個屬性:behavior: HitTestBehavior.opaque

完整的程式碼如下:

GestureDetector(    behavior: HitTestBehavior.opaque,    onTap: (){      if(_controller.offset != 0){        dismiss();      }else{        onTap();      }    },    child: SizedBox(      width: screenWidth,      child: child,    ),  )

其中 behavior 有三個值:

•deferToChild:子組件會一個接一個的進行命中測試,如果子組件中有測試通過的,則當前組件通過,這就意味著,如果指針事件作用於子組件上時,其父級組件也肯定可以收到該事件。•opaque:在命中測試時,將當前組件當成不透明處理(即使本身是透明的),最終的效果相當於當前Widget的整個區域都是點擊區域。•translucent:當點擊組件透明區域時,可以對自身邊界內及底部可視區域都進行命中測試,這意味著點擊頂部組件透明區域時,頂部組件和底部組件都可以接收到事件。

總結

引用群里一大佬的話:

不要把問題複雜化。

其實對於這種效果,我們仔細的想一分鐘,幾乎都能想出來解決方案的。而且實現起來也很簡單。

本來想封裝成一個 ListView 的,後來感覺沒什麼必要,單獨封裝成一個 Item 也足夠用了。