flutter - 自定義 Drawer 元件(不依賴 Scaffold)

大桔子發表於2023-03-26
  • 背景:我所開發的應用是一個點餐的平板應用,有著大量從左邊或右邊開啟 drawer 的場景,最終完成的效果如圖所示

    預設的 drawer 元件

  • flutter 預設的 drawer 是整合在 Scaffold 元件上的,簡單程式碼示例如下:

    Scaffold(
      drawer: Widget, // 從左邊彈起一個抽屜
      endDrawer: Widget, // 從右邊彈起一個抽屜
    );
  • 關閉該抽屜可使用 Navigator.pop(context); ,從此可以看出開啟 Drawer 其實是開啟了一個新的路由頁面
  • 除了使用上述 Navigator 的方式關閉抽屜,還有下面兩種方法參考自這裡,這兩種方法的原理都是透過獲取 ScaffoldState 物件然後呼叫其內部的 open、close 方法進行操作,下面是程式碼演示

    1. 查詢父級最近的 Scaffold 對應的 ScaffoldState 物件

      ScaffoldState _state = context.findAncestorStateOfType<ScaffoldState>()!;
      // 開啟抽屜選單
      _state.openDrawer();
      
      // 或者直接使用 Scaffold.of(contenxt)
      在 Flutter 開發中便有了一個預設的約定:如果 StatefulWidget 的狀態是希望暴露出的,
      應當在 StatefulWidget 中提供一個`of` 靜態方法來獲取其 State 物件,
      開發者便可直接透過該方法來獲取;如果 State不希望暴露,則不提供`of`方法
    2. 藉助 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

  1. 基本思路是參考這篇文章,從上面的分析中我們得出開啟一個 drawer 其實就是開啟一個新的路由頁面(頁面背景是透明的可以看到上一個頁面的內容,flutter 裡面的 showDialog, bottomSheet 都是這種處理)
  2. 先說一下 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();
      }
    }

相關文章