likes
comments
collection
share

flutter - 自定义 Drawer 组件(不依赖 Scaffold)

作者站长头像
站长
· 阅读数 63

这是 flutter 开发过程的第一次记录

  • 背景:我所开发的应用是一个点餐的平板应用,有着大量从左边或右边打开 drawer 的场景,最终完成的效果如上图所示

默认的 drawer 组件

  1. flutter 默认的 drawer 是集成在 Scaffold 组件上的,简单代码示例如下:
    Scaffold(
      drawer: Widget, // 从左边弹起一个抽屉
      endDrawer: Widget, // 从右边弹起一个抽屉
    );
    
  2. 关闭该抽屉可使用 Navigator.pop(context); ,从此可以看出打开 Drawer 其实是打开了一个新的路由页面
  3. 除了使用上述 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()
      
  4. 上面的默认 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();
  }
}
转载自:https://juejin.cn/post/7212101193437954104
评论
请登录