Flutter实践 | 动手撸一个FloatingActionMenu

226 阅读3分钟

效果图

截屏2023-01-18 11.55.13.png

截屏2023-01-18 11.55.22.png

前言

Hello,各位小伙伴,临近过年,导师不催写进度汇报了,就动手玩了几下Flutter,刚好我自己开发的软件需要新增一个创建文件夹的功能,再加上原来有的创建文件的功能,一共有两个功能了,因此原来的FloatingActionButton已经不能满足需求了。在掘金搜寻无果,决定自己撸了一个FloatingActionMenu。现在与大家一起交流。

正文

1. 创建dart文件

首先我们创建一个StatefulWidget。目前就暂定叫FloatingActionMenu吧。在创建好的空dart文件输入st,选中stful,Android Studio就会为我们创建好一个模板了。

截屏2023-01-18 12.08.35.png

class FloatingActionMenu extends StatefulWidget {
  const FloatingActionMenu({Key? key}) : super(key: key);

  @override
  State<FloatingActionMenu> createState() => _FloatingActionMenuState();
}

class _FloatingActionMenuState extends State<FloatingActionMenu> {
  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

2. 创建菜单

有了模板之后,我们就需要创建一个Container,把原来的菜单按钮显示出来先。

修改之后的 _FloatingActionMenuState 代码如下:

class _FloatingActionMenuState extends State<FloatingActionMenu> {
  @override
  Widget build(BuildContext context) {
    return mainButton();
  }

  Widget mainButton() {
    return Positioned(
      bottom: 0,
      child: Container(
        width: 56,
        height: 56,
        decoration: BoxDecoration(
            color: Colors.orange,
            borderRadius: BorderRadius.all(Radius.circular(56)),
        ),
        child: Center(
          child: Icon(
            Icons.add,
            color: Colors.white,
          ),
        ),
      ),
    );
  }
}

然后在main.dart下面,让Scaffold中的FloatingActionMenu传给 floatingActionButton

代码如下:

import "package:flutter/material.dart";
import 'package:learn/animation_widget/floating_action_menu.dart';

void main() {
  runApp(App());
}

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('小案例'),
        ),
        floatingActionButton: FloatingActionMenu(),
      ),
      //去掉右上角的debug贴纸
      debugShowCheckedModeBanner: false,
      theme: ThemeData(primarySwatch: Colors.blue),
    );
  }
}

效果图如下:

截屏2023-01-18 12.15.23.png

3. 创建子按钮

子按钮我们要定义好按钮的图标、背景、以及点击以后的回调事件。这里子按钮我们设置为了42其实是不对的,应该是40。起码Material Design官方是这样定义的。

class SubFloatActionButton extends StatelessWidget {
  final VoidCallback callback;
  final Color backgroundColor;
  final content;

  const SubFloatActionButton(
      {Key? key,
      required this.callback,
      required this.backgroundColor,
      required this.content})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
        onTap: callback,
        child: Container(
          width: 42,
          height: 42,
          decoration: BoxDecoration(
              color: backgroundColor,
              borderRadius: BorderRadius.all(Radius.circular(56)),
              boxShadow: [
                BoxShadow(
                    blurRadius: 10.0,
                    offset: Offset(0, 10),
                    color: backgroundColor.withAlpha(100))
              ]),
          child: content,
        ));
  }
}

4.创建主按钮动画

按钮展开以前是这样的, 截屏2023-01-18 12.24.28.png

展开以后是这样的, 截屏2023-01-18 12.24.39.png

小伙伴应该已经猜到要用什么了,对,Flutter动画。

使用Flutter中的Animation就需要混入一个TickerProviderStateMixin,因此State继承代码修改如下:

class _FloatingActionMenuState extends State<FloatingActionMenu>
    with TickerProviderStateMixin{}

然后我们声明两个私有变量:

late AnimationController _animationController; // 控制动画的类
late Animation<double> _animation;

并在initState中完成初始化:

_animationController =
    AnimationController(vsync: this, duration: Duration(milliseconds: 500));
_animation = Tween(begin: 0.0, end: 1.0).animate(_animationController);

随后,在原来的mainButton基础上,将Icon改成AnimatedIcon

AnimatedIcon(
  color: Colors.white,
  icon: AnimatedIcons.menu_close,
  progress: _animation,
)

最终把菜单按钮改为如下:

Widget mainButton() {
  return Positioned( // 这里的Position后续会增加,可先删除
    bottom: 0,
    child: Hero(
      tag: widget.heroTag, // 这里后续会增加,可先删除
      child: GestureDetector(
          onTap: () {
            if (_animationController.isCompleted) {
              _closeMenu();
              setState(() {
                _left = 0.0;
              });
            } else {
              _openMenu();
              setState(() {
                _left = 1.0;
              });
            }
            print("点击 $_left");
          },
          child: Container(
            width: 56,
            height: 56,
            decoration: BoxDecoration(
                color: widget.backgroundColor,
                borderRadius: BorderRadius.all(Radius.circular(56)),
                boxShadow: [
                  BoxShadow(
                      blurRadius: 10.0,
                      offset: Offset(0, 10),
                      color: widget.backgroundColor.withAlpha(100))
                ]),
            child: Center(
              child: AnimatedIcon(
                color: Colors.white,
                icon: AnimatedIcons.menu_close,
                progress: _animation,
              ),
            ),
          )),
    ),
  );
}

随后增加点击事件,用于动画控制器的顺序播放和倒序播放。代码如下:

_openMenu() {
  _animationController.forward();
}

_closeMenu() {
  _animationController.reverse();
}

5.创建按钮布局

截屏2023-01-18 14.12.39.png

在创建布局之前,先将封装好。

class FloatingActionMenu extends StatefulWidget {
  final Color backgroundColor; // 菜单的背景颜色
  final String heroTag; //共享动画
  final List<SubFloatActionButton> children; // 子按钮,即那个小按钮

  FloatingActionMenu(
      {Key? key,
      required this.backgroundColor,
      required this.children,
      this.heroTag = 'JayFloatActionButton'})
      : super(key: key);

  @override
  State<FloatingActionMenu> createState() => _FloatingActionMenuState();
}

我们这里主要用一个Stack来进行布局。

build中的代码修改如下:

@override
Widget build(BuildContext context) {
  // 得在这里装载数据,不然动画不显示
  _buttons.clear();
  for (int i = 0; i < widget.children.length; i++) {
    _buttons.add(subButton(widget.children[i], i));
  }
  _buttons.add(mainButton());
  return Container(
    height: 200,
    width: 56,
    // color: Colors.grey,
    child: Stack(children: _buttons),
  );
}

6.添加动画

首先添加一个按钮链表

List<Widget> _buttons = [];

添加一个subButton,用来包装原来的按钮,添加动画组件。需要变化的时候,直接setState()就可以了。代码如下:

double _left = 0.0;

Widget subButton(SubFloatActionButton subFloatActionButton, int i) {
  return AnimatedPositioned(
    bottom: (56 + (10.0 * (i + 1)) + 42 * i) * _left,
    left: (56 - 42) / 2,
    duration: Duration(milliseconds: 300),
    child: FadeTransition(
      opacity: _animation,
      child: subFloatActionButton,
    ),
  );
}

全部代码

所有代码如下,可以直接使用了。

FloatingActionMenu所有代码

import 'package:flutter/material.dart';

class FloatingActionMenu extends StatefulWidget {
  final Color backgroundColor;
  final String heroTag;
  final List<SubFloatActionButton> children;

  FloatingActionMenu(
      {Key? key,
      required this.backgroundColor,
      required this.children,
      this.heroTag = 'JayFloatActionButton'})
      : super(key: key);

  @override
  State<FloatingActionMenu> createState() => _FloatingActionMenuState();
}

class _FloatingActionMenuState extends State<FloatingActionMenu>
    with TickerProviderStateMixin {
  late AnimationController _animationController; // 控制动画的类
  late Animation<double> _animation;
  List<Widget> _buttons = [];

  @override
  void initState() {
    // if(widget.children!=null) {
    // }
    _animationController =
        AnimationController(vsync: this, duration: Duration(milliseconds: 500));
    _animation = Tween(begin: 0.0, end: 1.0).animate(_animationController);

    // for (int i = 0; i < widget.children.length; i++) {
    //   _buttons.add(subButton(widget.children[i], i));
    // }
    // _buttons.add(mainButton());
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    _buttons.clear();
    for (int i = 0; i < widget.children.length; i++) {
      _buttons.add(subButton(widget.children[i], i));
    }
    _buttons.add(mainButton());
    return Container(
      height: 200,
      width: 56,
      // color: Colors.grey,
      child: Stack(children: _buttons),
    );
  }

  double _left = 0.0;

  Widget subButton(SubFloatActionButton subFloatActionButton, int i) {
    return AnimatedPositioned(
      bottom: (56 + (10.0 * (i + 1)) + 42 * i) * _left,
      left: (56 - 42) / 2,
      duration: Duration(milliseconds: 300),
      child: FadeTransition(
        opacity: _animation,
        child: subFloatActionButton,
      ),
    );
  }

  Widget mainButton() {
    return Positioned(
      bottom: 0,
      child: Hero(
        tag: widget.heroTag,
        child: GestureDetector(
            onTap: () {
              if (_animationController.isCompleted) {
                _closeMenu();
                setState(() {
                  _left = 0.0;
                });
              } else {
                _openMenu();
                setState(() {
                  _left = 1.0;
                });
              }
              print("点击 $_left");
            },
            child: Container(
              width: 56,
              height: 56,
              decoration: BoxDecoration(
                  color: widget.backgroundColor,
                  borderRadius: BorderRadius.all(Radius.circular(56)),
                  boxShadow: [
                    BoxShadow(
                        blurRadius: 10.0,
                        offset: Offset(0, 10),
                        color: widget.backgroundColor.withAlpha(100))
                  ]),
              child: Center(
                child: AnimatedIcon(
                  color: Colors.white,
                  icon: AnimatedIcons.menu_close,
                  progress: _animation,
                ),
              ),
            )),
      ),
    );
  }

  _openMenu() {
    _animationController.forward();
  }

  _closeMenu() {
    _animationController.reverse();
  }
}

class SubFloatActionButton extends StatelessWidget {
  final VoidCallback callback;
  final Color backgroundColor;
  final content;

  const SubFloatActionButton(
      {Key? key,
      required this.callback,
      required this.backgroundColor,
      required this.content})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
        onTap: callback,
        child: Container(
          width: 42,
          height: 42,
          decoration: BoxDecoration(
              color: backgroundColor,
              borderRadius: BorderRadius.all(Radius.circular(56)),
              boxShadow: [
                BoxShadow(
                    blurRadius: 10.0,
                    offset: Offset(0, 10),
                    color: backgroundColor.withAlpha(100))
              ]),
          child: content,
        ));
  }
}

使用

import "package:flutter/material.dart";
import 'package:learn/animation_widget/float_action_menu.dart';

void main() {
  runApp(App());
}

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('小案例'),
        ),
        // body: CustomCheckboxTest(),
        floatingActionButton: FloatingActionMenu(
          backgroundColor: Colors.orange,
          children: [
            SubFloatActionButton(
                callback: () {},
                backgroundColor: Colors.orange,
                content: Icon(
                  Icons.file_open,
                  color: Colors.white,
                )),
            SubFloatActionButton(
                callback: () {
                  print("hello");
                },
                backgroundColor: Colors.orange,
                content: Icon(
                  Icons.folder,
                  color: Colors.white,
                )),
          ],
        ),
      ),
      //去掉右上角的debug贴纸
      debugShowCheckedModeBanner: false,
      theme: ThemeData(primarySwatch: Colors.blue),
    );
  }
}