Flutter:手把手教你實現一個仿QQ側滑選單的功能
一個類似於QQ側滑選單的功能,支援從上、下、左、右四個方法開啟選單欄。可以通過自定義transform實現更加炫酷的動效!
先上效果圖:



Github地址: ofollow,noindex">github.com/yumi0629/Sl…
使用方法:
SlideStack( child: SlideContainer( key: _slideKey, child: Container( /// widget mian. ), slideDirection: SlideDirection.top, onSlide: onSlide, drawerSize: maxSlideDistance, transform: transform, ), drawer: Container( /// widget drawer. ), ); 複製程式碼
slideDirection
屬性用來控制選單從哪個方法開啟;呼叫 key.currentState.openOrClose()
方法可以手動開啟或關閉選單;配合transform屬性和滑動過程中返回的監聽值,可以在動畫過程中為佈局新增各種個樣的變換。
實現分析
用Flutter實現這樣的一個效果其實很簡單,300行程式碼足矣。側滑選單的實現其實就是上層佈局隨著使用者手勢,更改自身的位置,從而讓底層選單欄展示出來。明白了這麼一個過程之後,一切就都好辦了。
基本思路:上下兩層佈局用Stack組合,上層佈局需要支援手勢,下層佈局只需要是一個普通佈局就可以了。所以難點就是,上層佈局如何支援手勢?關於Flutter中的手勢可以看下這篇文章: 解析Flutter中的手勢控制Gestures ,瞭解一下GestureRecognizer是什麼。當然,我們實現簡單的側滑功能並不需要這麼複雜,因為沒有涉及到滑動衝突,我們只需使用系統自帶的 HorizontalDragGestureRecognizer
類就可以了。上層佈局每一幀的變換進度使用 AnimationController
來控制,其回撥中的value值可以讓我們很方便的就獲取到動畫的進度值。
上層佈局的實現
Step 1 註冊手勢監聽Recognizer
首先,我們給我們的自定義佈局註冊手勢監聽Recognizer, _registerGestureRecognizer()
方法在佈局的 initState()
方法中執行:
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{}; void _registerGestureRecognizer() { if (isSlideVertical) { gestures[VerticalDragGestureRecognizer] = createGestureRecognizer<VerticalDragGestureRecognizer>( () => VerticalDragGestureRecognizer()); } else { gestures[HorizontalDragGestureRecognizer] = createGestureRecognizer<HorizontalDragGestureRecognizer>( () => HorizontalDragGestureRecognizer()); } } GestureRecognizerFactoryWithHandlers<T> createGestureRecognizer<T extends DragGestureRecognizer>( GestureRecognizerFactoryConstructor<T> constructor) => GestureRecognizerFactoryWithHandlers<T>( constructor, (T instance) { instance ..onStart = handleDragStart ..onUpdate = handleDragUpdate ..onEnd = handleDragEnd; }, ); 複製程式碼
Step 2 繫結Ticker和AnimationController
我們有了Recognizer,怎麼跟使用者的手勢繫結起來呢?這裡用到了 AnimationController
和 Ticker
類。
AnimationController animationController; Ticker fingerTicker; @override void initState() { animationController = AnimationController(vsync: this, duration: widget.autoSlideDuration) ..addListener(() { ······ // 重新整理上層佈局位置 setState(() {}); }); fingerTicker = createTicker((_) { ······ // 更具使用者手勢移動位置,更新animationController.value animationController.value = ······; }); _registerGestureRecognizer(); super.initState(); } 複製程式碼
很明顯,使用者的手勢滑動時會產生一個滑動值,我們將這個滑動值進行計算,再賦值給animationController.value;同時計算出上層佈局需要的偏移量,通過呼叫 setState(() {});
重新整理上層佈局位置。
Step 3 構建基本控制元件
所以,build函式的返回值就很好定義了,因為有手勢,我們最外層包裹一個 RawGestureDetector
,然後將我們在Step 1中註冊的gestures傳進去,表示這個控制元件之後將會接收垂直/水平方向的gestures。因為上層佈局涉及到位置的移動,因此我們選擇使用Transform來構建。每次使用者手指滑動時,產生一個dragValue,通過該值計算出控制元件應該偏移的值,我們將其儲存為containerOffset,將這個containerOffset傳給Transform,setState時就會產生頁面上的移動視覺效果了。
@override Widget build(BuildContext context) => RawGestureDetector( gestures: gestures, child: Transform.translate( offset: isSlideVertical ? Offset( 0.0, containerOffset, ) : Offset( containerOffset, 0.0, ), child: _getContainer(), ), ); 複製程式碼
Step 4 計算偏移距離
到目前為止,大致的實現框架已經出來了,接下來就是計算部分了。
首先,我們的containerOffset其實就是dragValue,很好理解。
double get containerOffset =>dragValue; 複製程式碼
其次是滑動(動畫)的進度,很簡單, dragValue / maxDragDistance
,也就是 拖動距離/總距離(Drawer的寬度/高度)
。
fingerTicker = createTicker((_) { animationController.value = dragValue / maxDragDistance; }); 複製程式碼
這裡有人可能會有一個疑問了,我根據dragValue,直接算出了containerOffset,然後讓上層控制元件移動位置,整個過程不久OK了嘛,還要什麼AnimationController幹嘛?確實,animationController只是起到了一個記錄作用。我們之所以要用到animationController,一是可以通過AnimationController將拖動進度返回給最外層的父控制元件,還有一個原因是,可以通過animationController去快速完成/取消滑動動作。
AnimationController好處都有啥,看下面:
void openOrClose() { final AnimationStatus status = animationController.status; final bool isOpen = status == AnimationStatus.completed || status == AnimationStatus.forward; animationController.fling(velocity: isOpen ? -2.0 : 2.0); } void _completeSlide() => animationController.forward().then((_) { if (widget.onSlideCompleted != null) widget.onSlideCompleted(); }); void _cancelSlide() => animationController.reverse().then((_) { if (widget.onSlideCanceled != null) widget.onSlideCanceled(); }); 複製程式碼
我們可以很方便的通過AnimationController提供的API,在使用者拖動到一半,或者說使用者點選了某個按鈕來開啟/關閉選單時,快速地完成開啟/關閉操作,而不是手動的不停的重新整理containerOffset。所以說,AnimationController是一個未雨綢繆的設計,因為這不是一個單純地佈局跟著使用者手勢動就OK了的控制元件,我們需要一個控制器來自由地控制佈局的位置。
Step 5 實現使用者拖動到一半時自動完成/取消操作
實際使用中,我們經常會碰到一個問題,就是使用者的手指並沒有完全滑動到maxDragDistance這個值,可能化到一半就停止了。那麼我們的上層控制元件應該怎麼做呢?將佈局位置定位在使用者手勢停止的地方明顯是不友好的。QQ側滑選單的解決方案是:使用者手指超過了某個邊界值則自動完成開啟操作;若未達到邊界值,則取消這個開啟操作:

handleDragEnd
方法,這個方法在Step 1中註冊GestureRecognizer時,我們將其傳入了Recognizer的onEnd回撥監聽中,
minAutoSlideDragVelocity
就是我們定義的這個邊界值:
void handleDragUpdate(DragUpdateDetails details) { if (dragValue > widget.minAutoSlideDragVelocity) { _completeSlide(); } else if (dragValue < widget.minAutoSlideDragVelocity) { _cancelSlide(); } fingerTicker.stop(); } 複製程式碼
合併上、下層控制元件
這個很簡單,之前已經提到了,使用Stack佈局時最簡單的方法了:
class SlideStack extends StatefulWidget { /// The main widget. final SlideContainer child; /// The drawer hidden below. final Widget drawer; const SlideStack({ @required this.child, @required this.drawer, }) : super(); @override State<StatefulWidget> createState() => _StackState(); } class _StackState extends State<SlideStack> { @override Widget build(BuildContext context) { return Stack( children: <Widget>[ widget.drawer, widget.child, ], ); } } 複製程式碼
細節修飾
到此為止,我們已經完成了90%的工作了,接下來就是修飾一些細節了,我們新增一些屬性,讓側滑選單體驗更加友好。這部分具體的請看 原始碼 。
- 給上層佈局新增陰影:參考
shadowBlurRadius
和shadowSpreadRadius
屬性; - 新增阻尼係數
dragDampening
,這個引數在我們做List滑動的時候很常見,佈局的實際移動距離,跟使用者手指的移動距離往往是不一致的,我們可以通過這個阻尼係數來控制; - 新增自定義
transform
,我們上面的實現都只是將上層佈局進行了平移,如果需要實現效果圖1中的平移+縮小效果,需要新增自定義的transform。之所以沒有將縮小效果包裹進控制元件,是因為我希望控制元件的形變可以更為靈活,大家可以從外部去控制,而不是直接寫死。而且我已經通過AnimationController將動畫進度暴露出來了,通過動畫進度可以很方便的進行各種你想要的transform。 - 新增進度回撥監聽
onSlideStarted
、onSlideCompleted
、onSlideCanceled
、onSlide
。