背景:我所開發的應用是一個點餐的平板應用,有著大量從左邊或右邊開啟 drawer 的場景,最終完成的效果如圖所示
預設的 drawer 元件
flutter 預設的 drawer 是整合在
Scaffold
元件上的,簡單程式碼示例如下:Scaffold( drawer: Widget, // 從左邊彈起一個抽屜 endDrawer: Widget, // 從右邊彈起一個抽屜 );
- 關閉該抽屜可使用
Navigator.pop(context);
,從此可以看出開啟 Drawer 其實是開啟了一個新的路由頁面 除了使用上述 Navigator 的方式關閉抽屜,還有下面兩種方法參考自這裡,這兩種方法的原理都是透過獲取
ScaffoldState
物件然後呼叫其內部的 open、close 方法進行操作,下面是程式碼演示查詢父級最近的 Scaffold 對應的
ScaffoldState
物件ScaffoldState _state = context.findAncestorStateOfType<ScaffoldState>()!; // 開啟抽屜選單 _state.openDrawer(); // 或者直接使用 Scaffold.of(contenxt) 在 Flutter 開發中便有了一個預設的約定:如果 StatefulWidget 的狀態是希望暴露出的, 應當在 StatefulWidget 中提供一個`of` 靜態方法來獲取其 State 物件, 開發者便可直接透過該方法來獲取;如果 State不希望暴露,則不提供`of`方法
藉助 GlobalKey 來獲取
ScaffoldState
物件(我下面的自定義 drawer 就是藉助這種方式),程式碼演示如下:// 定義一個globalKey, 由於GlobalKey要保持全域性唯一性,我們使用靜態變數儲存 static GlobalKey<ScaffoldState> _globalKey= GlobalKey(); Scaffold( key: _globalKey , //設定key ... ) // 然後就可以這樣開啟 drawer 了 _globalKey.currentState.openDrawer()
上面的預設 drawer 使用方式介紹完了,很容易發現這種方式必須依賴
Scaffold
,但通常一個路由頁面只有一個 Scaffold 而且是在最外層,況且他只能接收一個 drawer (當然可以透過條件判斷來展示多個 drawer),如果是頁面中開啟 drawer 的場景特別多的話,使用起來就會特別麻煩,所以我寫了一個自定義的 drawer 元件
自定義 Drawer - RDrawer
- 基本思路是參考這篇文章,從上面的分析中我們得出開啟一個 drawer 其實就是開啟一個新的路由頁面(頁面背景是透明的可以看到上一個頁面的內容,flutter 裡面的
showDialog
,bottomSheet
都是這種處理) 先說一下 RDrawer 的使用方法
// 開啟drawer ElevatedButton(onPressed: () => RDrawer.open(Widget child)); // 關閉drawer ElevatedButton(onPressed: () => RDrawer.close()); // 此處 child 元件可根據自己的 UI 圖封裝一個包含 title、body、footer 的 DrawerBody 元件,讓外界更方便使用 // 開啟和關閉動作可以在任意地方使用不必依賴 Scaffold
實現思路
- 分析: 開啟、關閉路由頁面,抽屜開啟、關閉時的動畫(如果不考慮抽屜動畫就會變得非常簡單和 showDialog 沒啥兩樣)
開啟一個新的路由頁面主要依賴這個 Widget
PageRouteBuilder
,從 chatgpt 上知道他有這麼多屬性(感嘆一下 chatgpt 真是一個神器呀)Flutter 的 `PageRouteBuilder` 是一個用於自定義頁面過渡動畫的小部件,它可以讓開發者根據自己的需求建立各種自定義過渡動畫。以下是 `PageRouteBuilder` 中可用的引數: - `pageBuilder`: 必須提供一個 `WidgetBuilder` 函式,用於構建將要過渡到的頁面。 - `transitionDuration`: 定義頁面過渡的持續時間,型別為 `Duration`。 - `reverseTransitionDuration`: 定義頁面返回時的過渡持續時間,型別為 `Duration`。 - `transitionsBuilder`: 定義過渡動畫的方式,接受一個 `Widget` 和一個 `Animation<double>` 引數,返回一個 `Widget`。 - `opaque`: 定義頁面是否不透明,預設值為 `true`。 - `barrierDismissible`: 定義點選遮罩區域是否可以關閉頁面,預設值為 `false`。 - `barrierColor`: 定義遮罩區域的顏色,預設值為半透明黑色。 - `barrierLabel`: 定義遮罩區域的語義標籤,預設值為 `null`。 - `maintainState`: 定義頁面是否保持在記憶體中,預設值為 `true`。 - `fullscreenDialog`: 定義頁面是否是全屏對話方塊,預設值為 `false`。 這些引數可以幫助開發者建立各種自定義過渡動畫,並控制頁面過渡的各個方面,例如過渡時間、透明度、遮罩等。
- 開啟一個新的頁面
Navigator.of(Get.context!).push(PageRouteBuilder(...))
- 關閉一個新的新頁面
Navigator.pop(context);
其實如果不考慮抽屜動畫現在的工作已經完成了,但 drawer 怎麼可能沒有動畫,動畫藉助
AnimateBuilder
實現,利用 Tween 自定義一個動畫@override void initState() { super.initState(); controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); // drawer 寬度為 563,動畫是藉助 Stack 讓其從 -563 的位置到 0 animation = Tween<double>(begin: -563, end: 0).animate( CurvedAnimation(parent: controller, curve: Curves.easeInOut), ); controller.forward(); }
動畫的啟動時機是 initState 時這個不需要特殊處理,drawer關閉時機卻要需要等動畫完成時在執行
Navigator.pop(context);
這個地方處理就麻煩一點,大概思路是這樣void close() { controller.reverse().then((value) { Navigator.pop(context); }); }
但是這個 close 方法是寫在
DrawerState
物件裡面外界無法訪問到,這時候就需要藉助上文提到的 GlobalKey了,來讓外界能訪問到 close 方法,大概程式碼如下:// 建立一個型別為 DrawerState 的 GlobalKey static final GlobalKey<DrawerState> drawerStateKey = GlobalKey<DrawerState>(); /// 開啟 drawer static open(Widget child) { Navigator.of(Get.context!).push( PageRouteBuilder( // ... 引數省略 pageBuilder: (_, __, ___) => RDrawer(key: drawerStateKey, child: child), ), ); } /// 透過 drawerStateKey 來關閉 drawer static close() => drawerStateKey.currentState?.close();
完整程式碼如下
enum DrawerDirEnum { left, right } /// Drawer 核心元件 class RDrawer extends StatefulWidget { /// drawer寬度 final double width; /// 展開方向 final DrawerDirEnum dir; /// 點選遮罩層是否允許關閉 final bool? maskClose; /// drawer 內容,此處可在封裝一個 DrawerBody 來定製自己的樣式,更方便使用 final Widget child; const RDrawer({ super.key, required this.child, this.width = 536, this.dir = DrawerDirEnum.right, this.maskClose = false, }); // 定義用於訪問 state 物件的 key static final GlobalKey<DrawerState> drawerStateKey = GlobalKey<DrawerState>(); /// 開啟 drawer static open( Widget child, { DrawerDirEnum? dir = DrawerDirEnum.right, double? width = 536, bool? maskClose = false, }) { Navigator.of(Get.context!).push( // 具體引數含義上文已介紹過 PageRouteBuilder( opaque: false, transitionDuration: const Duration(milliseconds: 300), barrierColor: const Color.fromRGBO(0, 0, 0, 0.7), fullscreenDialog: true, pageBuilder: (_, __, ___) => RDrawer( // 很重要:繫結 globalKey 以是 DrawerState 能被外界訪問到 key: drawerStateKey, width: width!, dir: dir!, maskClose: maskClose!, child: child, ), ), ); } /// 關閉 drawer static close() => drawerStateKey.currentState?.close(); @override State<RDrawer> createState() => DrawerState(); } /// Drawer 核心邏輯 class DrawerState extends State<RDrawer> with SingleTickerProviderStateMixin { late AnimationController controller; late Animation<double> animation; @override void initState() { super.initState(); controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); animation = Tween<double>(begin: -widget.width, end: 0).animate( CurvedAnimation(parent: controller, curve: Curves.easeInOut), ); controller.forward(); } /// 關閉 drawer void close() { // 待抽屜動畫完成後在關閉頁面 controller.reverse().then((value) { Navigator.pop(context); }); } @override Widget build(BuildContext context) { return Stack( children: [ // 為了實現 drawer 關閉動畫不能直接藉助 barrierDismissible 來控制點選遮罩層 GestureDetector(onTap: () => widget.maskClose! ? close() : null), AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget? child) { return Positioned( top: 0, bottom: 0, right: widget.dir == DrawerDirEnum.right ? animation.value : null, left: widget.dir == DrawerDirEnum.left ? animation.value : null, child: SizedBox(width: widget.width, child: widget.child), ); }, ), ], ); } @override void dispose() { controller.dispose(); super.dispose(); } }